Skip to content

feat(admission): SPEC-006 S-7 — a signed gate record belongs to one store, and says which inside its signed bytes - #662

Merged
IanFrelinger merged 3 commits into
masterfrom
feat/spec006-s7-store-id
Sep 28, 2026
Merged

IanFrelinger merged 3 commits into
masterfrom
feat/spec006-s7-store-id

Conversation

@IanFrelinger

Copy link
Copy Markdown
Owner

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:

A signed gate record carries no store identity until S-7. One copied from a sibling store under the
same operator key verifies, pins, anchors the store it was copied into — creating an expectation
where there was none, against which every other record there is then judged — and counts toward that
store's self-extension budget. The file-name rule does not catch it: the copy keeps its own
{id}.json name.

GateSigningActivation mints a StoreId (32 lowercase hex over 128 bits from
RandomNumberGenerator), inside the marker's signed bytes, only where a marker is minted. GateRecord
gains a nullable StoreId. WriteAsync strips it with the signature fields so it is never inherited
from 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.
GateSignatureExpectation gains a fifth refusal leg inside the present-signature arm, after the
trusted-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. 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
, 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 fact
asserts 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 nothing
exercised 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 build
minting no identity at all, both sides are null and Be(null) passes. Fixed with NotBeNull() 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

  • A whole-store copy — records and marker together — remains indistinguishable from the original.
    The spec says so and this branch claims nothing more. The identity travels with the marker.
  • A keyless consumer gets no S-7 protection, because the identity is bound only where key material
    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: that
    one asserts the store still works, which would stay green on the day the protection is regained.
  • Records signed before StoreId existed stay transplantable for their lifetime. Normative.
  • Forward compatibility, newly disclosed: a pre-S-7 binary drops StoreId on deserialize and
    recomputes 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 --repair signs — no marker deletion, no disagreement. A store whose marker
predates 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/, which layer-boundary gives no legal base to
combine 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_repair is the fact whose
assertion moves, and its first-wins mutation is the one to re-run.

Verification

Linux devtest container, never the host.

  • cert-gate: 1224/1224 passed, 0 warnings.
  • Full Ashlar.sln build succeeded (2 warnings, neither a warning CS — the harness's own SourceLink
    pair, which its header documents as absent in CI).
  • Every fact mutation-checked, each mutation reddening its own fact and nothing else, and the
    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_contained fail
once 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

t and others added 3 commits September 28, 2026 01:00
…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
IanFrelinger merged commit 3722b51 into master Sep 28, 2026
27 checks passed
@IanFrelinger
IanFrelinger deleted the feat/spec006-s7-store-id branch September 28, 2026 15:47
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>
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.

1 participant