Skip to content

feat(config): hash the governing config surface into a config_epoch (CLOUD-32) - #164

Merged
wenzowski merged 1 commit into
mainfrom
claude/cloud-32-config-epoch
Aug 8, 2026
Merged

wenzowski merged 1 commit into
mainfrom
claude/cloud-32-config-epoch

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes CLOUD-32.

Revised after the first push. CLOUD-32's Ready block was substantially rewritten while this was in flight, and it contradicted the first commit in four places. This PR implements the revised block — see What changed below.

Why

Policy changes need to be attributable after the fact. The epoch is a deterministic hash of the files that govern a run, so two records carrying the same epoch were produced under provably the same rules — and one carrying a different epoch was not.

$ batten config epoch
9a710f88da98bac3d84a724ebfbae88f463e23f8dd8f138f122ce4eff5e6dcd3

What the revised Ready block changed

Clause First commit Now
unreadable tracked path exit 3 exit 1, naming the path on stderr
hash construction its own hasher in epoch.rs identity::surface_fingerprint — one shared construction
which authority is hashed working tree always follows --config-from <ref>
min_batten_version left at 0.0.0 raised to 0.0.30

The exit-code change is the substantive one, and the argument is right: the [epoch] tracked set is config — it defaults to batten.toml itself — so an unreadable tracked path is unreadable config, which §7 routes to 1. 3 stays for an I/O failure not attributable to the config. Landed precedent is config_trust.rs's a_ref_with_no_config_is_a_usage_error: the same shape, a configured input this binary cannot read, refused by name. A refusal the reader cannot act on is barely better than a silent skip.

The tracked set is config, never code

Which files govern a repository is that repository's business — an agent settings file, a contributor guide, a hook config, each meaningful in one repository and meaningless in the next. So the set is [epoch] tracked, and the core carries only the default: batten.toml itself, the one file that governs every consumer by definition. Batten's own list lives in Batten's own config, as consumer #1.

Rule 1 now holds as a grep, not just in spirit

My first draft named this repository's files (AGENTS.md, hk.pkl, .claude/settings.json) in crates/batten doc comments and one test fixture. Prose and fixtures rather than behaviour — but rule 1 states its predicate as "a grep for a specific consumer's names must return zero hits", and a rule stated that way is one a gate can be written on.

$ git grep -nE "AGENTS\.md|\.claude/settings|hk\.pkl" -- crates/batten/ ; echo $?
1

One hash construction, not two

tagged_fingerprint and write_field are lifted out of identity.rs's fingerprint_of, so findings and the epoch share one length-prefixed SHA-256 framing rather than two that can drift. Three properties follow, each asserted:

  • A rename cannot be hidden by choosing names. Without the length prefix, ("ab","c") and ("a","bc") hash identically.
  • Authoring order never reaches the value. Paths canonicalized, sorted, deduplicated.
  • A CRLF checkout attributes identically. Text is NFC/LF-canonicalized; non-UTF-8 content is hashed verbatim, since there is nothing to normalize and refusing it would make the tracked set silently text-only.

And the complements: the set is part of the identity (the path is hashed as well as the bytes, so tracking one more file moves the epoch even when it is empty), while an untracked file does not move it — the epoch attributes policy, not the working tree.

It follows --config-from

Under a base ref, both the tracked list and the bytes come from the ref, never the working tree. An epoch attributing a run to a config that did not govern it would be worse than none. under_config_from_the_epoch_is_the_refs_surface_not_the_working_trees edits the working tree, confirms the working-tree epoch moved, and asserts the ref's epoch did not.

min_batten_version raised off 0.0.0

[epoch] is a new key, and an older binary must refuse it, not ignore it. deny_unknown_fields is what actually forces that; the floor states the intent so the refusal names the version rather than the key.

House style §2

epoch is added to §2's config row in the same change. §11 makes the shipped binary the emitter of the spec, not a warrant for the binary to outrun the doc, and CLOUD-19 settled §2 as authoritative for the surface — so the surface.rs row and the doc row land together.

Two surfaces that must agree

batten config epoch and doctor --json's config_epoch, with a test asserting they report the same value — the join CLOUD-133 will key guard records on. doctor omits the field when the surface cannot be read rather than emitting a placeholder (a placeholder would be a stable value over an unknown surface), and still exits 1 rather than 3, so it stays the config-or-usage-only verb §7 needs it to be.

Scope

