Repository navigation
feat(config): hash the governing config surface into a config_epoch (CLOUD-32) - #164
Conversation
CLOUD-32 Implement `config_epoch` hashing and stamp every guard log line
Why Definition of done
Acceptance
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 House style §8 names three properties for §2 divergence, named rather than glossed. Repo-agnostic input set (non-negotiable rule 1). The issue's example list (
|
50ca9a7 to
3e5ec16
Compare
|
/fast-forward |
|
Triggered from #164 (comment) by @wenzowski. Trying to fast forward Target branch ( 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.32Pull request ( 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_0119C7XbeQLWR5TTaW2ca6GpCan't fast forward * 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-49Rebase locally, and then force push to |
…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
3e5ec16 to
20f138e
Compare
|
/fast-forward |
Closes CLOUD-32.
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.
What the revised Ready block changed
epoch.rsidentity::surface_fingerprint— one shared construction--config-from <ref>min_batten_version0.0.00.0.30The exit-code change is the substantive one, and the argument is right: the
[epoch] trackedset is config — it defaults tobatten.tomlitself — so an unreadable tracked path is unreadable config, which §7 routes to1.3stays for an I/O failure not attributable to the config. Landed precedent isconfig_trust.rs'sa_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.tomlitself, 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) incrates/battendoc 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.One hash construction, not two
tagged_fingerprintandwrite_fieldare lifted out ofidentity.rs'sfingerprint_of, so findings and the epoch share one length-prefixed SHA-256 framing rather than two that can drift. Three properties follow, each asserted:("ab","c")and("a","bc")hash identically.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-fromUnder 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_treesedits the working tree, confirms the working-tree epoch moved, and asserts the ref's epoch did not.min_batten_versionraised off0.0.0[epoch]is a new key, and an older binary must refuse it, not ignore it.deny_unknown_fieldsis what actually forces that; the floor states the intent so the refusal names the version rather than the key.House style §2
epochis added to §2'sconfigrow 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 thesurface.rsrow and the doc row land together.Two surfaces that must agree
batten config epochanddoctor --json'sconfig_epoch, with a test asserting they report the same value — the join CLOUD-133 will key guard records on.doctoromits 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 exits1rather than3, 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.rsis 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.rsunit tests: framing, sorted/deduped, CRLF-stable, refused-by-name.editing_any_tracked_file_changes_the_epochiterates 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 verifygreen on this SHA, rebased on currentorigin/main.mise run denyclean.