* 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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user