Skip to content

fix(admission): grandfathering is a list the operator signed, not a date the attacker owns - #645

Merged
IanFrelinger merged 13 commits into
masterfrom
autonomy/pr3-signature-expectation
Sep 27, 2026
Merged

IanFrelinger merged 13 commits into
masterfrom
autonomy/pr3-signature-expectation

Conversation

@IanFrelinger

@IanFrelinger IanFrelinger commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

D3: a self-extend cycle's admission ledger could be back-dated into existence. Grandfathering a pre-activation unsigned record was a timestamp compared against GateRecord.DecidedAt — and on an unsigned record DecidedAt is the writer's own field. An actor who could write gates/ did not have to strip anything: they could write a brand-new unsigned Admitted record dated one second below a floor the store's own records expose, and it was grandfathered, counted toward the self-extension budget, and shown by ashlar gates as a decision nobody made.

Grandfathering is now membership in an enumerated (id, canonical-sha256) inventory that the operator's key signed into the activation marker at the one instant the operator was present. Every record the store hands out is either signature-verified or byte-pinned by the operator's own key.

Verification

The last local sweep was interrupted, so this was opened as a draft to let CI finish it. It has: 34 checks passing, 0 failing on this head, including all five required — cert-gate, build-core, shell-lint, lychee (README + docs) and Readiness summary. cert-gate on the hosted runner is the same gate that last ran green locally at 1183/1183.

What the commits do

commit
hermetic kernel facts three facts read the machine's ambient key directory; pinned to an empty dir
one posture, resolved once GetAsync, ListAsync, AdmittedInWindowAsync and both write guards read through a single ReadStoreAsync
a file's name is the id inside its signed bytes a record under a second name is a copy, and the budget counts files
the inventory (id, canonical-sha256), signed into the marker, plus the operator count line
the operator's verb is not the way around the kernel's refusal six findings from an adversarial audit
every refusal names a way out plus the guard states what it does not cover
the canonical file answers for its id and the store enumerates in a defined order
S-6 says what it does not cover the rule body and a fourteen-row residual block, plus two new cert-gate classes that execute every sentence
the class-closing brace CI caught a syntax error no local check could — see below

Defects found and fixed inside this branch

The panel review found more in the work-in-progress than in the original defect. Named here because each was live before it was caught:

  • The inventory hash was taken over the file's bytes. A reformat, a trailing newline or a text=auto line-ending change would have refused the whole store forever — a brick armed by adoption. It is over CanonicalJson.Bytes(record), which also removed a second read of a file ParseAllAsync had already read.
  • EarliestProvenInstant computed the floor and returned now. --repair re-anchored at repair time — the exact floor-forward move the method exists to refuse.
  • AdmittedInWindowAsync excluded unsigned admissions whenever the store was signed, so ashlar keys init silently freed up to a full window of self-extension budget.
  • The Amend call after a keyed write was a no-op under a six-line comment explaining why the call must happen.
  • The plain activation verb performed the laundering step WriteAsync rule (e) refuses. Delete the marker, plant an unsigned Admitted record under the derived floor, and the operator's own sign-activate — the command the refusal tells them to run — minted it into the inventory permanently. A refusal that names a verb which performs the refused step has not stopped the attack; it has routed it through the operator.
  • A marker nothing can read was the only state with no named exit. Activate calls ReadVerified first, so --repair threw on the very file it was reached for. All three refusals now name the two-step exit, and the fact follows the advice to prove it recovers the store.
  • GetAsync answered by directory enumeration order when two files claim one id. Not merely a funnel disagreement: DecideAsync reads through GetAsync, so a planted file sorting first made an honest proposal permanently undecidable with nothing overwritten and nothing refused.

The disclosure half

Two sentences on this branch were false until the last commits, and merging without them would have shipped documented wrong advice:

  • SPEC-006 said an actor who strips every signature and deletes the marker is "undetectable to a keyless reader (a keyed reader still catches it through the marker its key wrote)". There is no keyed-reader advantage: the marker lives in the directory being attacked, so an actor who strips everything deletes it too, and the pinning set is public key material, never a memory of this store.
  • The CHANGELOG told operators to archive records out of gates/ to fix a half-signed history. That is the budget forgery this change exists to stop — every removed admission raises the remaining budget by one, and removing a refused record lets the same proposal be re-proposed. The remedy is ashlar gates sign-activate --repair. The old advice is retracted in place rather than deleted, because somebody may have acted on it.

GateSignatureResidualTests executes fourteen residuals — total strip plus marker deletion reading clean for every reader, a sibling marker carried with its records importing verdicts the victim never made, a record planted before activation grandfathered permanently, deleting a record raising the budget and erasing a refusal, a public key dropped into trusted/ making a forged signature verify, and more. Each performs the attack through the public API and asserts the store does not catch it. Two rows carry no fact and say why: the stolen-key half reduces to using the key correctly and observing the store accept what it signed, which asserts the system works.

Three tripwires were vacuous before they were written. The plan's own assertion — that GateSignatureExpectation.cs must not contain DecidedAt >= — matches zero times, because the shipped floor is spelled record.DecidedAt < DerivedGraceBefore.Value inside a negation. Two further escapes keep every proposed count green: a second floor against Proposal.ProposedAt, equally the writer's own field, and swapping two else if arms. The fact now counts date-field names as a set and asserts ordering directly, and it names the escape it cannot catch — a date rule relocated into GateStore is invisible to a single-file scan.

A_grandfathered_record_with_changed_bytes_is_refused_even_below_the_derived_floor exists because of a measurement: on 2026-09-20 that escape was applied to the real tree and cert-gate stayed 1183/1183, zero reddened. The doc records the date, the form and the result so it can be re-run.

One CI round trip, and what it taught

The first Group 6 push failed to compile: CS1519 and CS1513 in one file. My edit-application error — the plan's anchor ended with both the fact's brace and the class's brace, so the insert put a method outside the class.

The useful part is why my local check missed it. I verified braces balanced with a state machine that skips comments and strings, and it reported BALANCED — correctly. A method after the class's closing brace keeps totals equal and final depth zero. The discriminator is that depth returns to zero before EOF with code following. The new check counts top-level types; run against the reconstructed defect it reports top-level-types=2 while still showing depth=0, negative=False, proving the old one could not have caught it.