The value only. Stamping it onto guard records is CLOUD-133's, which defines the record. §8's cache and etag-style revalidation are CLOUD-232's. What lands here is recompute-per-invocation — the reference implementation that cannot go stale, and §7(a) pins its output so any later cache must reproduce a cold recompute byte-for-byte.

Tests

  • crates/batten/tests/config_epoch.rs (new file — tests/cli.rs is the exit-code suite and other work appends to it): 15 E2E tests over the compiled binary, including that this repository's own tracked surface is computable.
  • epoch.rs unit tests: framing, sorted/deduped, CRLF-stable, refused-by-name.
  • editing_any_tracked_file_changes_the_epoch iterates every tracked file — a fold that hashed only the first entry would pass a single-file test and be wrong for every real tracked set.

mise run verify green on this SHA, rebased on current origin/main. mise run deny clean.

@linear-code

linear-code Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
CLOUD-32 Implement `config_epoch` hashing and stamp every guard log line

Why
Policy changes need to be attributable after the fact. The governing config surface should hash to an epoch value recorded with every guard decision.

Definition of done

  • Hash the tracked config surface such as .claude/settings.json, AGENTS.md, hk.pkl, and batten.toml
  • Attach the epoch to every guard and decision record — moved to CLOUD-133: there is no guard or decision record to attach to yet. See the Scope decision.

Acceptance

  • Changing any tracked file changes the epoch
  • Guard records include the epoch and tests assert the join — not a bar for this issue. The stamp-join is owned by CLOUD-133, whose own Acceptance carries the test; §7 below names no join case, deliberately. Read §5 and §7 as the completion predicate, not this line.

Refinement — Ready (scoped to epoch computation; the input set is config-driven, not a hardcoded file list)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

Scope decision. This issue delivers the config_epoch value — a deterministic hash of the governing config surface — and exposes it as a new config epoch row in the one command table (crates/batten/src/surface.rs, house style §11). No doctor surface is claimed: doctor is spec'd in house style §2 but is not in the binary's command table today, so citing it would name an unbuilt dependency. Stamping the epoch onto "every guard log line" is not implementable here, and not merely for sequencing: no guard log exists — hook adjudicates in crates/batten/src/hook.rs but emits no decision record, and receipt records verification claims, not decisions. The record's schema is defined by CLOUD-133 (still in Backlog, unmilestoned). The join-on-epoch assertion is an acceptance clause on CLOUD-133, recorded on the board as blocks (this issue blocks it), so the obligation isn't lost. This keeps CLOUD-32 inside the Phase-1 config bundle instead of dragging a cross-phase blocker in.

House style §8 names three properties for config_epoch; this issue lands one. The paragraph asks for a hash stamped on every guard log line (descoped above), one content-addressed and cached until a new hash supersedes it, and cheap drift-detection as an etag-style revalidation between runs. Content-addressing needs nothing extra — the epoch is the content address. The cache and the cross-run revalidation are neither implemented here nor waved away: the engine has no caching layer at all (grep -riE 'etag|revalidat|cache' --include='*.rs' crates/ is empty), so they are a capability the engine does not yet have, and DoR §2's amendment requires the gap be linked rather than absorbed — CLOUD-232, blocks on the board. What lands here is the reference implementation: recompute per invocation, the only one that cannot go stale. §7(a) pins its output, so any later cache must reproduce a cold recompute byte-for-byte or fail a test that already exists.

§2 divergence, named rather than glossed. config epoch is not in house style §2's config show | schema | lint row, and §11 does not license the addition — §11 makes the shipped binary the emitter of the spec, not a warrant for the binary to outrun the doc. CLOUD-19 (Done) settles which way that resolves: §2's tree is the single source of truth for the surface and is authoritative, so the change that adds the surface.rs row adds epoch to §2's config row in the same move — never a verb in the binary the doc does not name. relatedTo, not a blocker: §2 is a document this change edits, not work it waits on.

