* test(#3468): add write-path drift guard, ratcheted at its measured baseline
Guard-first, per ADR-3180 Amendment 3's standing rule that a phase builds
and runs its guard BEFORE its scope is fixed, and states its copy count as
'N found by the guard', never 'N per the epic'.
Measured, not assumed:
Axis 1 (policy dispatch, ADR-3408 section 8.1) — 7 violations, RED by
design. 5 field-name-keyed getFieldClassification('literal') branches
plus 2 declared FieldPreservation members with no executor at all
(derive, clear). This is the fail-first evidence for the refactor.
Axis 2 (write seam, section 8.3) — 4 bypasses, ratcheted. Epic #3408
scoped this at two writers; the whole-repo scan found four, and one the
epic named (patchCore) is not among them because it bypasses via
stateReplaceField rather than the seam calls. Fourth consecutive time an
epic's copy count proved a lower bound.
Two detectors were written and removed again before this commit, both
recorded in the file header rather than silently dropped:
- A prompt-layer detector that reported 5 backticked prose mentions as
drift. That is ADR-3180 Amendment 3's recorded false-positive class,
and CONTRIBUTING.md already settles it: a backticked command reference
is a mention. Now gated on inline-code spans.
- A stateReplaceField co-occurrence detector for section 8.3(b). Measured
at 29 false positives to 1 true positive — it matched the function's own
definition and ~20 calls on frontmatter-free body slices. Banking 29
non-defects to catch one is the 'ratchet as a parking lot' gaming route
Decision 5 names, so it is a DECLARED KNOWN GAP owned by Phase 2
(#3469), which both fixes it and makes its detection tractable.
* test(#3468): failing-first coverage for policy dispatch and the loud failure
Matrix sections A, B and C from 50-test-matrix.md.
Expected RED against this tree, confirmed by static trace rather than
assumed:
B1, B2, B3 — an unwired declared preserve-when-unchanged row must throw
with code STATE_PRESERVATION_UNWIRED_ROW and a structured .field. Today
src/state-transition.cts:314 silently continues.
A4 — a whitespace-only snapshot is restored today, because the guard is
.length > 0. Required behavior is skip.
Everything else is characterization, locking in behavior the refactor must
preserve. C1 is table-driven over every FIELD_CLASSIFICATION key; C2 pins
current_phase_name's exact outputs as literals, because its row is being
reclassified preserve-always to preserve-when-unchanged as a
behavior-preserving change and nothing else would catch a drift. C3 is a
seeded fast-check property (seed 3468, 200 runs, replay data on failure).
A22 is deliberately NOT a behavioral test. Whether 'derive' has an explicit
executor is not observable through applyStatePreservation's public API — it
is a structural property, and the drift guard's unimplemented_policy axis is
what enforces it. That split is ADR-3408 Decision 5's own pairing: the lint
is the structural metric, the test is the outcome metric, and neither is
reported alone.
* refactor(#3468): dispatch preservation on the declared policy, not the field
Implements ADR-3408 sections 8.1, 8.2 and 8.6.
applyStatePreservation is now one loop over FIELD_CLASSIFICATION dispatching
on the row's preservation value, with four small executors — one per
FieldPreservation member. No branch is selected by field name. Zero
literal-argument getFieldClassification calls remain.
Behavior-preserving for 16 of 20 input classes. The four that change:
- An unwired declared preserve-when-unchanged row now THROWS
(code STATE_PRESERVATION_UNWIRED_ROW, structured .field) instead of
silently continuing. This fires only on an internal invariant violation
with both ends in our own source; a drifted, malformed or unparseable
user STATE.md must never reach it, which is section 8.2's bright line
and what test B8 proves through the real CLI.
- derive gained an explicit no-op executor. That is what makes the throw
decidable: 'policy says do nothing' is now distinguishable from 'nobody
wired this'.
- current_phase_name's row is corrected from preserve-always to
preserve-when-unchanged. The row was wrong, not the code — it has always
been delta-gated on the body Phase line, so preserve-always had two
divergent implementations. Behavior is unchanged and test C2 pins it.
- A whitespace-only snapshot is no longer restored; the check is trimmed.
clear is deleted from the FieldPreservation union — no row used it and no
executor existed. Speculative Generality: a policy invented for a need that
never arrived. Verified zero dependents.
The caller folds six dedicated pre/post parameters into one bodyDeltas map
keyed by field, so all seven preserve-when-unchanged rows travel one channel
instead of two. Two shapes for one kind of data is why the executor needed
per-field branches at all.
Also fixed, found while reviewing the refactor rather than deferred:
- applyPreserveIfPlaceholder opened with a field-name literal test, which
section 8.1 forbids outright. The executor is idempotent, so the test
bought nothing. The drift guard could not see it, so Axis 1 is widened
to catch field-variable comparisons against literals — the guard
reported zero while a violation sat in the file it polices, which is
Goodhart's gaming-by-indirection.
- loadBaseline conflated an unreadable baseline with an absent one. A
guard whose own diagnostic collapses two states into one identical
result reproduces the exact failure shape this epic exists to remove.
* docs(#3468): record Phase 1 validation as ADR-3408 Amendment 1
Amendment 1 records what Phase 1 found, per ADR-3408 section 8's rule that a
behavior it does not state is not decided:
- preserve-always had TWO divergent implementations; current_phase_name's
row was wrong and is reclassified, behavior unchanged.
- section 8.6 resolved: clear is deleted, zero dependents.
- the closed guard vocabulary is real and has exactly one true member,
because stopped_at's scoping turned out to be caller-side extraction.
- copy count found by the guard: 4 write-seam bypasses where the epic
scoped 2, and patchCore — one of the two it named — is not among them.
- two detectors built and removed again, with their measured false-positive
rates, so nobody re-attempts them.
- a DECLARED KNOWN GAP for section 8.3(b), owned by Phase 2.
- Decision 5's anti-gaming list earned itself twice in one phase.
Also adds the changeset fragment.
* test(#3468): fix review findings — try/finally, stale clear allowlist, ratchet owners
Standards axis, both hard violations:
- tests/state-write-path-drift-guard.test.cjs wrapped stdout/argv/exitCode
restoration in try/finally inside the test body. CONTRIBUTING.md:356
forbids it outright, and the correct t.after() pattern was already in
use two lines up in the same test.
- tests/state-transition.test.cjs still listed 'clear' as an allowed
FieldPreservation value in the row-enumeration test AND the
getFieldClassification property test, after this PR deleted it. A stale
allowlist weakens the property's negative space — it would accept a
resurrected clear row as valid.
Contract tension, resolved rather than left:
ADR-3408 section 8.3 requires each ratchet entry carry the issue owning
its removal. All four shipped with owner: null. The guard was right not to
INVENT one, but the owners are known from the phase plan, so recording
them is not inventing: phase.cts -> #3469, state.cts and milestone.cts ->
#3471, health-diagnostic.cts -> sanctioned-permanent.
Rather than a JSDoc caveat, --baseline now MERGES prior owner values on
the (file, source) key, so a mechanical regeneration can no longer
silently discard curated provenance. Verified by regenerating twice.
* fix(#3468): sanitize attacker-controlled fields on every guard output path
Isolated security review, MEDIUM, confidence 8/10.
findSeamBypasses and findPromptSeamUses built findings with an UNSANITIZED
`file`, while the co-located `source` on the same object was correctly
wrapped in sanitizeForReport. On a fork PR a filename is exactly as
attacker-controlled as a source fragment — a repo can legally track a
filename carrying C1 control bytes or bidi overrides.
The raw value reached two paths: --json stdout, and the COMMITTED baseline
JSON via buildBaselineEntries. JSON.stringify neutralizes C0 controls but
does NOT escape C1 (0x7f-0x9f) nor the bidi/zero-width range
sanitizeForReport exists to strip — which is the precise threat the guard's
own header names. Only the human formatter was safe.
Sanitization now happens at CONSTRUCTION, so every consumer inherits it
rather than each output path having to remember. The same defect was present
on `field` and `policy` and is fixed alongside. Double-sanitization in the
formatter is left in place, verified idempotent: escaped output is ASCII and
cannot re-match the control/bidi classes.
Also: the guard was not referenced anywhere in package.json, so nothing ran
it. A drift guard nobody runs is not a guard, and ADR-3408 Decision 5 assumes
it runs. Wired into lint:ci beside its sibling drift guards; it was already
green on this tree, so the chain stays green.
* chore(#3468): re-curate ratchet after an upstream rewording of a tracked bypass
The rebase onto origin/next turned the guard red on its first real day, which
is the ratchet working rather than a defect.
c90ae479f fix(#3350) reworded cmdPhaseComplete's syncStateFrontmatter call
onto one line and changed its third argument. Because entries are keyed on
(file, trimmed source text) rather than a line number, that single upstream
edit registered as BOTH a stale acknowledgment and an unrecorded site — the
two-sided signal the design intends, forcing a human to look rather than
letting a tracked bypass drift out of view.
The owner-preserving merge behaved exactly as designed: three owners survived
because their keys were unchanged, and phase.cts's dropped to null because its
source text is genuinely a different key. Re-curated to #3469, the phase that
owns its removal.
Note for Phase 2: c90ae479f is #3350's fix landing independently on next —
one of the two instances Phase 2 was scoped to drive fail-first. Surfaced to
the epic rather than absorbed silently.
* test(#3468): derive B1's fixture from the table so it cannot go stale
Checkpoint 2 came back with 2 failures of 33803, both B1:
actual 'current_phase_name'
expected 'current_plan'
The implementation was right and the test was stale. B1 hand-built a
bodyDeltas literal intending current_plan to be the ONLY unwired row, but it
also omitted status, stopped_at and current_phase_name — all three of which
became preserve-when-unchanged rows in THIS PR. Table order puts
current_phase_name first, so the throw correctly named it.
B1 now builds from neutralBodyDeltas() and deletes exactly one key, which is
what its own comment always claimed it did. A future table change can no
longer silently make it assert the wrong field.
Audited every other bodyDeltas literal in the file: four exist, all correct —
two enumerate all seven rows explicitly, two pass {} where the emptiness is
the point of the test. Roughly thirty other sites already derive from the
helper.
Also renames the local unchchangedChanged to lastActivityDescChangedDeltas.
A typo'd identifier that happens to work is still a Mysterious Name; noted
during research and fixed now that this change touches the file.
* chore(#3468): re-curate ratchet and fold the seam channel into the shared helper
The rebase onto be9329b10 fix(#3374) was a true semantic conflict, resolved
rather than handed back, because the resolution was determinable:
That PR extracted the post-sync preservation pass into a shared
applyPostSyncPreservation helper — which is ADR-3408 section 8.3, i.e. a
piece of Phase 2's own deliverable, landing upstream. Its structure is kept
wholesale; this branch's contribution is applied INSIDE it.
That combination had to be checked rather than assumed. Upstream's helper
wires only FOUR bodyDeltas keys and still passes status / stopped_at /
current_phase_name through six dedicated parameters. This branch reclassifies
current_phase_name to preserve-when-unchanged, deletes those six parameters
from StatePreservationInput, and makes an unwired declared row THROW. Taking
upstream's file as-is would therefore have thrown on EVERY STATE.md write.
The helper now wires all seven rows through the single channel. Verified
7-to-7 against FIELD_CLASSIFICATION, with a clean tsc — which is the real
proof the dedicated parameters are gone, since they no longer exist on the
input type.
The ratchet also caught the same phase.cts call being reworded a second time,
reporting it as both a stale acknowledgment and an unrecorded site. Re-curated
to #3469. Recording the tradeoff plainly: keying on (file, source text) means
an upstream reword of a tracked line needs re-curation, where keying on line
numbers would churn on every unrelated edit. ADR-3180 Decision 4(e) chose
source text deliberately, and the owner-preserving merge added earlier covers
the common case where the text is unchanged.
* chore(#3468): backfill pr number in changeset fragment
---------
Co-authored-by: sim <sim@local>