Deliberately not done

  • Refusing two files that hold one id was implemented and reverted. A_never_signed_store_behaves_exactly_as_today_for_both_readers already records this as a deliberate decision — "a pre-existing hole in an unsigned store, and S-2 owns it" — and an actor who can plant a second file can overwrite the first, so it grants no capability. The canonical file wins instead, which closes the enumeration-order attack without moving the S-2 contract.
  • Refusing two files that hold one id was implemented and reverted — see above. Nothing else from the review is deferred.

Verification notes

Every behavioural fix is mutation-checked: each mutation was confirmed applied, reddened exactly its own fact, and the tree was verified byte-restored afterwards.

Two things worth recording:

  1. A date escape bolted onto the hash-mismatch arm of Refuse reddens nothing across all 1183 facts — executed, not reasoned about. That blind spot is what Group 6's scan exists to close.
  2. One fact in this branch was vacuous when first written. Its arrange step wrote node["state"]; the serialized key is "State", so it added a second lower-cased property and asserted a condition that held either way — passing cert-gate at 1183/1183 twice while proving nothing. The mutation caught it. It now asserts its own setup took effect before asserting anything else.

[coordinated-integration]

This PR changes four files under application/src/Ashlar.CLI/Commands/ alongside the kernel change in src/Ashlar.Manifest/Admission/, which layer-boundary refuses against a runtime base. Declaring the token rather than splitting, with the reasons a reviewer should check:

