Repository navigation
fix(admission): grandfathering is a list the operator signed, not a date the attacker owns - #645
Merged
Merged
Conversation
…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
marked this pull request as ready for review
September 21, 2026 02:27
…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
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.
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 recordDecidedAtis the writer's own field. An actor who could writegates/did not have to strip anything: they could write a brand-new unsignedAdmittedrecord dated one second below a floor the store's own records expose, and it was grandfathered, counted toward the self-extension budget, and shown byashlar gatesas 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)andReadiness summary.cert-gateon the hosted runner is the same gate that last ran green locally at 1183/1183.What the commits do
GetAsync,ListAsync,AdmittedInWindowAsyncand both write guards read through a singleReadStoreAsync(id, canonical-sha256), signed into the marker, plus the operator count lineDefects 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:
text=autoline-ending change would have refused the whole store forever — a brick armed by adoption. It is overCanonicalJson.Bytes(record), which also removed a second read of a fileParseAllAsynchad already read.EarliestProvenInstantcomputed the floor and returnednow.--repairre-anchored at repair time — the exact floor-forward move the method exists to refuse.AdmittedInWindowAsyncexcluded unsigned admissions whenever the store was signed, soashlar keys initsilently freed up to a full window of self-extension budget.Amendcall after a keyed write was a no-op under a six-line comment explaining why the call must happen.WriteAsyncrule (e) refuses. Delete the marker, plant an unsignedAdmittedrecord under the derived floor, and the operator's ownsign-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.ActivatecallsReadVerifiedfirst, so--repairthrew 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.GetAsyncanswered by directory enumeration order when two files claim one id. Not merely a funnel disagreement:DecideAsyncreads throughGetAsync, 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:
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 isashlar gates sign-activate --repair. The old advice is retracted in place rather than deleted, because somebody may have acted on it.GateSignatureResidualTestsexecutes 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 intotrusted/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.csmust not containDecidedAt >=— matches zero times, because the shipped floor is spelledrecord.DecidedAt < DerivedGraceBefore.Valueinside a negation. Two further escapes keep every proposed count green: a second floor againstProposal.ProposedAt, equally the writer's own field, and swapping twoelse ifarms. 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 intoGateStoreis invisible to a single-file scan.A_grandfathered_record_with_changed_bytes_is_refused_even_below_the_derived_floorexists 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:
CS1519andCS1513in 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=2while still showingdepth=0, negative=False, proving the old one could not have caught it.Deliberately not done
A_never_signed_store_behaves_exactly_as_today_for_both_readersalready 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.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:
Refusereddens nothing across all 1183 facts — executed, not reasoned about. That blind spot is what Group 6's scan exists to close.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 insrc/Ashlar.Manifest/Admission/, whichlayer-boundaryrefuses 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/*rejectsapplication/; a base ofapplication/*rejectssrc/. 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 initorgates sign-activate, and nothing else in the design witnesses that instant. An actor who plants a fabricatedAdmittedrecord 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. Landingsrc/first would ship an inventory whose sole control does not exist yet; landingapplication/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 nameashlar gates sign-activate --repairas 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.csregisters it.KeysCommand.cscarries the same count line becausekeys initis 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.csandPkgCommand.cschange only to followActivateSigningAsync's new return type andGetAsync'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