refactor(#3469): one composition for the STATE.md write seam (#3501)

* docs(#3469): amend ADR-3408 section 8.3 — the pipeline has sanctioned exceptions

Section 8.3 read 'Every STATE.md write applies the pipeline.' That is false by
design for two commands, and acting on it would have inverted a shipped
feature.

Preservation makes curated frontmatter win over a re-derived body value.
state sync exists to do the opposite — #905's 'body annotation beats existing
frontmatter when both are present'; it re-derives frontmatter FROM the body.
REGENERATE_STATE is a factory reset that rebuilds STATE.md from scratch.
Applying the pipeline to either would re-lock exactly what the command was
invoked to replace.

This issue's own scope line, inherited from the epic, said to route the direct
writeStateMd callers through the pipeline. For cmdStateSync that would have
shipped silently, with every gate green, because no test asserts that sync
LETS the body win. Caught by reading the helper's docstring and then verifying
the claim against the code — a stale comment had already misdirected this epic
once.

Both commands are now named in a closed exception list and are permanent
ratchet entries.

Consequence recorded rather than left to bite Phase 4: the 'drive the ratchet
to 0 and delete the file' target in this ADR and in #3471 is wrong. Two
entries are permanent, so the correct end state is 2, and the honest report is
'0 removable bypasses, 2 sanctioned'. A guard reaching 0 here would only do so
by having stopped looking at two real writers.

* refactor(#3469): one composition for the write seam, not one per caller

Implements ADR-3408 section 8.3 as amended.

syncAndPreserveStateMd is now the single composition of syncStateFrontmatter
and applyPostSyncPreservation. readModifyWriteStateMd and cmdPhaseComplete
both CALL it instead of each assembling the two steps themselves.
cmdPhaseComplete keeps its own writePlanningFileSet envelope — the
composition returns content, it does not take over the write, so STATE.md
still commits atomically with ROADMAP and REQUIREMENTS.

Assembling the stages at a call site is a re-derivation even when every step
calls an owner. Upstream's fix(#3374) routed cmdPhaseComplete through
applyPostSyncPreservation but left it calling syncStateFrontmatter directly
first, so the composition was duplicated and free to diverge with both guards
green. That is ADR-3180 Amendment 2's finding repeating on the write side.

cmdMilestoneComplete gains preservation. It wrote through writeStateMd, so it
got sync and no preservation — the identical shape #3374 reported for
phase.complete, and flagged upstream as a follow-up in the helper's own
docstring. This is that follow-up.

Divergence is now visible: preservation_warnings names each field restored
over a disagreeing derived value. Deliberately NOT named warnings —
cmdPhaseComplete already exposes warnings as a prose string array, and two
sibling commands carrying that name with different element types is
Generative Fix Divergence, the class this epic exists to remove.

patchCore stops running stateReplaceField over the whole document. One
observable consequence, intended per design row 9: a frontmatter-shaped patch
key with no body counterpart now reports failed instead of silently
succeeding, because the old whole-document match was literally hitting the
YAML line case-insensitively.

The guard closes Phase 1's DECLARED KNOWN GAP as promised rather than
re-deferring it: section 8.3(b) detection is tractable now the composition
exists. Scoped by two factors to avoid Phase 1's measured 29-to-1 false
positive rate — a variable field-name argument AND a content argument whose
nearest preceding assignment is not stripFrontmatter. Verified 0 findings and
0 false positives across all 33 call sites, plus 5 synthetic shapes. It also
detects the re-assembly shape above.

Ratchet: 4 entries to 2, both sanctioned-permanent. cmdStateSync's owner
changes from #3471 to sanctioned-permanent per Amendment 2 — routing it
through preservation would invert the #905 contract.

Also fixed inline rather than deferred: cmdMilestoneComplete's STATE.md read
now happens inside withStateLock. It previously read outside any lock before
writeStateMd took its own, leaving a TOCTOU window under concurrent writers.

* test(#3469): characterization coverage for the single write seam

Matrix sections A-E. Criterion 6 was amended by maintainer decision — all five
instances closed by point fixes while Phase 1 was in flight — so these are
characterization tests at the consumer's output per ADR-3180 Decision 4(b)/(c),
paired with the drift guard's count, never either alone.

Section C is the one that earns its keep. cmdStateSync is a sanctioned
permanent exception: state sync exists to re-derive frontmatter FROM the body,
so preservation there re-locks exactly what the command was invoked to
replace. C1 pins that the body wins; C4 pins that this phase left the command
byte-identical. Nothing else in the suite would notice if a future change made
sync start preserving, and the natural reading of 'one write seam' is to make
precisely that change.

Section E pins the guard's false-positive scoping. E4 (updateCore's
strip-then-replace) and E5 (sectionBody-scoped calls) must NOT be reported —
the naive detector measured 29 false positives to 1 true positive in Phase 1.
E7 is the inverse: a sanctioned-permanent entry disappearing must FAIL,
because a guard reaching zero here would only do so by having stopped looking
at two real writers.

Also corrects a stale test that asserted patchCore's old whole-document
behavior, which this phase deliberately changes.

One honest limitation, flagged rather than papered over: A1's 'byte-identical
to pre-refactor' cannot be diffed against real pre-refactor bytes from inside
the suite. It is implemented as the seeded fast-check property that
cmdPhaseComplete's composed output equals readModifyWriteStateMd's for the
same inputs — the strongest available proxy, not the literal claim.

* docs(#3469): refresh the seam glossary entry and add the changeset

Two spec-review gaps, both real.

CONTEXT.md's STATE.md Transition Module entry named three direct writeStateMd
callers including cmdMilestoneComplete. This phase routed that one through the
composition, so the line was false the moment the refactor landed.

Worth recording plainly: I wrote that sentence in Phase 0, correcting an
older stale pointer in it, and my own Phase 2 change invalidated it again
within the same epic. That is the exact drift this epic exists to remove,
demonstrated on the epic's own documentation — and it is why the entry now
ends by saying the whole-repo drift guard, not this line, is the authoritative
count.

The entry now records the composition (syncAndPreserveStateMd) and states that
exactly two direct callers remain, both SANCTIONED PERMANENT rather than debt.

Changeset: type Changed, because milestone complete's observable output moves.
Tier-2 per ADR-3180 Decision 3 — a stale body line no longer wins over fresher
frontmatter, and the command gains preservation_warnings. Docs requirement is
met by the ADR amendment already in this diff.

* test(#3469): register property-test temp-dir cleanup at creation time

Standards review, minor but real: the new fast-check property cleaned up its
temp dirs in a loop AFTER fc.assert returned. A genuine property failure
throws, so that line never ran and every dir from the failing run — including
all of fast-check's shrinking iterations — leaked.

The failure path is exactly when a littered machine hurts most, and a failing
property test is the case the test exists for.

Cleanup is now registered with t.after() at dir-creation time, so teardown
happens however the test exits. Not try/finally — CONTRIBUTING.md:356 bans it
inside test bodies, which is why the after-the-assertion shape existed in the
first place.

Swept the rest of the branch's test diff for the same shape; phase.test.cjs
already uses registered teardown and nothing else matched.

* fix(#3469): patchCore routes frontmatter writes instead of dropping them

Checkpoint returned 10 failures of 33880. One implementation defect, three
test defects, one stale test — all fixed, and the implementation defect is the
one that matters.

patchCore stripped frontmatter and then reconstructed it VERBATIM, applying no
patches to it. An arbitrary custom frontmatter key with no body counterpart and
no FIELD_CLASSIFICATION row — risk_level in the upstream fix(#3351) test —
therefore always reported failed and silently never wrote. It worked before,
via the old whole-document match on the raw YAML line.

That is a regression against this phase's own design row 9, which requires
frontmatter changes to ROUTE THROUGH the seam — still work, policy-governed —
not to stop working. Removing a capability is not routing it. An upstream test
caught it, which is the argument for running the checkpoint before believing
the refactor.

patchCore now partitions by frontmatter shape, decided structurally from the
parsed frontmatter's own keys rather than a naming heuristic:
  - classified keys still report failed — policy owns them and a raw patch may
    not bypass it;
  - unclassified keys apply to the frontmatter object and report updated —
    Phase 1's behavior-table row 19, a field with no row is not this contract's
    business;
  - body-shaped keys are unchanged.

The property 'failure' was my own test breaking the repo's Clock Seams rule.
The two paths agree byte-for-byte; the only difference was last_updated,
stamped from the wall clock on two invocations milliseconds apart, so it could
never pass. Time is now frozen with mock.timers across both — not by excluding
last_updated from the comparison, which would have silently stopped comparing
a field the composition writes.

B4's fixture could not discriminate: normalizeStateStatus maps any text
containing 'complete' to 'completed', and milestone complete's own new body
value derives to exactly that — which was also the fixture's stale value. The
stale value is now 'executing' so the assertion can tell 'body correctly won'
from 'stale survived'.

B5's fixture tripped a pre-existing unstarted-phase guard before reaching any
write-seam code; it now has the matching phase directory.

D9 asserted the old exempt set. readModifyWriteStateMd now calls one symbol
rather than assembling two, so it needs no exemption; syncAndPreserveStateMd
is the sole legitimate composition site.

* fix(#3469): patchCore resolves body-first, so the body wins a name collision

Re-verification returned 2 failures of 33880, both D4 — the hostile row for a
key that exists as BOTH a frontmatter key and a body field.

The partition checked frontmatter first, so 'status' — classified in
FIELD_CLASSIFICATION and also present as a body 'Status:' line — routed to the
frontmatter branch, was rejected as classified, and reported failed.

Wrong order. Patching 'status' means the body field, and upstream fix(#3351)
says so in its own comment: 'the legitimate working case for state.patch is
display-cased BODY fields — Status, Current Plan, Phase.' The body is
authoritative in this model; frontmatter is the projection. D4 asserted
exactly that and was right.

Resolution order is now body, then frontmatter:
  1. resolves to a body field -> apply to body, updated
  2. else an own key of the frontmatter:
       classified   -> failed  (policy owns it)
       unclassified -> apply to frontmatter, updated
  3. else -> failed

Verified by probe against the compiled lib for all four cases rather than
asserted: risk_level (frontmatter-only, unclassified) still lands;
current_phase still fails; display-cased Status unchanged; D4's lower-cased
status now lands via the body with the frontmatter untouched.

The current_phase case was the one that could have regressed silently, so its
fixture was read rather than assumed — D1's body carries 'Phase: 3 (alpha)'
and no 'Current Phase:' line, so body-first cannot reach it.

* chore(#3469): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 16:04:09 -04:00
committed by GitHub
parent 71180983a0
commit e2f4c16d9e
15 changed files with 1443 additions and 152 deletions

View File

@@ -14,7 +14,7 @@ import planningWorkspace = require('./planning-workspace.cjs');
import frontmatterMod = require('./frontmatter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
import stateMod = require('./state.cjs');
import { platformWriteSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs';
import { platformWriteSync, platformReadSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs';
import { transitionCore } from './state-transition.cjs';
@@ -50,7 +50,13 @@ import phaseLocatorMod = require('./phase-locator.cjs');
const { listMilestonePhaseDirs } = phaseLocatorMod;
const { planningPaths } = planningWorkspace;
const { extractFrontmatter } = frontmatterMod;
const { writeStateMd } = stateMod;
// ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the
// same #3374-shaped exposure the milestone-complete write used to carry (a
// stale body value silently clobbering fresher frontmatter, with no
// divergence signal). Routed through the single write-seam composition
// (`syncAndPreserveStateMd`) instead, under `withStateLock` — see
// `cmdMilestoneComplete`'s own STATE.md-update block for the full rationale.
const { syncAndPreserveStateMd, withStateLock } = stateMod;
// #2288 security: a milestone version label becomes a filesystem directory
// component (`milestones/<label>-phases/`) into which phase directories are
@@ -531,6 +537,24 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
const phasesDir = planningPaths(cwd).phases;
const today = realClock.localToday();
const milestoneName = options.name || version;
// ADR-3408 §8.5 / #3469: "liberal but visible" — when the write-seam
// composition's preservation stage restores a curated frontmatter value
// over a disagreeing freshly-derived one, that divergence is surfaced
// here rather than silently absorbed (the direct answer to #3374's
// `warnings: []`). Structured (field + reason), not prose, so a caller can
// assert on the value rather than regex a rendered message.
//
// Named `preservation_warnings`, NOT `warnings`: `cmdPhaseComplete` already
// exposes a sibling field called `warnings` typed as prose `string[]`. Reusing
// that name here for a structured `{field, reason}[]` shape would be the
// "Generative Fix Divergence" anti-pattern — two sibling state commands
// sharing one field name with different element types. `warnings` stays
// one meaning (prose) repo-wide; this is a distinct, machine-assertable
// signal for ADR-3408 §8.5's "preservation is visible" rule. Check
// `preservation_warnings.length` rather than a companion `has_warnings`
// flag — that flag existed only to mirror `cmdPhaseComplete`'s channel,
// which this field intentionally does not claim to be.
const preservationWarnings: Array<{ field: string; reason: string }> = [];
// Scope stats and accomplishments to only the phases belonging to the
// current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
@@ -850,19 +874,49 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
// reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
// src/state-transition.cts, backed by the field-classification table. The
// runtime-specific next-milestone slash command is resolved here and injected
// via the intent so the core stays pure. writeStateMd still owns the lock and
// the steady-state syncStateFrontmatter post-sync.
// via the intent so the core stays pure.
//
// ADR-3408 §8.3 / #3469: this used to write via `writeStateMd`, which gets
// sync and NO preservation — the identical shape #3374 reported for
// `phase.complete` (a stale body value silently clobbering fresher
// frontmatter). Routed through the single write-seam composition
// (`syncAndPreserveStateMd`) instead, under the same lock discipline
// `cmdPhaseComplete`'s atomic-commit adapter already uses: `withStateLock`
// wraps read + transform + sync + preserve + write so the read this
// transaction bases its transform on cannot be raced by a concurrent
// writer (closing a pre-existing TOCTOU gap `writeStateMd`'s own internal
// lock never covered, since the read used to happen before any lock was
// taken). `resync: true` mirrors `cmdPhaseComplete`'s posture (progress
// recomputed from disk; only the preserve-when-unchanged deltas apply) —
// milestone completion is the same kind of lifecycle transition.
if (fs.existsSync(statePath)) {
const result = transitionCore(
fs.readFileSync(statePath, 'utf-8'),
{
kind: 'milestoneComplete',
version,
nextMilestoneCommand: formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string,
},
{ clock: realClock, sourcePath: statePath },
);
writeStateMd(statePath, result.content, cwd);
withStateLock(statePath, () => {
const originalStateContent = platformReadSync(statePath) || '';
const result = transitionCore(
originalStateContent,
{
kind: 'milestoneComplete',
version,
nextMilestoneCommand: formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string,
},
{ clock: realClock, sourcePath: statePath },
);
const divergedFields: string[] = [];
const finalContent = syncAndPreserveStateMd(
originalStateContent,
result.content,
statePath,
cwd,
true,
undefined,
undefined,
divergedFields,
);
platformWriteSync(statePath, finalContent);
for (const field of divergedFields) {
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
}
});
}
// Archive phase directories if requested
@@ -915,6 +969,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
},
milestones_updated: true,
state_updated: fs.existsSync(statePath),
preservation_warnings: preservationWarnings,
};
output(result, raw);

View File

@@ -90,8 +90,7 @@ const {
readModifyWriteStateMd,
stateExtractField,
stateReplaceField,
syncStateFrontmatter,
applyPostSyncPreservation,
syncAndPreserveStateMd,
withStateLock,
updatePerformanceMetricsSection,
} = stateMod;
@@ -2961,13 +2960,13 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// to the STATE.md Transition Module. The ~90-line inline RMW callback
// that lived here is the pure `completePhaseCore` in
// src/state-transition.cts, backed by the field-classification table.
// `updatePerformanceMetricsSection` + `syncStateFrontmatter` stay in
// this adapter: they are section-table / disk-scan concerns, not
// classified fields, and `syncStateFrontmatter` is the post-sync this
// transaction needs (it does NOT go through readModifyWriteStateMd
// because STATE.md is committed atomically with ROADMAP/REQUIREMENTS —
// the post-sync preservation pass runs via applyPostSyncPreservation
// instead, #3374).
// `updatePerformanceMetricsSection` stays in this adapter: it is a
// section-table / disk-scan concern, not a classified field. The
// sync + post-sync preservation this transaction needs runs via the
// single write-seam composition, `syncAndPreserveStateMd` (it does
// NOT go through readModifyWriteStateMd because STATE.md is
// committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
// #3374 / #3469).
const nextPhaseDisplayName =
phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
phaseDisplayNameFromSlug(nextPhaseName);
@@ -3021,27 +3020,28 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
current_phase_name: nextPhaseDisplayName,
}
: undefined;
const synced = syncStateFrontmatter(stateContent, cwd, authoritativeFm);
// #3374: the direct sync above deliberately bypasses
// ADR-3408 §8.3 / #3469: this deliberately bypasses
// readModifyWriteStateMd (STATE.md is committed atomically with
// ROADMAP/REQUIREMENTS), which also bypassed the #948/#1230
// preservation pass every RMW write gets — so a stale body
// `Stopped at:` line silently clobbered a fresher frontmatter
// stopped_at on every completion. Run the shared post-sync pass:
// snapshots from the on-disk pre-image (originalStateContent) and the
// transformed content, table-driven applyStatePreservation, then the
// #2736 authoritative re-assert (which restores the #3350 pairing
// override the preserve-always restore may have reverted). resync=true
// is the lifecycle-transition posture (progress recomputed from disk;
// only the preserve-when-unchanged deltas apply). Fields the
// transition legitimately rewrote (Status, Phase, Stopped At via
// completePhaseCore's #3374 continuity line) have changed body
// sources, so their deltas do not fire.
stateContent = applyPostSyncPreservation(
// ROADMAP/REQUIREMENTS), so it calls the single write-seam
// composition (`syncAndPreserveStateMd`) directly instead of
// assembling `syncStateFrontmatter` + `applyPostSyncPreservation`
// itself — a call site re-assembling the pair, even with every step
// calling an owner, is the exact re-derivation §8.3 forbids by name
// (Phase 2 found this shape live here). The composition runs
// snapshots from the on-disk pre-image (originalStateContent) and
// the transformed content, table-driven applyStatePreservation, then
// the #2736 authoritative re-assert (which restores the #3350
// pairing override the preserve-always restore may have reverted).
// resync=true is the lifecycle-transition posture (progress
// recomputed from disk; only the preserve-when-unchanged deltas
// apply). Fields the transition legitimately rewrote (Status, Phase,
// Stopped At via completePhaseCore's #3374 continuity line) have
// changed body sources, so their deltas do not fire.
stateContent = syncAndPreserveStateMd(
originalStateContent,
stateContent,
synced,
statePath,
cwd,
true,
authoritativeFm,
);

View File

@@ -1694,12 +1694,46 @@ function milestoneCompleteCore(
* Apply a `patch` transition to STATE.md content.
*
* Migrates `cmdStatePatch` (state.cts) onto the substrate. Applies each
* caller-supplied `{field: value}` pair via `stateReplaceField` over the full
* content (body + frontmatter — patch can target either), tracking which fields
* were updated vs. not found.
* caller-supplied `{field: value}` pair, resolved BODY-FIRST:
*
* - A key that resolves against the STRIPPED body (via `stateReplaceField`,
* case-insensitive on the field name) is applied there and reported
* `updated` — this is the legitimate, documented case (display-cased body
* fields — Status, Current Plan, Phase — which are never frontmatter
* keys). It wins deterministically even when the same key also happens to
* exist as a parsed frontmatter key (e.g. `status` matches both the
* frontmatter key and a `Status:` body line) — frontmatter is inert for
* that key.
* - Only when the body has no match is the key checked against parsed
* frontmatter (determined structurally, never by a naming heuristic), and
* routed through the seam: `FIELD_CLASSIFICATION` governs it. A CLASSIFIED
* key (has a row, e.g. `current_phase`, `current_phase_name`) is NOT
* writable by an arbitrary patch — policy owns it — and is reported
* `failed`. An UNCLASSIFIED key (no row, e.g. a custom `risk_level`) is a
* pass-through per Phase 1 behavior-table row 19 ("field absent from
* FIELD_CLASSIFICATION → untouched pass-through"): it is applied directly
* to the frontmatter object before reassembly and reported `updated`.
* - A key matching neither the body nor the frontmatter is reported `failed`.
*
* ADR-3408 §8.3(b): this used to run `stateReplaceField` over the FULL
* document (body + frontmatter), which — because `field` is an arbitrary,
* caller-supplied string, unlike every other `stateReplaceField` call site in
* this file, which passes a fixed Title-Case string literal that can never
* collide with a lowercase/snake_case YAML key — let a frontmatter-shaped
* patch key (e.g. `status`, `current_phase`) match and rewrite the YAML
* frontmatter block directly via `stateReplaceField`'s case-insensitive
* `^field:` line pattern, entirely outside `FIELD_CLASSIFICATION` and the
* write-seam preservation policy: a second, undeclared writer. The fix is
* that a CLASSIFIED frontmatter key no longer writes outside the declared
* policy table — not that every frontmatter-shaped key stops working.
* `.gsd/phase/refactor-3469-one-write-seam/40-design.md` row 9 requires
* frontmatter changes to route through the seam (still work, governed by
* FIELD_CLASSIFICATION), not to stop working outright. Body-shaped keys
* (`Status`, `Current Plan`, `Phase`, ...) are the LEGITIMATE case and are
* unaffected — they were always matched against the body text, and still are.
*
* The curated-field preservation that fixes #1743/#1695 is NOT in this core —
* it lives in `readModifyWriteStateMd`'s post-sync delta (table-driven via
* it lives in the write seam's post-sync delta (table-driven via
* `current_phase_name`'s `preserve-when-unchanged` row, ADR-3408 §8.1 —
* reclassified from `preserve-always` in #3468 to match its long-standing,
* delta-gated behavior).
@@ -1714,20 +1748,61 @@ function patchCore(
content: string,
intent: { kind: 'patch'; patches: Record<string, string> },
): StateTransitionResult {
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
const hasFrontmatter = Object.keys(existingFm).length > 0;
let body = stripFrontmatter(content);
const fm: Record<string, unknown> = { ...existingFm };
const updated: string[] = [];
const failed: string[] = [];
let result = content;
for (const [field, value] of Object.entries(intent.patches)) {
const replaced = stateReplaceField(result, field, value);
// Body-first: a key that resolves against a body field is the
// legitimate, documented case (display-cased body fields — Status,
// Current Plan, Phase — are never frontmatter keys) and wins
// deterministically even when the same key also happens to exist as a
// frontmatter key (case-insensitively, via stateReplaceField's
// `^field:` pattern — e.g. `status` matching both the frontmatter key
// and a `Status:` body line). Frontmatter is only consulted when the
// body has no match for this key.
const replaced = stateReplaceField(body, field, value);
if (replaced !== null) {
result = replaced;
body = replaced;
updated.push(field);
} else {
failed.push(field);
continue;
}
if (Object.prototype.hasOwnProperty.call(existingFm, field)) {
// Frontmatter-shaped key: route through the seam. A classified field
// is policy-owned — a raw patch may not bypass it. An unclassified
// field is an untouched pass-through (behavior-table row 19).
if (getFieldClassification(field) !== null) {
failed.push(field);
} else {
fm[field] = value;
updated.push(field);
}
continue;
}
failed.push(field);
}
if (updated.length === 0) {
// No field matched — return `content` VERBATIM (mirrors `updateCore`'s
// null-result branch): reassembling via stripFrontmatter/
// reconstructFrontmatter even when nothing changed can round-trip the
// frontmatter block to different bytes than the original (key order,
// formatting), which would falsely defeat `readModifyWriteStateMd`'s
// #948 no-op write guard for every patch that updates nothing, not just
// a frontmatter-shaped one.
return { content, updated, data: { updated, failed } };
}
const result = hasFrontmatter
? `---\n${reconstructFrontmatter(fm as unknown as Frontmatter)}\n---\n\n${body}`
: body;
return { content: result, updated, data: { updated, failed } };
}

View File

@@ -2802,6 +2802,7 @@ function applyPostSyncPreservation(
resync: boolean,
authoritativeFm?: Record<string, unknown>,
deriveProgressKeys?: boolean,
divergedFields?: string[],
): string {
// Snapshot the existing progress block BEFORE the transform so we can
// restore it when resync is false.
@@ -2914,11 +2915,35 @@ function applyPostSyncPreservation(
// original four fields; this is the absorption ADR-1769 / CONTEXT.md
// already claimed shipped.
const postFm = extractFrontmatter(syncedContent, statePath) as Record<string, unknown>;
// #3469 (ADR-3408 §8.5): snapshot the freshly-synced (pre-preservation)
// frontmatter so a caller that wants visibility into "did preservation
// restore a curated value over a disagreeing derived one" can diff against
// it via the optional `divergedFields` out-param below. Additive only:
// callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
// nothing extra and see no change to `synced`/the returned content.
const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
const preservation = applyStatePreservation({
preFm, postFm, preFmSnapshot, resync,
deriveProgressKeys: deriveProgressKeys === true,
bodyDeltas,
});
if (divergedFields && preservationInputSnapshot) {
// §8.5's "liberal but visible": every field whose value actually
// differs before vs after `applyStatePreservation` is a field where the
// curated (frontmatter) value won over a disagreeing freshly-derived
// one — regardless of which policy executor fired. Diffing the object
// (rather than special-casing which executor mutated it) is intentional:
// it stays correct if a future FIELD_CLASSIFICATION row adds a new
// preservation policy without this function needing to know about it.
for (const key of Object.keys(preservation.postFm)) {
const before = preservationInputSnapshot[key];
const after = preservation.postFm[key];
const changed = (typeof before === 'object' || typeof after === 'object')
? JSON.stringify(before) !== JSON.stringify(after)
: before !== after;
if (changed) divergedFields.push(key);
}
}
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
// layouts with no body `Phase:` line, both phase-source snapshots are null
// (equal), so the #1695 restore fires and would put the stale pre-transition
@@ -2942,6 +2967,56 @@ function applyPostSyncPreservation(
return syncedContent;
}
/**
* ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
* `applyPostSyncPreservation`, as a single named `content -> content`
* function. Every STATE.md write that (a) is not one of the two sanctioned-
* permanent exceptions (`cmdStateSync`, `REGENERATE_STATE` — §8.3's closed
* exception list, ADR Amendment 2) and (b) needs a non-standard I/O envelope
* calls THIS — never `syncStateFrontmatter` + `applyPostSyncPreservation`
* assembled locally. §8.3: "Assembling the stages at a call site is a
* re-derivation even when every step calls the owner." Phase 2 (#3469) found
* exactly that shape live in `cmdPhaseComplete`'s atomic-commit adapter
* (phase.cts) — every step called an owner, so the drift guard and an
* owner-level test both stayed green while the composition itself was free
* to diverge from `readModifyWriteStateMd`'s.
*
* Both current non-RMW callers of the pair — `readModifyWriteStateMd` and
* `cmdPhaseComplete`'s atomic 3-file commit adapter — now call this instead
* of assembling the two stages themselves. `cmdMilestoneComplete` (the
* #3374-shaped exposure `applyPostSyncPreservation`'s own docstring flagged
* as a follow-up) is the third.
*
* Returns CONTENT ONLY — a caller that needs its own I/O envelope (a lock,
* an atomic multi-file commit) supplies it around this call; this function
* never takes over the write.
*
* `divergedFields` is passed straight through to `applyPostSyncPreservation`
* — see its own docstring.
*/
function syncAndPreserveStateMd(
originalContent: string,
transformedContent: string,
statePath: string,
cwd: string | undefined,
resync: boolean,
authoritativeFm?: Record<string, unknown>,
deriveProgressKeys?: boolean,
divergedFields?: string[],
): string {
const synced = syncStateFrontmatter(transformedContent, cwd, authoritativeFm);
return applyPostSyncPreservation(
originalContent,
transformedContent,
synced,
statePath,
resync,
authoritativeFm,
deriveProgressKeys,
divergedFields,
);
}
/**
* Atomic read-modify-write for STATE.md.
* Holds the lock across the entire read -> transform -> write cycle,
@@ -2981,14 +3056,16 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
return false;
}
let synced = syncStateFrontmatter(modified, cwd, options?.authoritativeFm);
// #3374: the post-sync preservation pass (snapshots, table-driven
// applyStatePreservation, #2736 re-assert) — see applyPostSyncPreservation.
synced = applyPostSyncPreservation(
// #3469 (ADR-3408 §8.3): sync + post-sync preservation is the single
// owned composition (`syncAndPreserveStateMd`), not assembled here — this
// call site and `cmdPhaseComplete`'s atomic-commit adapter both route
// through the same function so the composition cannot diverge between
// the two.
const synced = syncAndPreserveStateMd(
content,
modified,
synced,
statePath,
cwd,
resync,
options?.authoritativeFm,
options?.deriveProgressKeys === true,
@@ -4352,11 +4429,15 @@ export = {
readModifyWriteStateMd,
syncStateFrontmatter,
// #3374: the shared post-sync preservation pass (snapshots + table-driven
// applyStatePreservation + #2736 re-assert). Exported for cmdPhaseComplete's
// atomic-commit adapter in phase.cts, which syncs STATE.md directly (it is
// committed atomically with ROADMAP/REQUIREMENTS) and must apply the same
// preservation policy the RMW path applies.
// applyStatePreservation + #2736 re-assert).
applyPostSyncPreservation,
// #3469 (ADR-3408 §8.3): the ONE write-seam composition (sync +
// preservation) as content -> content. Exported for cmdPhaseComplete's
// atomic-commit adapter (phase.cts, syncs STATE.md directly because it is
// committed atomically with ROADMAP/REQUIREMENTS) and for
// cmdMilestoneComplete (milestone.cts) — both need the composition's
// output but supply their own I/O envelope around it.
syncAndPreserveStateMd,
readStateHeadFreshness,
withStateLock,
updatePerformanceMetricsSection,