There is no legal base for this change without it. A base of master/main/runtime/* rejects application/; a base of application/* rejects src/. A change that genuinely spans the boundary has no other path, which is what this token exists for.

Splitting would put master in the one state this PR argues it must never be in. The grandfather inventory is minted over whatever is on disk at the instant the operator runs keys init or gates sign-activate, and nothing else in the design witnesses that instant. An actor who plants a fabricated Admitted record before activation has it blessed permanently and counted toward the self-extension budget — the inventory records that the operator said yes, not that the record is true. The count printed by those two verbs is therefore the only control on the mechanism. Landing src/ first would ship an inventory whose sole control does not exist yet; landing application/ first would ship a count for a mechanism that is not there. They are one change.

Two of the four files are not optional either. GateSigningActivation's refusal messages name ashlar gates sign-activate --repair as the remedy, and before this PR that flag did not exist — the kernel was telling operators to run a command that would fail to parse. GatesCommand.cs registers it. KeysCommand.cs carries the same count line because keys init is the command operators are actually told to run, and is therefore the likeliest place for a planted record to be blessed unnoticed.

The other two are consequential edits, not new surface. ReportBackgroundAgentCommand.cs and PkgCommand.cs change only to follow ActivateSigningAsync's new return type and GetAsync's behaviour; neither gains a verb or an option.

The five required checks — cert-gate, build-core, shell-lint, lychee (README + docs), Readiness summary — are green on this head.

🤖 Generated with Claude Code

t and others added 11 commits September 20, 2026 21:08
…e store is signed

SPEC-006 S-1 refuses a signature that fails verification and says nothing
about one that was deleted. From a record's own bytes a stripped signature
and an honestly unsigned record are the same bytes, so every verification
path enforced Ed25519 only "when present" - a condition the attacker's own
input controls. Anyone who could write .ashlar/gates/ could strip a real
admission's signature, or hand-write an unsigned Admitted record, and
GateStore.AdmittedInWindowAsync counted it toward the self-extension budget.

Whether a missing signature is corruption is now decided by the STORE, never
by the record. GateStore resolves a GateSignatureExpectation per read from
two anchors that are not the record being judged: any verifying signed
record already in the store, and a signed activation marker at
{stateRoot}/gate-signing.json (GateSigningActivation) when the reader's key
material vouches for its signer. A record with no signature decided at or
after the grace floor is refused with the same loud fail-closed path as a
forged one. GateSignatureExpectation.Refuse is pure - no I/O, no clock -
and the verify-failure reason string is byte-identical to before.

The marker is a sibling of gates/ and not a dotfile: GateStore.ListAsync
globs gates/*.json and GateRecord has required members, so a marker inside
gates/ would deserialize to a JsonException and brick every listing,
including the budget count. An unsigned marker, or one whose Sig does not
verify against its own Signer, throws from GateSigningActivation.TryRead:
honouring it would let anyone with write access brick a keyless store by
planting a far-past ActivatedAt. A verifying marker is honoured only when
this machine's key material contains its signer or, for a keyless reader, a
verifying record carries the same key - so a planted foreign marker is
ignored rather than obeyed. The grace floor is the MIN over the anchors, so
a forward-dated marker cannot grandfather a record an earlier signed record
already dates, and caller clock skew can only make reads stricter.

The pinning set (OperatorKey.TrustedPublicKeysBase64: operator.pub union
trusted/*.pub, plus the explicit signer a store was constructed with) comes
from key material only. Deriving it from the records would be circular: a
forger who re-signs every record with a fresh keypair would make that key
accepted. Generate(rotate: true) already retains the superseded public key
under trusted/, so rotation neither invalidates earlier records nor moves
the activation instant; peers/ is not consulted, because its own comment
forbids wiring the package-import allowlist onto a different trust decision.
A present-but-unreadable key file throws the corrupt-key error TryLoad uses;
an inaccessible key directory narrows the set from the constructor rather
than throwing during composition.

GateStore.WriteAsync now fails closed on a keyless write into a signed
store, naming `ashlar keys init` as the remedy and saying out loud that
deleting records from gates/ is not - that deletion is the budget forgery
this change exists to stop. Without the guard the invariant is not total:
DecideAsync on a keyless store rewrote a signed record unsigned, a silent
self-downgrade by legitimate code that the read rule would then refuse.
SignedGateStoreTests.A_keyless_store_deciding_a_signed_record_rewrites_it_unsigned_not_bricked
asserted exactly that rewrite; it is a deliberate contract reversal, renamed
to A_keyless_store_refuses_to_decide_a_record_in_a_signed_store, and keeps
its real concern by asserting ListAsync still enumerates. The guard is
stricter than the read rule on purpose: a verifying marker refuses a keyless
write even with no corroborating record, because a keyless writer cannot
vouch for the marker and must not gamble that a keyed reader will not.

A keyed writer activates on its first signed write, anchoring ActivatedAt to
that record's DecidedAt so everything already on disk is grandfathered -
the non-bricking adoption path. GateStore.ActivateSigningAsync is the
explicit activation the CLI will call from `keys init`, taken under the
store lock. SPEC-006 S-2 holds: a store that has never been signed behaves
exactly as before for every reader, and a keyless reader of a store with no
verifying record honours no marker.

GateSignatureExpectationTests rides the cert-gate. Every fact was mutation-
checked; the residual is recorded in the spec: an actor who strips every
signature AND deletes the marker is undetectable to a keyless reader, and
deleting an admitted record still raises the remaining budget until
SPEC-003's chained ledger exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the marker

A_keyless_reader_still_detects_a_stripped_signature survived a mutation that
disabled the derived anchor, because the store under test still held the
activation marker the keyed writer had left, and the keyless reader
corroborated that marker through the one record that was still signed. The
fact is meant to pin the DERIVED anchor - a verifying record is intrinsic
proof the store is signed - so the attacker now deletes the marker as well as
stripping the signature. That is the strongest keyless case the design
claims to catch; stripping every signature too is the residual SPEC-006
records. With the marker gone the mutation reddens the test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the store expects

The kernel now refuses a gate record whose signature was removed once the
store is known to be signed (SPEC-006 S-6). The CLI is where an operator
meets that rule, so four things change here.

`ashlar keys init` activates signing in the project it is run in
(--path, default the current directory) whenever that project has a .ashlar
root, through GateStore.ActivateSigningAsync under the store lock. The
instant the key came into existence is the instant after which an unsigned
verdict there is a stripped signature; everything already recorded is
grandfathered. Never on --rotate: the marker is written once, and a rotation
must not be able to re-open the grace window. If the key is generated but
the activation fails, the command says so and exits non-zero rather than
look like both landed. `ashlar gates sign-activate` is the same activation
for a project set up after the key existed; an existing marker is reported
and never moved.

`gates` and `gates --show` print one line above the listing naming the
store's resolved posture (GateStore.SignatureTrust): expected and on what
basis, not expected because there is no key, or - the case an operator
would otherwise never notice - a key is present but this store has never
been signed. Each record's line says "unsigned" or the fingerprint of a
signature the store verified; no fingerprint is ever printed for a record
that was not (rule S-3).

GatesCommand.OpenReadStore stays key-free, against the plan that routed
list/show through OperatorKey.TryLoad(). The plan's reason was that a
keyless reader is where the rule is inert, and that is not so under the
store's own constructor: GateStore loads the pinning set and the activation
marker from PUBLIC key material regardless of whether a signer is supplied,
so a read store resolves the full expectation and refuses a stripped or
re-signed record exactly as a signing store would. What the identity adds
is only the private seed, which reads do not need - and loading it would
break the e2e loop's read-survives-a-corrupt-operator-key claim, the reason
OpenReadStore was introduced. Both openers are spelled `new GateStore(` so
the read-funnel convention test can see them.

PkgCommand.GatherAsync now reads with the sealer both callers already
loaded: it decides what travels to another node, and a stripped or
re-signed admission must not leave the machine under the origin's seal.
ReportBackgroundAgentCommand.ReadGatesAsync reads with OperatorKey.TryLoad();
a corrupt key surfaces there as the store error the report already renders,
because its catch covers InvalidOperationException.

Verified in-container: the CLI command suites, the self-extend bridge and
M1 enforcement suites, and a scripted smoke of every new path - activation
on keys init, the trust line keyed and keyless, idempotent sign-activate, a
keyless propose refused naming `ashlar keys init`, `gates --show` surviving
a corrupt operator.key, and a stripped record refused on listing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…6 into SPEC-006

GateStore decides whether a record with no signature is a stripped one by
resolving a posture from the store's OTHER records and its activation
marker. A second production reader would not have that posture, and every
functional fact in GateSignatureExpectationTests stays green while it
accepts a stripped record. GateRecordReadFunnelConventionTests freezes the
shape: only GateStore.cs deserializes a GateRecord; the stripped-signature
arm of GateSignatureExpectation.Refuse is present by its phrase and the
store judges through Refuse rather than around it; and every production
construction of the store either passes a signer or is on a frozen list
with its reason, with a stale-row companion so the list can only shrink
honestly. A target-typed `new(` for a GateStore is refused outright,
because the construction scan cannot see it and it is exactly how a
keyless writer would slip in unlisted. Comment lines are stripped before
scanning so prose about the funnel can neither satisfy nor trip it. Text
scans are tripwires, not proofs, and the class says so.

SPEC-006 gains rule S-6 in section 4 - the two anchors, the minimum grace
floor, the pinning set from key material only, the marker's placement and
its corruption rule, the keyless-write refusal, and the named conformance
tests - and section 6 splits the old "S-1 applied to a missing signature"
bullet: the gate-record half is now enforced, the certification-record
half stays unmet, and S-2 is reaffirmed by naming the two degrade paths
that stay permissive and the tests that pin them. The residual is written
down rather than papered over: stripping every signature and deleting the
marker is undetectable to a keyless reader, and deleting an admitted record
still raises the remaining budget until SPEC-003's chained ledger exists.

The changelog carries the release note the plan's risk section asked for:
a node that ran keyless and later gained a key holds a half-signed store
that every keyed reader now refuses, and a keyless process can no longer
write into it. That is the store doing its job. The remedy is a key, and
for a half-signed history it is archiving records out of gates/ with the
explicit warning that every archived admission raises the remaining
self-extension budget - deleting records is the forgery this change exists
to stop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tory

GateStore's constructor resolves its signer-pinning set through
OperatorKey.TrustedPublicKeysBase64, which reads ASHLAR_KEY_DIR and falls back
to ~/.ashlar/keys. Every kernel fact that constructs a store is therefore
reading ambient process state, and three of them inverted on it: on a machine
that had run `ashlar keys init`,
SignedGateStoreTests.A_signed_record_verifies_even_for_a_reader_that_holds_no_key,
SignedGateStoreTests.A_keyless_store_refuses_to_decide_a_record_in_a_signed_store
and
ExtensionPackagingTests.A_record_signed_before_claims_existed_still_verifies_and_still_packs
failed with "signed by a key that is neither the operator key nor any key
retained under trusted/", while CI stayed green. Green where nobody is watching
and red where someone is, is the worst shape a suite can have.

Every Ashlar.Tests.Kernel class that constructs a store now joins
EnvironmentSensitiveCollection and points ASHLAR_KEY_DIR at a FRESH EMPTY
directory of its own for the class's lifetime. Empty, not the key directory the
fact generates into: pointing it there would turn the two keyless facts green
while making their reader keyed, which is the opposite of what their names
promise. EnvironmentSensitiveCollection gains the sentence saying what it is
for, which is the half that was missing.

GateRecordReadFunnelConventionTests.Gate_store_kernel_facts_pin_the_key_directory
freezes it, and refuses a vacuous scan: it fails if no kernel fact constructs a
store at all. Injecting the key directory is the real fix and stays deferred,
because KeylessConstructionSites identifies a keyless construction by counting
constructor arguments and a second argument would blind that gate silently.

No production diff: this lands before the mechanism moves so the suite is
trustworthy while it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…te store

GateStore resolved a signature expectation in three places and they disagreed.
GetAsync resolved from the activation marker alone, ListAsync from the marker
AND the store's other verifying records, and WriteAsync's keyless guard from
scratch again. The marker is a file in the directory an attacker who can strip
a signature already writes, so deleting it left GetAsync with no anchor at all:
a keyed GetAsync returned a stripped, State-flipped record that ListAsync beside
it refused. DecideAsync reads through GetAsync, so `ashlar gates --admit` on a
stripped and tampered Held record signed the attacker's content under the
operator's own key and handed back a verdict that verified — legitimate code
minting exactly the forgery the read rule exists to catch.

ReadStoreAsync is now the only resolution point and the only way a record leaves
the store: parse and hash once, judge the PRESENT signatures and collect the
survivors as anchors, resolve the posture from those anchors and the marker, and
apply it to every record. GetAsync, ListAsync, AdmittedInWindowAsync and the
keyless write guard all consume it; ReadRecordAsync is gone, because re-parsing
the file after ParseAllAsync would judge one set of bytes and return another.
AdmittedInWindowAsync consumes it directly rather than through ListAsync, so the
count uses the expectation instance that judged the records instead of re-reading
the public SignatureTrust after an await. The keyless guard reads the marker's
presence off the expectation's MarkerActivatedAt rather than reading the marker a
second time; the refusal text is unchanged.

The hash ReadStoreAsync computes is the sha256 of the record's CANONICAL bytes,
not of the file: a reformat or a CRLF normalisation must not move it. Nothing
consumes it yet; the grandfather inventory does.

GetAsync is therefore no longer a single-file read. One corrupt, stripped,
re-signed or duplicated record anywhere under gates/ now refuses `gates --show`
of an unrelated record, PkgCommand.GatherAsync, PackageImport's dedup probe and
DecideAsync's pre-read. That is the price of the funnels agreeing, and
A_corrupt_neighbour_names_itself_not_the_requested_record pins the operator's
pointer to the file that is actually broken.

Get_and_list_agree_on_the_posture_of_the_same_store asserts the two funnels
throw the identical message; A_keyed_decision_cannot_be_made_on_a_record_the_
store_would_refuse executes the laundering end to end and asserts the record's
bytes are unchanged and no marker reappeared; and the convention fact
One_resolution_point_serves_every_read freezes the shape by exact count — the
empty-anchor resolution gone, ParseAllAsync spoken twice, the marker read once.
Exact, because "at least twice" was true of the broken code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gned bytes

The proposal id sits inside the signed bytes; the file name does not. Copy one
legitimately signed Admitted record to two more names and every copy verifies,
every copy is enumerated, and AdmittedInWindowAsync — which counts files,
because files are all there are to count — reads three admissions where the
operator made one, holding an honest proposal as over-budget. No stripping, no
key, no marker: just cp.

GateSignatureExpectation.Refuse gains a fourth leg requiring the file name to
equal Proposal.Id + ".json". It is evaluated LAST so every refusal message above
it stays byte-identical, and it is scoped to `Expected || record.Sig is not
null` rather than made unconditional: an unconditional refusal would change what
a never-signed store does, which is S-2, and the plan for this rule was to leave
S-2 alone. Conditioning on the signature still fires during the anchor pass, so
a signed duplicate cannot anchor a posture for the rest of the store. It lives
in Refuse rather than in ParseRecordAsync for the same reason — the parser is
also the S-2 path.

A_duplicated_admitted_record_under_a_second_file_name_is_refused executes the
copy and asserts the refusal names both the offending file and the id it holds,
and that the budget count refuses rather than returning three.
A_never_signed_store_behaves_exactly_as_today_for_both_readers is the other
half, stated as a fact rather than as an intention: an unsigned store still
enumerates, still counts a copy, still takes a keyless write, for a reader
holding the operator key and for one holding nothing. Making the new leg
unconditional reddens it, which is what scoping it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ate the attacker owns

A store that has activated signing must still read the records decided before that
instant. Until now the rule for those was a timestamp: an unsigned record was
grandfathered if its DecidedAt fell below a grace floor. On an unsigned record
DecidedAt is the writer's own field, and the floor was derivable from the store's
own contents — so an actor with write access to gates/ did not have to strip
anything. They could write a brand-new unsigned Admitted record dated one second
below the floor, and it was grandfathered, counted toward the self-extension
budget, and displayed by `ashlar gates` as a decision nobody made.

Grandfathering is now membership in an enumerated (id, canonical-sha256) inventory
that the operator's key signed into the activation marker at the one instant the
operator was present. Every record this store hands out is therefore either
signature-verified or byte-pinned by the operator's own key.

WHY THE MECHANISM AND ITS PRINTED COUNT ARE ONE COMMIT. The inventory is minted
from whatever is on disk at the instant the operator runs `ashlar keys init` or
`ashlar gates sign-activate`, and nothing else in the design witnesses that
instant. An actor who plants a fabricated Admitted record BEFORE activation has it
blessed permanently — the inventory records that the operator said yes, not that
the record is true. So the count the operator is shown is the only control on the
mechanism, and a tree in which the inventory exists and the count does not is a
state this repository should never be at: it trades one invisible forgery for
another. They land together or not at all.

WHY THE CANONICAL HASH AND NOT THE FILE'S BYTES. Hashing the file as it sits on
disk makes a trailing newline, a reformat or a text=auto line-ending normalisation
refuse the whole store forever, with the operator's only exit being the command
that re-mints the set — a brick armed by adoption. The canonical form is derived
from the deserialized record, so formatting cannot move it, and neither can a
future nullable member, because CanonicalJson omits nulls; that is the same
mechanism S-5 already depends on. Hashing the record already parsed also removes
a second read of a file ParseAllAsync had just read, which was a window where the
bytes judged were not the bytes returned.

WHY THE INVENTORY IS NULLABLE AND NOT REQUIRED. A required member makes
System.Text.Json throw on a marker written before the inventory existed, which
makes the named refusal unreachable and turns a migration into a stack trace. It
is nullable, so a v1 marker deserializes and still verifies, and TryRead refuses
it by name with `ashlar gates sign-activate --repair` as the remedy.

WHY A FOREIGN MARKER IS REPLACED BY THE OPERATOR AND NEVER BY THE WRITE PATH. A
marker signed by a key this machine does not vouch for is somebody else's
declaration about this store. Refusing always hands anyone who can write the state
root a permanent brick of every keyed write with no named exit; replacing always
puts a new capability on a security artefact on the hot path. So the kernel's
automatic path refuses and names the verb, and only the operator's explicit verb
replaces — and says so in its own sentence, because that is the one outcome of
this verb that is a security event rather than a setup step.

WHY THE RECORD IS WRITTEN BEFORE THE INVENTORY IS AMENDED. A grandfathered record
now decided under a key must leave the inventory, or its entry pins bytes that are
no longer on disk and an actor can restore the pre-decision bytes and have them
accepted. Record first, amendment second: a crash between them leaves a stale
entry, exploitable only by writing those exact bytes back, i.e. a downgrade. The
reverse order would leave an unsigned record with no entry, and refuse the whole
store.

WHY A SURVIVING UNSIGNED ADMISSION STILL COUNTS TOWARD THE BUDGET. Excluding
unsigned records from AdmittedInWindowAsync whenever the store is signed means
running `ashlar keys init` silently frees up to a full window of self-extension
budget — a loosening of the asset triggered by the one command an operator is told
to run. A record that survived an inventory-bearing marker was witnessed by the
operator's key, so counting it is counting what the operator authorized. The
exclusion is kept exactly where grandfathering is still a date, and therefore
still the writer's own input.

THE COUNT PRINTS BEFORE THE SUCCESS LINE, never under it: an authorization printed
beneath a tick has already been granted. The admitted subset is broken out because
that is the half that spends self-extension budget. It is computed inside
ActivateSigningAsync under the store lock, from the same scan that minted the
inventory — a count taken by re-reading the store afterwards is a different number
about a different instant, and on an authorization surface that difference is the
whole game. On the already-active path it describes the marker IN FORCE, not the
one this call would have minted, so a second activation cannot appear to bless
records it did not bless.

`--repair` now exists. GateSigningActivation's refusals already named it as the
remedy for a deleted or pre-inventory marker, and it was not a flag, so the error
told the operator to run a command that did not exist. It re-mints the inventory
over the store as it stands while keeping the original activation instant, and its
help text says what it is: a re-blessing of whatever is on disk, whose only
control is the count it prints. The `sign-activate` comment no longer claims
unconditional idempotence, which stopped being true when replacement and repair
arrived; what is still true, and what that claim was protecting, is that the
activation INSTANT never moves — not by re-running, not by rotating the key, not
under --repair — so the grace window cannot be re-opened.

FIVE DEFECTS FOUND BY THE FACTS AND BY READING THE OPERATOR SURFACE, each a
correct mechanism with its operative line missing:

  * the inventory hash was taken over File.ReadAllBytes(path) rather than
    CanonicalJson.Bytes(record) — the brick described above. Caught by
    A_grandfathered_record_reformatted_but_unchanged_still_reads.
  * EarliestProvenInstant walked every verifying record to find the earliest
    instant the store's own signatures prove, then returned `now`, discarding it,
    so --repair re-anchored the marker at repair time — precisely the
    floor-forward move the method exists to refuse. Caught by
    A_repaired_marker_cannot_move_the_grace_floor_forward.
  * AdmittedInWindowAsync keyed its exclusion on expectation.Expected, true of any
    signed store, so it excluded every unsigned admission including the ones the
    operator had just authorized. Caught by
    Keys_init_does_not_free_self_extension_budget and
    Adopting_a_store_of_unsigned_records_keeps_every_one_readable.
  * the Amend call after a keyed write was `_ = record.Proposal.Id;` — a no-op
    under a six-line comment explaining why the call must happen. Amend itself was
    complete. Caught by A_grandfathered_id_leaves_the_inventory_once_it_is_signed.
  * `sign-activate` reported "signing already active ... the instant never moves"
    on the path where it had just overwritten a foreign marker. Found by reading
    the operator surface, not by a fact — GateSigningOutcome now distinguishes the
    three cases the marker alone cannot, and the foreign replacement is pinned by
    A_planted_foreign_marker_does_not_block_activation_and_does_not_ride_a_keyed_write.

Verified in-container: cert-gate 1178/1178 (net10.0) and Ashlar.Tests.CLI 254/254.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rnel's refusal

Six findings from an adversarial audit of the previous commit, three of which
changed behaviour and each of which is now pinned by a fact with a mutation that
reddens it.

THE PLAIN ACTIVATION VERB PERFORMED THE LAUNDERING STEP THE WRITE PATH REFUSES.
WriteAsync rule (e) refuses to auto-activate over a store whose marker has been
deleted, and says why: re-minting an inventory over whatever is on disk now
permanently blesses every unsigned record present at that instant. But
ActivateSigningAsync passed replaceUnvouchedFor: true unconditionally and never
gated the MISSING-marker case on repair at all, so GateSigningActivation.Activate
fell straight through to its no-marker branch and minted. The whole chain needs no
key and no stripping:

  1. delete .ashlar/gate-signing.json — the store falls back to the derived date
     floor and every read still succeeds;
  2. write gates/ext-payload.json, Sig null, State Admitted, DecidedAt one second
     below the minimum DecidedAt over the records that still verify. It is
     grandfathered by the derived floor, and excluded from AdmittedInWindowAsync
     on that basis, so nothing looks wrong yet;
  3. the operator's next keyed admit refuses, naming the verb;
  4. plain `ashlar gates sign-activate` mints Grandfathered = {(ext-payload, sha)}
     and writes it. The record is now permanently blessed by the operator's key,
     returned by GetAsync, shown by `gates --show`, gathered by PkgCommand, and —
     because the expectation now carries an inventory — counted toward the
     self-extension budget.

The operator saw a tick. A refusal that names a verb which performs the refused
step has not stopped the attack; it has routed it through the one command the
operator was told to run. Activate now takes the count of records whose signatures
verify — which is what distinguishes a store that was never signed from one whose
marker is gone — and refuses unless the verb that re-mints deliberately asks.

GET AND LIST DISAGREED AGAIN, THROUGH A STRING COMPARISON. GetAsync resolved
{id}.json, checked File.Exists, then matched the file NAME ordinally. On Windows
and default macOS File.Exists("ext-1.json") succeeds against a file called
EXT-1.json while the ordinal compare fails; under any other rename it returns null
on every platform. Either way ListAsync returned a record GetAsync called absent,
so DecideAsync reported no such proposal and PackageImport's dedup probe
re-imported it. That is the two-funnels-disagree defect this branch exists to
remove, reintroduced one commit after removing it. GetAsync now matches the id
inside the record, and the File.Exists shortcut went with the rest: it answered
from the filesystem's case rules rather than from the store, which is the same
mistake in cheaper form.

ACTIVATION COULD BRICK THE STORE IT WAS ASKED TO BLESS. It read with judge: false
and minted over whatever it found. A store holding a file not named for the id
inside it — an operator's manual copy, say — reads perfectly well beforehand,
because Group 3's file-name leg is inert until the store is signed. The moment a
marker lands, every read of that store is refused, and the remedy the refusal
names, --repair, re-mints the same broken set: the operator's exit leads back to
the same wall. So `ashlar keys init`, the one command an operator is told to run,
could make a healthy store unreadable with no way out but deletion — which is the
budget forgery this whole change exists to stop. Activation now refuses first,
naming the offending file, while the store still reads and the files can still be
moved.

Also, without behaviour change: the security-critical canonical hash had two
spellings, an inlined one that was live and a documented CanonicalSha256 helper
that nothing called — which is how two spellings drift apart; the call site now
uses the helper. And the inventory hash compared Ordinal against hex, so a marker
whose digest another writer spelled uppercase would have been refused with an
error message in which the two values look identical.

Not fixed here, recorded for the residual block that Group 6 adds to SPEC-006: a
keyless reader honours a marker when ANY record's Signer matches it, so an actor
who can write both gates/ and the state root can re-sign every record under a key
of their own and plant a matching marker naming whatever unsigned records they
chose. The existing residuals cover total stripping plus marker deletion, and a
transplant under the operator's own key; neither says that the inventory gives a
keyless reader no protection at all against an actor who supplies both anchors.

Verified in-container: cert-gate 1181/1181 on net10.0, and each of the three new
facts goes red under its own mutation in isolation — removing the re-mint guard,
restoring the File.Exists-plus-file-name match, and deleting the brick check —
with the tree confirmed byte-restored afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t it does not cover

Three findings from an adversarial panel reviewing 8e91690f. Two changed code;
the third changed nothing and is recorded because the reasoning is the valuable
part.

A MARKER NOTHING CAN READ WAS THE ONE STATE WITH NO NAMED EXIT. ReadVerified
throws on a marker that is unparseable, empty, or unsigned/unverifiable, and
ResolveExpectation calls it on every read — so one junk file in the state root
refuses every GetAsync, ListAsync, AdmittedInWindowAsync and both write paths.
The trap is that Activate calls ReadVerified first too, so `sign-activate
--repair` — which exists precisely for a store whose marker a reader refuses —
throws on the very file it was reached for. Three of those refusals ended with
"inspect the file" and named nothing; the fourth, the missing-inventory one,
named --repair and worked, which made the gap easy to miss. All of them now name
the two-step exit: delete the marker, then --repair. Deleting is safe to advise
because a marker that does not verify carries no authority to lose, and the
re-mint cannot date the store later than its own surviving signatures prove.
A_marker_nothing_can_read_names_the_exit_that_recovers_the_store follows that
advice rather than matching its words — it deletes the file, repairs, and asserts
the store reads again — so advice that did not work would fail it.

THE DELETED-MARKER RE-MINT GUARD IS BOUNDED, AND NOW SAYS SO. It fires on records
whose signatures still verify, so an actor who strips every signature as well as
deleting the marker drives that count to zero and the plain verb mints freely.
That is not a hole the guard can close: with no signature and no marker left,
nothing in the state root distinguishes such a store from one that was never
signed — which is the recorded residual that the store's own account of itself
lives in the directory being attacked. The guard raises the price of the cheap
attack from "delete one file" to "strip everything"; it does not make the
expensive one impossible. Stated at the guard, so nobody reads it as more.

CONSIDERED AND REJECTED: refusing two files that carry one proposal id. The panel
found that in a never-signed store ListAsync returns both while GetAsync returns
whichever the directory yields first, and that DecideAsync reads through GetAsync
and writes through PathFor(id), so a planted file's content could be decided and
seated under an honest record's name. Both observations are accurate. Refusing it
in ReadStoreAsync was implemented, and reverted: it reddened
A_never_signed_store_behaves_exactly_as_today_for_both_readers, whose comment
already records this case as a deliberate decision — "a copy under a second name
is NOT refused here: the file-name binding is scoped to a signed store. It is a
pre-existing hole in an unsigned store, and S-2 owns it." The severity does not
survive that scope: an actor who can plant a second file can write the honest
file directly, so routing through a duplicate id grants no capability they lack,
and the end state is the same minus the operator's name in the actor field. Where
it would matter — a store that later gets signed — activation already refuses a
misnamed file, and Group 3's file-name leg already refuses a record under a second
name once signed. The check was redundant everywhere it was safe and a silent
contract change everywhere it was not.

The activation brick-check's comment now names the larger thing it closes: the
inventory is minted one entry per file and read with FirstOrDefault on the id, so
a duplicated id would leave one entry unreachable and the honest file failing its
hash check — the operator told its bytes changed since they authorized it, about a
file nobody touched, with neither named exit working. Two files cannot share one
name, so requiring every file to be named for the id inside it makes that
collision unrepresentable rather than merely unlikely.

Verified in-container: cert-gate 1182/1182 on net10.0; removing the exit from the
unreadable-marker refusal reddens the new fact in isolation, and the tree is
restored afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e enumerates in a defined order

A design panel reviewing this branch found that GetAsync answered a single-record
read by directory enumeration order when two files carry one proposal id, and
required it to refuse. The finding is real; the required change was not, and its
stated blast radius — "verified by reading all 31 existing facts: none changes" —
is false: A_never_signed_store_behaves_exactly_as_today_for_both_readers copies
ext-1.json to ext-1-copy.json and then asserts GetAsync("ext-1") is non-null, so
a refusal reddens it. That fact is deliberate, and its comment says so: "a copy
under a second name is NOT refused here ... it is a pre-existing hole in an
unsigned store, and S-2 owns it."

Both the finding and the fact are right, and the resolution is neither refusing
nor choosing arbitrarily: THE CANONICAL FILE WINS. GetAsync prefers the file
PathFor would have written — the one named for the id — so S-2 stays exactly as
permissive as the CHANGELOG promises, ListAsync still returns copies, and a
planted name can never outrank the record it impersonates. Refusal is kept for
the one genuinely ambiguous case, where several files claim an id and none bears
its name; that state is unreachable in a signed store, because the file-name leg
binds name to id and activation refuses to mint over a store that violates it.

WHY THE ARBITRARY WINNER MATTERED, which neither I nor the earlier audit saw. It
is not merely a disagreement between the two funnels. An actor who can write
gates/ plants aaa.json carrying an honest record's id and State = Admitted. It
sorts first, so GetAsync returns the plant; DecideAsync reads through GetAsync
and refuses a record that is already Admitted; and the honest proposal becomes
permanently undecidable with nothing overwritten, nothing deleted and nothing
refused. The usual answer — that an actor who can plant a file can overwrite the
real one — does not cover this: overwriting destroys the evidence, while this
leaves the honest record sitting on disk looking correct.

PARSEALLASYNC NOW ENUMERATES IN ORDINAL PATH ORDER. Directory.EnumerateFiles
guarantees no order at all. Four behaviours inherited that: which record answers
when two files claim one id, which file a refusal names first, the tiebreak
inside ListAsync's ProposedAt sort (which is not a total order), and the order
the grandfather inventory is minted in. None of them was wrong before; all of
them were untestable, which is how a flake earns a green tick.

THE FACT THIS COMMIT ADDS WAS ITSELF VACUOUS AT FIRST, AND THE MUTATION IS WHAT
CAUGHT IT. Its arrangement wrote node["state"] = "Admitted"; the serialized key
is "State", so the edit added a second, lower-cased property and left the plant
Held. The assertion then held whichever file answered, and the fact passed
against the mutated code as readily as against the fixed code — passing cert-gate
at 1183/1183 twice while proving nothing. The arrangement is now checked before
anything is asserted: if the plant does not really differ from the honest record,
the fact fails on that rather than on the thing it exists to pin. "A mutation
that did not apply is not evidence" applies to the arrange step too.

Also: the two-step exit out of an unreadable marker now says why --repair alone
is not it — that it reads the same check first and fails identically — because an
operator who has been trained to reach for --repair will reach for it first.

VERIFICATION, STATED EXACTLY. cert-gate ran 1183/1183 in-container on this tree
WITH the canonical-file preference and the ordinal enumeration, and separately at
1183/1183 before the ordering change. The corrected fact was then run on its own:
it passes clean and fails under the mutation that restores the enumeration-order
winner. What has NOT run is a full three-suite sweep after the fact's arrangement
was corrected — that change touches only the test file, and the fact passes in
isolation, but the sweep was interrupted and nobody should read this as though it
completed. Re-run cert-gate, Ashlar.Tests.CLI (net10.0) and Ashlar.Tests.Kernel
(net8.0) before this branch is pushed.

Recorded, not fixed: a date escape bolted onto the hash-mismatch arm of
GateSignatureExpectation.Refuse reddens NOTHING across all 1183 facts — executed,
not reasoned about. That blind spot is what the Group 6 scan and its paired
behavioural fact exist to close, and they are the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@IanFrelinger
IanFrelinger marked this pull request as ready for review September 21, 2026 02:27
t and others added 2 commits September 26, 2026 21:15
…ry sentence

The mechanism landed in the four commits before this one. This is the half that
says what it does NOT do — and until now the branch shipped two sentences that
were false.

SPEC-006 claimed an actor who strips every signature and deletes the marker is
"undetectable to a keyless reader (a keyed reader still catches it through the
marker its key wrote)". There is no keyed-reader advantage. The marker lives in
the directory being attacked, so an actor who strips everything deletes it too
and the keyed reader is left with no anchor either; the pinning set is public key
material, never a memory of this store. The parenthesis is gone and the sentence
now says the store resolves to never-signed for EVERY reader.

The CHANGELOG told operators the remedy for a half-signed history is to archive
the offending records out of gates/. That is the budget forgery this whole change
exists to stop: every removed admission raises the remaining self-extension
budget by one, because the budget counts what is present, and removing a REFUSED
record lets the same proposal be proposed again from scratch. The remedy is
`ashlar gates sign-activate --repair`. The old advice is RETRACTED IN PLACE
rather than deleted — "an earlier version of this entry told you to archive the
offending records. Do not do that — it was wrong" — because somebody may have
read it and acted on it, and a silent edit would leave them with no way to know.

GateSignatureResidualTests executes fourteen disclosed residuals: a total strip
with the marker deleted reading clean for every reader; a sibling marker carried
with its records importing verdicts the victim never made; a signed admission
copied from a sibling anchoring the victim store and spending its budget; a
record planted before activation grandfathered permanently; the earliest record
being undatable by a derived anchor; deleting a record raising the budget AND
erasing a refusal; a keyless reader honouring a store whose every anchor the
attacker signed; a public key dropped into trusted/ making a forged signature
verify and pin; an unreadable marker halting the store; one misnamed file holding
a store permanently un-signable; the same bytes getting two verdicts under two
key directories; and the activation instant being whatever clock the caller
supplies. Each performs the attack through the store's public API and asserts the
store does NOT catch it, so every residual is a sentence somebody can check.

TWO ROWS CARRY NO FACT, AND SAY WHY. The stolen-operator-key half reduces, in a
test, to using the key correctly and observing the store accept what that key
signed — which asserts the system WORKS. A fact dressed up as that attack is the
covered debt this class exists to refuse, so the absence is made legible instead
of being written badly, and a companion asserts that shape in both directions: a
row naming a fact that does not exist fails, and a fact no row names fails too.
The second is the one a forward-only inventory cannot see — an attack this suite
demonstrates that the spec is silent about.

THE TRIPWIRE THE PLAN SPECIFIED WOULD HAVE BEEN VACUOUS, THREE TIMES OVER. The
plan asked that GateSignatureExpectation.cs must not contain `DecidedAt >=`. That
string occurs ZERO times: the shipped floor is spelled
`record.DecidedAt < DerivedGraceBefore.Value` inside a negation, so the assertion
is green on arrival and a full regression spelled with `<` sails past it. A panel
of three judges then found two more escapes that keep every proposed count green:
adding a second floor against `record.Proposal.ProposedAt`, which is equally the
writer's own field, and swapping the two else-if arms so the date is tested before
membership. `Grandfathering_is_a_list_not_a_timestamp` counts the date-field names
as a SET whose sum is one, and asserts the ORDER directly, because counts cannot
see an order.

IT ALSO NAMES THE ESCAPE IT CANNOT CATCH. A date rule relocated into
GateStore.ResolveExpectation and passed into Refuse as an extra parameter is
invisible to a scan scoped to GateSignatureExpectation.cs, and nothing here scans
for that. The XML doc says so rather than implying coverage that does not exist.

A_grandfathered_record_with_changed_bytes_is_refused_even_below_the_derived_floor
exists because of a measurement, not an argument. On 2026-09-20 the escape
`&& !(DerivedGraceBefore is not null && record.DecidedAt < DerivedGraceBefore.Value)`
was appended to the hash-mismatch guard and the full suite was run in-container:
1183 passed, 0 failed, zero facts reddened. The negated form matters — an `||`
there makes the guard fire MORE and proves nothing. The doc records the date, the
form and the result so the next reader can re-run it, because that measurement is
the only justification for both this fact and its structural twin.

The absence companion cannot use File.ReadAllText(...).Contains(needle). SPEC-006
wraps that sentence across two lines and .gitattributes has `* text=auto`, so the
file is CRLF locally and LF on the runner; a raw Contains was MEASURED green with
the sentence still present. Both sides are whitespace-flattened.

GateStoreAnchorProvenanceConventionTests lists the two anchors that decide a
store's posture, both attacker-writable, and forces the disclosing sentence to
exist. Its weaker limb is labelled for what it is: asserting that no row is
operator-side reads a bool literal out of the test's own table, so it is an
addition-tripwire, not a fact, and the doc says so rather than dressing it up.

FOUR CORRECTIONS TO THE PANEL'S OWN OUTPUT, applied rather than accepted:
  * its spec text said GetAsync MUST refuse when two files hold one id. What
    shipped is the canonical file winning; refusal is reserved for the case where
    no file bears the id's name. Both the rule body and the CHANGELOG now describe
    the real behaviour, including why the arbitrary winner mattered — DecideAsync
    reads through that path, so a planted file sorting first could make an honest
    proposal permanently undecidable with nothing overwritten.
  * its required change for that finding would have reddened
    A_never_signed_store_behaves_exactly_as_today_for_both_readers, and its stated
    blast radius — "verified by reading all 31 existing facts: none changes" — was
    wrong.
  * the MarkerReaders row claimed the marker reads happen "all under the store
    lock". AcquireLockAsync is taken by ActivateSigningAsync, RecordAsync,
    ProposeAsync and DecideAsync only; GetAsync, ListAsync and
    AdmittedInWindowAsync take none, and those are precisely the callers that
    drive ResolveExpectation into reading the marker.
  * its integrator instruction prescribed the `||` form of the date escape, which
    would leave the new fact green.

VERIFICATION — READ THIS BEFORE TRUSTING ANY OF THE ABOVE. Docker is shut down at
the user's request and host dotnet is forbidden here, so NONE of this was compiled
or run locally. Every symbol was checked against the real source by a reviewer
whose whole job was to stand in for the compiler — it reflected over
FluentAssertions 8.10.0 for overload resolution, re-measured every asserted
literal and count over 2016 production files, and returned BUILDS for both new
classes and BUILDS_WITH_FIXES for the edits, whose fixes are the four corrections
above. That is not a build. CI is the verification: cert-gate on ubuntu-latest
runs the same gate. Expect the fact count to rise by 19 and treat any red as real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e file

CI caught what no local check could, because Docker is down and there is no
compiler here: CS1519 at GateSignatureExpectationTests.cs:1181 and CS1513 at EOF.
cert-gate failed in 59s, build-core in 1m12s, and eight downstream jobs with them.

MY ERROR, NOT THE AUTHOR'S. The edit plan said to insert the new fact "between
that fact's closing `}` and the class's closing `}`", and quoted an anchor whose
last two lines were BOTH braces. My insert-after applied the payload after the
whole anchor, so the method landed outside the class: the class closed at 1181 and
the file ended one brace short.

THE CHECK I RAN WAS BLIND TO IT, AND THAT IS THE MORE USEFUL FINDING. I verified
"braces balanced" with a state machine that skips comments and string literals,
and it reported BALANCED — correctly. A method inserted after the class's closing
brace keeps the totals equal and returns final depth to zero: the class closes
early, the stray method opens and closes, and nothing is left over. Balanced
totals cannot see this shape at all.

What distinguishes it is that depth returns to zero BEFORE the end of the file and
real code follows. scratchpad/struct_check.py now reports, per file, final depth,
whether depth ever went negative, how many times a brace returned it to zero, and
whether anything trails the last one. For a file-scoped namespace holding one test
class the answer must be exactly one top-level type, closing on the final line.
The reconstructed defect scores top-level-types=2 while still showing depth=0 and
negative=False — so the new check catches it and the old one provably could not. I
re-ran it against the broken state to confirm that rather than assuming it, on the
same principle this branch applies to its own tests: a check that cannot fail is
not a check.

No test content changed; this moves one brace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@IanFrelinger
IanFrelinger merged commit 9a5e580 into master Sep 27, 2026
37 checks passed
@IanFrelinger
IanFrelinger deleted the autonomy/pr3-signature-expectation branch September 27, 2026 02:00
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