Repo-agnostic input set (non-negotiable rule 1). The issue's example list (.claude/settings.json, AGENTS.md, hk.pkl, …) is consumer-specific and must not live in crates/batten. The epoch instead hashes a set of paths declared in batten.toml (e.g. [epoch] tracked = […]), defaulting to batten.toml itself. Batten's own tracked list becomes an example in Batten's own config — consumer #1 — not core code. A grep of the core for those identifiers stays at zero hits.

  • Source of truth (§1). batten.toml's [epoch] tracked list; the epoch is derived from it, nothing re-typed. The hash construction is not re-typed either: it reuses crates/batten/src/identity.rs (length-prefixed SHA-256 over NFC/LF-canonicalized bytes, canonical_repo_path for the path field) instead of minting a second divergent hash of the same bytes — the constraint CLOUD-133 states for its subject field, and the precedent receipt.rs already follows via scope_fingerprint. Which authority gets hashed follows --config-from <ref> (CLOUD-31, landed): the epoch covers the config that actually governed the run, never the working tree when a base ref was in force.
  • Computable predicate (§2, policy-engine-first). The epoch is a pure function of the tracked files' bytes; determinism is asserted by a mise run test case (same input → same epoch; changing any tracked file changes it), byte-stable across environments and running in both the hk gate and CI. The amendment's bash-layer failure mode does not arise here: the mechanism lands in the engine — crates/batten plus an [epoch] table in batten.toml — never as a mise-tasks/ script, so this issue's gate needs no capability the engine lacks. (The capability gap this issue does carry is §8's cache and revalidation, linked above as CLOUD-232 — an undelivered spec property, not a missing gate.) This issue grows the capability that later batten.toml rules and CLOUD-133's records consume.
  • Effect (§3). Computing/printing the epoch is read.
  • Generated artifacts + drift gate (§4). Both derived surfaces this touches now have byte-for-byte gates (CLOUD-33 landed): [epoch] is a new Config field, so schema/batten.schema.json is regenerated by batten generate schema and diffed by mise run schema-check; the config epoch row changes the emitted spec, so completions/ is regenerated and diffed by mise run completions-check. Neither committed copy is hand-edited. Batten's own batten.toml raises min_batten_version off 0.0.0 when it starts declaring [epoch], because Config is deny_unknown_fields — an older binary must refuse the key, not ignore it.
  • Output & exit (§5). The epoch is a single byte-stable hash string (a pointer, not content); Success (0). A tracked path that cannot be read is a config error — Usage (1), not Internal (3): the [epoch] tracked set is config, defaulting to batten.toml itself, so in the default configuration an unreadable tracked path is unreadable config, which crates/batten/src/exit.rs:27 and house style §7 both route to 1. Landed precedent for exactly this shape: crates/batten/tests/config_trust.rs:301 (a_ref_with_no_config_is_a_usage_error) asserts exit 1 and requires the refusal to name the path it could not read. 3 is left for an I/O failure not attributable to the config. What no code may be is 0 over a silent skip that would forge a stable epoch across a changed surface — and never 2: the epoch is a value, not a policy verdict.
  • Commit / bump (§6). feat → patch until 0.1.0 (DoR §6: below 0.1.0 release-plz bumps the patch whatever the type says).
  • Test obligation (§7). E2E over the compiled binary: (a) batten config epoch is stable across two runs on an unchanged tree; (b) editing any file in [epoch] tracked changes the printed epoch; (c) a tracked path removed/unreadable yields exit 1 with the path named on stderr, not a stale-but-successful hash; (d) under --config-from <ref> the epoch printed is the ref's config surface, not the working tree's.
  • Blockers (§8). blockedBy CLOUD-29 (closed) — the typed loader landed 2026-08-07, so the parsed [epoch] config this needs already exists and nothing holds this out of the ready queue. blocks CLOUD-133 (carries the stamp-onto-guard-record obligation). blocks CLOUD-232 (§8's cache and etag-style revalidation, which need the value first). relatedTo CLOUD-31 — the epoch is computed over whichever authority --config-from resolved; CLOUD-19 — the settled §2 surface this adds a row to.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 8, 2026 06:30
@wenzowski
wenzowski force-pushed the claude/cloud-32-config-epoch branch from 50ca9a7 to 3e5ec16 Compare August 8, 2026 15:34
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski

Copy link
Copy Markdown
Contributor Author

Triggered from #164 (comment) by @​wenzowski.

Trying to fast forward main (2d2237a) to claude/cloud-32-config-epoch (3e5ec16).

Target branch (main):

commit 2d2237a2efede309dd9529bd5849e59ff162630a (HEAD -> main, origin/main)
Author: Alec Wenzowski <alec@button.is>
Date:   Sat Aug 8 08:32:59 2026 -0700

    chore: release v0.0.32

Pull request (claude/cloud-32-config-epoch):

commit 3e5ec166763c815090a2fc0fa9febbaad72eb029 (pull_request/claude/cloud-32-config-epoch)
Author: Claude <noreply@anthropic.com>
Date:   Sat Aug 8 06:28:43 2026 +0000

    feat(config): hash the governing config surface into a config_epoch (CLOUD-32)
    
    Policy changes need to be attributable after the fact. The epoch is a
    deterministic hash of the files that GOVERN a run, so two records
    carrying the same epoch were produced under provably the same rules, and
    one carrying a different epoch was not.
    
    The tracked set is config, never code. Which files govern a repository is
    that repository's business — an agent settings file, a contributor guide,
    a hook config, each meaningful in one repository and meaningless in the
    next. So the set is `[epoch] tracked` in batten.toml, and the core
    carries only the default: batten.toml itself, the one file that governs
    every consumer by definition. Batten's own list lives in Batten's own
    config, as consumer #1, which is where a worked example belongs.
    
    That keeps non-negotiable rule 1 true as a GREP, not merely in spirit.
    The first draft named this repo's files in doc comments and in a test
    fixture — prose and fixtures, not behaviour, but the rule states its
    predicate as "a grep returns zero hits", and a rule stated that way is
    one a gate can be written on. Both are now generic; `git grep` over
    crates/batten for any of those names returns nothing.
    
    An unreadable tracked path is exit 1, NAMING THE PATH, never a skip. The
    tracked set IS config — it defaults to batten.toml itself — so an
    unreadable tracked path is unreadable config, which §7 routes to 1; 3
    stays for an I/O failure not attributable to the config. Same shape as
    config_trust's unreadable-ref case, which also names what it could not
    read: a refusal the reader cannot act on is barely better than a silent
    skip, and a skip would compute a STABLE epoch over a changed surface,
    which looks exactly like a valid answer.
    
    The epoch follows --config-from. Under a base ref both the tracked list
    and the bytes come from the ref, never the working tree: an epoch
    attributing a run to a config that did not govern it would be worse than
    none.
    
    The construction is identity::surface_fingerprint, not a second hash of
    the same bytes. tagged_fingerprint and write_field are lifted out of
    fingerprint_of, so findings and the epoch share ONE length-prefixed
    SHA-256 framing rather than two that can drift. Without the length prefix
    ("ab","c") and ("a","bc") hash identically, so a rename could be hidden
    by choosing names — asserted directly. Text is NFC/LF-canonicalized so a
    CRLF checkout attributes identically; non-UTF-8 content is hashed
    verbatim, since there is nothing to normalize and refusing it would make
    the tracked set silently text-only. Paths are canonicalized, sorted and
    deduplicated, and the path is hashed as well as the bytes, so adding an
    empty file to the tracked set still moves the epoch — the SET is part of
    what governs.
    
    batten.toml raises min_batten_version off 0.0.0 now that it declares
    `[epoch]`: a new key must be REFUSED by an older binary, never ignored.
    deny_unknown_fields is what forces that; the floor states the intent so
    the refusal names the version rather than the key.
    
    House style §2 gains `epoch` on its `config` row in the same move — §11
    makes the binary the emitter of the spec, not a warrant to outrun the
    doc, and CLOUD-19 settled §2 as authoritative for the surface.
    
    Surfaced two ways that must agree: `batten config epoch` and
    doctor --json's config_epoch, with a test asserting the join CLOUD-133
    will key guard records on. doctor OMITS the field when the surface cannot
    be read rather than emitting a placeholder — a placeholder would be a
    stable value over an unknown surface — and still exits 1 rather than 3,
    so it stays the config-or-usage-only verb §7 needs it to be.
    
    Scope: the value only. Stamping onto guard records is CLOUD-133's, which
    defines the record; §8's cache and etag-style revalidation are
    CLOUD-232's. What lands here is recompute-per-invocation, the reference
    implementation that cannot go stale.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_0119C7XbeQLWR5TTaW2ca6Gp

Can't fast forward main (2d2237a) to claude/cloud-32-config-epoch (3e5ec16). main (2d2237a) is not a direct ancestor of claude/cloud-32-config-epoch (3e5ec16). Branches appear to have diverged at 312b320:

* 2d2237a2efede309dd9529bd5849e59ff162630a chore: release v0.0.32
| * 3e5ec166763c815090a2fc0fa9febbaad72eb029 feat(config): hash the governing config surface into a config_epoch (CLOUD-32)
|/  
* 312b32052f1b456d1c4047b49f297a1058816502 test(fail-on-warning): start each fixture from an empty directory

commit 312b32052f1b456d1c4047b49f297a1058816502
Author: Claude <noreply@anthropic.com>
Date:   Sat Aug 8 05:55:16 2026 +0000

    test(fail-on-warning): start each fixture from an empty directory
    
    The helper cleared a leftover batten.local.toml and said why — "a file left by
    an earlier run would silently change the case" — but cleared nothing else. The
    rule globs `**/*.rs`, so any stray source file an earlier run left in the same
    scratch dir becomes an extra finding, and every assertion indexing findings[0]
    then reads the wrong one.
    
    Not hypothetical: a discarded branch whose fixture wrote `a.rs` into these dirs
    turned this suite red with none of its own lines changed, and the failure looked
    like a defect in the feature rather than in the fixture.
    
    Same class the existing comment names, so it gets the same treatment one level
    up: remove the directory, then recreate it. That makes the local-override
    removal redundant, so it goes.
    
    Refs: CLOUD-49

Rebase locally, and then force push to claude/cloud-32-config-epoch.

…CLOUD-32)

