Repository navigation
feat(admission): SPEC-006 S-7 — a signed gate record belongs to one store, and says which inside its signed bytes - #662
Merged
Conversation
…which inside its signed bytes
SPEC-006 S-7. Before this, a signed gate record said WHAT was decided and by WHICH key and
nothing about WHERE, so one copied out of a sibling store under the same operator key
verified, pinned, ANCHORED the store it was copied into — creating an expectation where
there was none — and spent that store's self-extension budget. S-6's file-name leg does not
catch it: the copy keeps its own {id}.json name.
The activation marker now carries a StoreId: 128 random bits as lowercase hex, minted when
signing is activated, inside the marker's signed bytes. Every record signed while an
honoured marker carries one carries that same value in ITS signed bytes, and a reader
refuses a record whose StoreId disagrees with the identity its own honoured marker names —
before it can anchor, be counted by AdmittedInWindowAsync, or be read as a decision made
there.
Minted, never derived. A path-keyed identity is lost by a clone, a remount or a move, so it
would refuse every record in a relocated store; a contents-derived one could be computed by
the actor who can write the state root. It lives in the marker because that is the one
artefact there the operator's key signs, which is also what makes it travel with a bundle.
Stable: repeated activation, rotation and --repair keep the identity a vouched-for marker
already carries, and where there is none to keep, a re-mint carries forward the single
identity the store's own verifying records agree on. Otherwise the one command an operator
is told to run would disown every verdict the store already signed.
Compatibility is normative and is S-5's mechanism again: CanonicalJson omits nulls, so a
record signed before StoreId existed has no such field in the bytes its signature covers —
no existing canonical hash moved and no grandfather inventory entry was invalidated — and a
record carrying no identity keeps verifying everywhere rather than reading as a mismatch.
TWO DISCLOSURES, both loud rather than folded in:
- The residual fact A_signed_admission_copied_from_a_sibling_store_anchors_it_and_spends_its_budget
is RENAMED and narrowed to A_signed_admission_copied_into_a_store_with_no_marker_of_its_own_still_anchors_it.
It still passes, and it must: the victim it uses has no marker, so it has no attested
identity to deny the copy with. Its row phrase and the SPEC-006 sentence move with it, and
it gains a non-vacuity assertion that the copied record really does name the store it came
from — without which it would pass equally against a build where no record carries an
identity at all.
- The parenthetical predicting S-7 would close the marker-transplant residual was WRONG and
is corrected in place: the identity lives in the marker, so when the marker travels the
identity travels with it. What S-7 bounds is a record moved WITHOUT its marker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vouches for, and the rule that says so now has facts
Two adversarial reviews of the S-7 commit returned LAND_WITH_FIXES. This is the fix set.
THE RULE HAD NO FACT (confirmed by mutation, by the reviewer and again here). Every marker plant
in the suite passed `storeId: null`, so nothing exercised an unvouched marker that DECLARES an
identity, and hoisting `storeId = marker.StoreId` out of `if (honoured)` — plus adding
`existing.StoreId ??` to the unvouched replacement — left all 56 facts green. Both halves now have
a fact in `GateRecordStoreIdentityTests`, each with its own mutation:
- `An_ignored_marker_contributes_no_store_identity`: a keyed reader, a foreign-key marker that
declares the SIBLING's identity, and B still reads its own record. Hoisting the assignment out
of the honoured branch: Failed: 1, Passed: 59.
- `Replacing_a_foreign_marker_ignores_the_identity_that_marker_declared`: the same on the write
side, where the sibling fact's `storeId: null` plant cannot tell "ignored what it declared" from
"it declared nothing". Adding `existing.StoreId ??`: Failed: 1, Passed: 59.
A KEYLESS READER NO LONGER BINDS AN IDENTITY — a REGRESSION this branch introduced, now fixed
rather than disclosed. Keyless honouring is corroboration by a record under the marker's own key,
and an actor who can write the state root supplies BOTH halves. So the identity bound was theirs,
and the store then refused its OWN genuine records with a message telling the operator to delete
files it really wrote. Those same three files read fine before S-7. The binding now requires
`_trustedSigners.Count > 0`, which is the rule `ResolveExpectation` already states for the pinning
set — key material only, never derived from the records — applied to the identity as well. The cost
is stated in SPEC-006 and in the code: a keyless consumer gets no S-7 protection, which is strictly
better than handing an actor a way to make a store disown its verdicts.
`A_keyless_reader_still_reads_its_own_records_under_a_planted_marker_and_record` pins it; removing
the key-material guard: Failed: 1, Passed: 59.
SPEC-006's DISCLOSURE OF THE UPGRADE-PATH HOLE WAS FALSE IN BOTH HALVES and is rewritten rather
than softened. It does not take marker deletion: a marker predating S-7 has no identity to keep, so
`--repair` inherits, and in such a store none of its OWN records can legitimately name an identity
— so ONE planted file signed under a vouched key is unopposed and decides what the operator's verb
signs. And the store is not loud in that state but SILENT: with no marker no identity is bound, the
S-7 leg cannot bite, and the copy is readable and counted — which two facts that pass today already
demonstrate. The paragraph now says what it actually takes, names both consequences (S-7 disarmed
for the pair; the untouched sibling accepts the compromised store's records), and records why
removing the inheritance is not the obvious fix. The candidate closures — unanimity plus a count
floor, or an explicit operator ADOPT step — are left OPEN rather than chosen here. The same false
sentence in `GateSigningActivation`'s XML doc is corrected in place.
`Two_disagreeing_store_identities_carry_forward_neither_on_a_repair` pins the MUST at SPEC-006 that
bounds that inheritance and had no test, so a refactor to "majority" or "first wins" cannot widen it
silently. Mutating the disagreement arm to first-wins: Failed: 1, Passed: 59.
Three smaller corrections, all of them things a reader would otherwise rely on:
- The narrowed residual's non-vacuity guard compared two values that are BOTH null in a build that
mints no identity, and `Be(null)` passes — so it did not exclude the one state its own comment
named. With `NotBeNull` added, the global no-identity mutation reddens it (Failed: 10); with the
guard reverted under the same mutation it passes again (Failed: 9). Same one-line arrange guard
added to `Replacing_a_foreign_marker_keeps_the_identity_this_stores_records_name`.
- SPEC-006 still said the marker "carries no store identity". It carries one; what is true is that
the identity is SELF-ASSERTED, so a transplanted marker names its own store consistently.
- The compatibility paragraph was normative in the backward direction only. A pre-S-7 binary drops
`StoreId` and recomputes different bytes, so new artefacts are unreadable to older readers — said
out loud so nobody diagnoses a mixed-version lane as a forgery.
VERIFICATION, all in the devtest container on net8.0, reading the Passed!/Failed! line:
full cert-gate filter at this tree Passed! - Failed: 0, Passed: 1218, Total: 1218
three touched classes, baseline Passed! - Failed: 0, Passed: 60, Total: 60
five mutations as quoted above, each reddening ITS OWN fact
dotnet build Ashlar.sln -c Debug Build succeeded, 0 Error(s), 2 Warning(s) (both SourceLink
harness artefacts from the inner clone's origin)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntences S-7 made inaccurate are corrected The review tail on S-7, closed item by item. A sentence this branch made false, in the cert-gate file that documents the residual: GateSignatureResidualTests' transplant fact still opened "a marker ... carries no store identity". The marker now carries one. Corrected the way SPEC-006 already was — the identity is SELF-ASSERTED, so a transplanted marker names its own store consistently and a reader still cannot tell a marker it wrote from one it did not. Checked first that the sentence is not a pinned Residual.Phrase; it is not. The keyless residual was declared normatively and bound by nothing. SPEC-006 says a keyless consumer gets no S-7 protection and calls it a residual under S-6, but GateSignatureResidualTests.Residuals had no row and the class no fact, so neither limb of the inventory binding could see it and the disclosure could be deleted with cert-gate green. Added both. The row needed its OWN inverted fact rather than borrowing the keyless fact on GateRecordStoreIdentityTests: that one asserts the store still WORKS, which stays green on the day the protection is regained, and the inventory binds rows to facts declared on the residual class in both directions. Two comments and one operator-facing string that said more than the code established. GateSignatureExpectation's S-7 leg reasoned "a store with no identity has no honoured marker to attest one" — false since the keyless fix, a keyless reader can honour a marker that names a store and still bind nothing. And the refusal claimed the copy came "from another store under the same operator key" while the leg compares NO keys; after a key rotation the record and the marker are legitimately signed by two different vouched-for keys. The message now states what the leg actually established: the signature verifies under a vouched-for key, the file name matches the id, and the two identities disagree. Two facts made load-bearing on the line they name. The_store_identity_survives_repeated_activation_and_a_repair held equally against a build with the marker's half of `existing.StoreId ?? inheritStoreId ?? NewStoreId()` deleted, because in all three arrangements the two sources supplied the same value; a fourth leg makes them disagree, both non-null. And the minted identity's normative shape had no fact at all: 32 lowercase hex characters and distinctness across mints are now asserted, at the marker and at the mint, with the entropy left to a review of RandomNumberGenerator.GetBytes(16) rather than claimed by a test that cannot reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IanFrelinger
added a commit
that referenced
this pull request
Sep 30, 2026
… it (#684) `src/Ashlar.Tests.Kernel`'s 412 SPEC-006 admission and signing cases run only in the readiness gate's native lanes, and those lanes are diff-conditional. Both path lists contained `src/Ashlar.Tests.Kernel/**` but none of the code that suite tests. So editing the tests re-ran them, while editing `GateStore.cs` or `AdmissionGate.cs` — the code they guard — skipped them. The trigger was inverted. Adds the kernel project's transitive ProjectReference closure that no entry covered, to BOTH lists at the same position in the same order: src/Ashlar.Manifest/** src/Ashlar.Policies.Dev/** src/Ashlar.Certification.Contracts/** src/Ashlar.Analyzers/** `src/Ashlar.Policies/**` does not cover `src/Ashlar.Policies.Dev/` — the rewritten pattern's `/` after `Policies` must consume a `/`, and the candidate has `.Dev` there. Verified under both matchers. `Ashlar.Analyzers` is referenced by `Ashlar.Core.Application` with `OutputItemType="Analyzer"` and `ReferenceOutputAssembly="false"`. It is a build-time analyzer, not code the 412 cases exercise; it is here because breaking it breaks the kernel suite's BUILD. Both lists are machine-enforced to be identical including ordering (`AiPipelineCiRoutingConventionTests:49-61`, inside required cert-gate), so the patch asserts that itself rather than trusting the edit, along with the test's supported-glob shape, its uniqueness requirement, and its negative contract that no glob may match `README.md`, `docs/unrelated-guide.md` or `src/Unrelated/Nested/Other.cs`. Cost is zero, measured rather than asserted. Across the last 40 merged PRs (#683..#631), 20 already set `run_heavy=true` and these four entries flip **none** of the other 20. Only three of the 40 touched these directories at all (#645, #662 in `Manifest/Admission/`, #640 in `Policies.Dev/`) and all three already ran heavy via `src/Ashlar.Tests.Infrastructure/**`. Three records asserted the absence this commit removes, two of them written yesterday in #683. Leaving them would reproduce the defect they describe: - `ci/test-ownership.tsv` row 58 said Manifest and Policies.Dev were "in neither"; now states the routing and that the wider 41-directory absent set across all 22 discovered suites is still open. - `docs/specs/SPEC-006-keys-and-signing.md` said a change confined to the code under test does not re-run the tests. The remaining caveat — a skipped lane counts as a pass, and the sweep emits no per-project count — is unchanged and still true. - `ci/test-ownership.tsv` row 47 stays literally true: `src/Ashlar.Analyzers/**` does not match `src/Ashlar.Analyzers.Tests/`. Annotated so it does not read as false. - `docs/CiGateInventory.md` line 112's core-path prose, which nothing machine-reads. This does not fix the other half. `ValidationServiceAdapter.cs:248` still computes `passed` from failures alone, so a project selecting zero tests passes, and `readiness-summary` still counts a skipped lane as a pass. Those are separate changes with real blast radius, and they are not bundled here. Co-authored-by: t <t@t> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SPEC-006 S-7: a signed gate record now belongs to one store, and says which inside its signed bytes
The residual this closes, in SPEC-006's own words:
GateSigningActivationmints aStoreId(32 lowercase hex over 128 bits fromRandomNumberGenerator), inside the marker's signed bytes, only where a marker is minted.GateRecordgains a nullable
StoreId.WriteAsyncstrips it with the signature fields so it is never inheritedfrom incoming bytes, then re-applies the identity taken from the honoured marker before computing
the signature, so the field is under the signature and cannot be relabelled.
GateSignatureExpectationgains a fifth refusal leg inside the present-signature arm, after thetrusted-signer pin, so every pre-existing refusal message stays byte-identical.
Minted, never derived, and the threat model is the reason. Not from the store's path — a path-keyed
identity is lost by a clone, a remount or a move, so it would refuse every record in a relocated store,
including one delivered by an ordinary checkout. Not from the store's contents — every input such a
derivation could reach is a file in the directory being attacked, so an actor who can write the state
root must not be able to compute the victim's identity. It lives in the marker because that is the one
artefact in the state root the operator's key signs.
Backward compatibility is normative, not an afterthought.
CanonicalJsonomits nulls, so a recordsigned before
StoreIdexisted has no such field in the bytes its signature covers. No existingcanonical hash moved, so no grandfather inventory entry was invalidated. A record carrying no identity
keeps verifying in any store and is not a mismatch — the spec requires exactly that, because a record
signed without the field cannot be distinguished from one written under any store.
What two rounds of adversarial review changed, which is most of why this is worth reading
A regression the first pass introduced and did not disclose. Binding the identity from a marker a
keyless reader merely "corroborates" — where corroboration is a record signed under the marker's own
key, both halves of which an actor who can write the state root supplies themselves — made the store
refuse its own genuine records, with a message accusing them of being copies. Those same three files
read fine before the change. It also violated, verbatim, the rule the same method already states for the
pinning set: key material only — deriving it from the records would accept whatever key an attacker
re-signed them all with. The identity is now bound only when
_trustedSigners.Count > 0, and a factasserts a keyless reader still reads its own records under a planted marker and corroborating record.
The rule the whole feature rests on had no test. A reviewer hoisted the identity read out of
if (honoured)and let the unvouched replacement inherit an identity, ran it, and got 56/56 —byte-identical to baseline. Every marker plant in the suite passed
storeId: null, so nothingexercised an unvouched marker that declares an identity. Both halves now have facts.
A non-vacuity guard that was itself vacuous. The narrowed residual asserted
carried.StoreId.Should().Be(<A's identity>)to prove a copied record names its origin — but in a buildminting no identity at all, both sides are
nullandBe(null)passes. Fixed withNotBeNull()first;the mutation now reddens ten facts instead of sailing through.
Three sentences this branch made inaccurate, corrected rather than softened. A SPEC-006 mitigation
sentence was false in both halves (contradicted by an existing passing conformance fact); a comment
reasoned "a store with no identity has no honoured marker to attest one", which the keyless fix made
false; and the operator-facing refusal claimed the copy came "from another store under the same operator
key" while the leg compares no keys — after a rotation the record and marker are legitimately signed
by two different vouched-for keys. The message now states only what the leg established.
What this does NOT close, said plainly
The spec says so and this branch claims nothing more. The identity travels with the marker.
vouches for the marker. This is now a disclosed residual with its own inverted fact and a row in
GateSignatureResidualTests.Residuals. It deliberately does not reuse the keyless fact above: thatone asserts the store still works, which would stay green on the day the protection is regained.
StoreIdexisted stay transplantable for their lifetime. Normative.StoreIdon deserialize andrecomputes different bytes, so it reports a post-S-7 store's records as not verifying. Recorded so
nobody diagnoses a mixed-version lane as a forgery.
The one decision left open
How a store adopts an identity on the pre-S-7 upgrade path. Today one file planted in
gates/,signed under any key the machine vouches for, decides what the operator's own
ashlar gates sign-activate --repairsigns — no marker deletion, no disagreement. A store whose markerpredates S-7 has zero identity-bearing records of its own, so the only verifying record that can name
an identity is a copied one, and the vote is first-non-null-unopposed.
The disclosure is restated to what it actually takes, and the disagreement rule SPEC-006 states as a
MUST is now pinned by a fact, so a refactor to "majority" or "first wins" cannot widen it silently. No
mechanism was implemented.
Options, costed: unanimity-plus-a-count-floor is the cheap one and buys almost nothing here, since
the stores it protects are the ones already safe. An explicit operator adopt step that prints the
candidate identity and which files name it is the one that actually closes it — it puts a human between
a planted file and a signed adoption, matching the shape this repo already trusts for the grandfather
inventory — and it needs CLI surface in
application/, whichlayer-boundarygives no legal base tocombine with
src/. Recommendation: ship the disclosure now, do the adopt step as a follow-up PR.If that is taken,
Two_disagreeing_store_identities_carry_forward_neither_on_a_repairis the fact whoseassertion moves, and its first-wins mutation is the one to re-run.
Verification
Linux devtest container, never the host.
Ashlar.slnbuild succeeded (2 warnings, neither awarning CS— the harness's own SourceLinkpair, which its header documents as absent in CI).
load-bearing ones independently re-proven by reviewers running their own mutations rather than
reading the author's numbers. All four original blocking findings confirmed resolved in the committed
code, not in the report about it.
One measurement worth recording so nobody misreads it later: a reviewer saw
RollbackGateExemptionTests.A_second_breach_at_the_default_retention_window_is_still_containedfailonce and pass on re-run at the same tree. That was at the pre-rebase base, which predated the stepping
clock that fixed it (#656). This branch is rebased past that fix, and the failure has not recurred.
🤖 Generated with Claude Code