Policy changes need to be attributable after the fact. The epoch is a
deterministic hash of the files that GOVERN a run, so two records
carrying the same epoch were produced under provably the same rules, and
one carrying a different epoch was not.

The tracked set is config, never code. Which files govern a repository is
that repository's business — an agent settings file, a contributor guide,
a hook config, each meaningful in one repository and meaningless in the
next. So the set is `[epoch] tracked` in batten.toml, and the core
carries only the default: batten.toml itself, the one file that governs
every consumer by definition. Batten's own list lives in Batten's own
config, as consumer #1, which is where a worked example belongs.

That keeps non-negotiable rule 1 true as a GREP, not merely in spirit.
The first draft named this repo's files in doc comments and in a test
fixture — prose and fixtures, not behaviour, but the rule states its
predicate as "a grep returns zero hits", and a rule stated that way is
one a gate can be written on. Both are now generic; `git grep` over
crates/batten for any of those names returns nothing.

An unreadable tracked path is exit 1, NAMING THE PATH, never a skip. The
tracked set IS config — it defaults to batten.toml itself — so an
unreadable tracked path is unreadable config, which §7 routes to 1; 3
stays for an I/O failure not attributable to the config. Same shape as
config_trust's unreadable-ref case, which also names what it could not
read: a refusal the reader cannot act on is barely better than a silent
skip, and a skip would compute a STABLE epoch over a changed surface,
which looks exactly like a valid answer.

The epoch follows --config-from. Under a base ref both the tracked list
and the bytes come from the ref, never the working tree: an epoch
attributing a run to a config that did not govern it would be worse than
none.

The construction is identity::surface_fingerprint, not a second hash of
the same bytes. tagged_fingerprint and write_field are lifted out of
fingerprint_of, so findings and the epoch share ONE length-prefixed
SHA-256 framing rather than two that can drift. Without the length prefix
("ab","c") and ("a","bc") hash identically, so a rename could be hidden
by choosing names — asserted directly. Text is NFC/LF-canonicalized so a
CRLF checkout attributes identically; non-UTF-8 content is hashed
verbatim, since there is nothing to normalize and refusing it would make
the tracked set silently text-only. Paths are canonicalized, sorted and
deduplicated, and the path is hashed as well as the bytes, so adding an
empty file to the tracked set still moves the epoch — the SET is part of
what governs.

batten.toml raises min_batten_version off 0.0.0 now that it declares
`[epoch]`: a new key must be REFUSED by an older binary, never ignored.
deny_unknown_fields is what forces that; the floor states the intent so
the refusal names the version rather than the key.

House style §2 gains `epoch` on its `config` row in the same move — §11
makes the binary the emitter of the spec, not a warrant to outrun the
doc, and CLOUD-19 settled §2 as authoritative for the surface.

Surfaced two ways that must agree: `batten config epoch` and
doctor --json's config_epoch, with a test asserting the join CLOUD-133
will key guard records on. doctor OMITS the field when the surface cannot
be read rather than emitting a placeholder — a placeholder would be a
stable value over an unknown surface — and still exits 1 rather than 3,
so it stays the config-or-usage-only verb §7 needs it to be.

Scope: the value only. Stamping onto guard records is CLOUD-133's, which
defines the record; §8's cache and etag-style revalidation are
CLOUD-232's. What lands here is recompute-per-invocation, the reference
implementation that cannot go stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119C7XbeQLWR5TTaW2ca6Gp
@wenzowski
wenzowski force-pushed the claude/cloud-32-config-epoch branch from 3e5ec16 to 20f138e Compare August 8, 2026 15:38
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 20f138e into main Aug 8, 2026
5 checks passed
@wenzowski
wenzowski deleted the claude/cloud-32-config-epoch branch August 8, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants