fix(#4129): derive completed_phases from the ROADMAP authority; honor the progress-ratchet on every state write (#4359)

* test(#4129): failing-first regressions — completed_phases clobber on resyncing writes and phase-complete failure to increment

* fix(#4129): completed_phases derives from the ROADMAP authority and the write path honors the progress-ratchet

Three coordinated prongs (diagnosis in .gsd/bug/fix-4129-completed-phases-recompute/):

P1 — buildStateFrontmatter's disk scan floors the completed-phases numerator at
the milestone-scoped ROADMAP Complete-row count (deriveProgressFromRoadmap, the
one owner), gated inside the same safeToUseRoadmapCount / not-withheld branch
that owns the denominator. A completed phase whose verification routes stale
(#2348 clean-commit-time drift) or is missing no longer under-counts forever.

P2 — applyPreserveAlways's resync arm merges instead of wholesale-replacing on
a measured scan: totals derived both directions (#2440), completed counters
up-only (#2969 — the schema-declared progress-ratchet, now enforced on the
write path like the read path always has), percent recomputed from the merged
counters. The #3756 unmeasured guard and the #3242 explicit-progress contract
are unchanged.

P3 — phase complete's atomic 3-file commit passes the post-completion
ROADMAP-derived counters through the #2736 authoritativeFm seam (new object
direction for the progress key; completedOnlyRaise at the post-preservation
re-assert), because the transaction's disk scan reads the pre-completion
ROADMAP and failed to increment on the completing phase's own write.

* fix(#4129): adversarial-review hardening — intent is a floor at BOTH authoritativeFm sites

The pre-preservation merge could lower a correctly-higher disk-derived
counter (a verification-passed phase whose ROADMAP table row drifted behind
the disk signal). completedOnlyRaise now governs both application sites: the
intent and the derivation agree on direction (up), never on subtraction.

* fix(#4129): the ratchet merge keeps derived values verbatim when numerically equal

The re-parsed derived block carries string scalars ("2") while the curated
snapshot carries numbers (2); substituting the curated spelling over an
equal derived one was a no-op in substance but a shape churn the ADR-3473
§8.7 reporting loop surfaced as a phantom preserved-over-disagreeing-derived
warning on phase complete (ADR-3408 §8.5 Matrix B). Only a strictly-greater
curated counter replaces the derived value now; percent gets the same
verbatim rule.

* changeset(#4129): backfill PR 4359

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-05 23:02:07 -04:00
committed by GitHub
parent bd75d42f52
commit e6d047decc
7 changed files with 827 additions and 34 deletions

View File

@@ -4348,9 +4348,19 @@ describe('ADR-3473 §8.6 matrix rows 13-17 (+10/11 pinned): the measured-vs-unme
});
test('stringNonZeroTotalIsMeasured', () => {
// #4129 superseded the observable: a measured resyncing write no longer
// lets the derived block wholesale-replace the curated one — the
// declared progress-ratchet now merges (totals derived both directions,
// completed counters up-only, #2969). The measured/unmeasured BOUNDARY
// this row pins is unchanged and still observable here: the derived
// TOTALS stand (a wholesale curated restore would have written 5/32).
const r = restoreWithDerivedTotals({ total_phases: '1', total_plans: '0', completed_phases: '0', completed_plans: '0' });
assert.strictEqual(r.mutated, false, 'a measured scan (total_phases:"1") must win — derived stands untouched');
assert.deepStrictEqual(r.postFm.progress, { total_phases: '1', total_plans: '0', completed_phases: '0', completed_plans: '0' });
assert.strictEqual(r.mutated, true, 'a measured scan (total_phases:"1") must not wholesale-restore the curated block — the #4129 ratchet merge runs');
assert.deepStrictEqual(
r.postFm.progress,
{ total_phases: '1', total_plans: '0', completed_phases: 5, completed_plans: 32 },
'measured: totals take the derived value (#2440 both directions); completed counters keep curated ("0" < 5/#2969 up-only); derived carried no percent so none is invented',
);
});
test('absentTotalsAreUnmeasured', () => {
@@ -4379,15 +4389,18 @@ describe('ADR-3473 §8.6 matrix rows 13-17 (+10/11 pinned): the measured-vs-unme
// Pinned mirror pair from the design's row 11/the existing matrix's row
// 10 — grouped here so the boundary (only both-zero is unmeasured) reads
// legibly against the coercion cases above.
// legibly against the coercion cases above. #4129 superseded the observable
// to the ratchet merge (see stringNonZeroTotalIsMeasured above): "measured"
// is still decided by EITHER total being non-zero, and still observable
// because the derived TOTALS stand rather than a curated wholesale restore.
test('oneNonZeroTotalCountsAsMeasuredEitherDirection', () => {
const r1 = restoreWithDerivedTotals({ total_phases: 1, total_plans: 0, completed_phases: 0, completed_plans: 0 });
assert.strictEqual(r1.mutated, false, 'total_phases:1, total_plans:0 must count as measured');
assert.deepStrictEqual(r1.postFm.progress, { total_phases: 1, total_plans: 0, completed_phases: 0, completed_plans: 0 });
assert.strictEqual(r1.mutated, true, 'total_phases:1, total_plans:0 must count as measured (#4129 ratchet merge ran, not a curated restore)');
assert.deepStrictEqual(r1.postFm.progress, { total_phases: 1, total_plans: 0, completed_phases: 5, completed_plans: 32 });
const r2 = restoreWithDerivedTotals({ total_phases: 0, total_plans: 1, completed_phases: 0, completed_plans: 0 });
assert.strictEqual(r2.mutated, false, 'total_phases:0, total_plans:1 must count as measured (the mirror) — only both-zero is unmeasured');
assert.deepStrictEqual(r2.postFm.progress, { total_phases: 0, total_plans: 1, completed_phases: 0, completed_plans: 0 });
assert.strictEqual(r2.mutated, true, 'total_phases:0, total_plans:1 must count as measured (the mirror) — only both-zero is unmeasured');
assert.deepStrictEqual(r2.postFm.progress, { total_phases: 0, total_plans: 1, completed_phases: 5, completed_plans: 32 });
});
});
@@ -4441,3 +4454,121 @@ describe('ADR-3473 §8.6 matrix row 36: property — preservation never drops a
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #4129: the resync arm of `applyPreserveAlways` (rows 8-14 of .gsd/bug/
// fix-4129-completed-phases-recompute/50-test-matrix.md). A resyncing write
// whose scan MEASURED something now runs the declared `progress-ratchet`
// merge instead of wholesale-replacing the curated block — the write path
// finally enforces the same monotonic property the read path
// (`shouldPreserveExistingProgress`) always has. The #3756 unmeasured guard
// and the #3242/#3871 explicit-progress contract are unchanged (pinned by the
// blocks above and by tests/frontmatter.test.cjs).
// ─────────────────────────────────────────────────────────────────────────────
describe('#4129: resyncing measured write ratchets the progress block', () => {
function resyncMerge(curatedProgress, derivedProgress, extra = {}) {
const tx = openStateTransaction({
snapshot: { progress: { ...curatedProgress } },
resync: true,
bodyDeltas: neutralBodyDeltasForMatrix(),
...extra,
});
return applyStatePreservation({ transaction: tx, postFm: { progress: { ...derivedProgress } } });
}
test('resyncRatchetKeepsCuratedCompletedWhenDerivedUnderCounts', () => {
// The issue's exact shape: derived 2 (a stale-dated sibling verification)
// vs curated 3 (the ROADMAP truth a hand-fix or earlier correct write left).
const r = resyncMerge(
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
{ total_phases: 18, completed_phases: 2, total_plans: 6, completed_plans: 6, percent: 11 },
);
assert.deepStrictEqual(
r.postFm.progress,
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
'#4129: a measured resync must never move completed counters DOWN — totals derived, completed curated, percent recomputed from the merged counters (3/18=17)',
);
assert.strictEqual(r.mutated, true, 'the merge actually changed the derived block');
});
test('resyncRatchetStillLetsCompletedMoveUp', () => {
// Genuine completion: derived ABOVE curated must ratchet up, and percent follows.
const r = resyncMerge(
{ total_phases: 18, completed_phases: 2, total_plans: 6, completed_plans: 6, percent: 11 },
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
);
assert.deepStrictEqual(
r.postFm.progress,
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
'#4129: the ratchet is up-only, never a freeze — a genuine increment still lands',
);
});
test('resyncRatchetMixedSidesRecomputePercentFromMergedCounters', () => {
// Mixed: derived completed_plans ratchets up, curated completed_phases
// survives — percent must be recomputed from the MERGED counters
// (min(54/54 plans, 5/? phases capped by plan fraction), never either
// side's stale stored percent.
const r = resyncMerge(
{ total_plans: 54, completed_plans: 50, completed_phases: 5, percent: 93 },
{ total_plans: 54, completed_plans: 54, completed_phases: 1, percent: 50 },
);
assert.strictEqual(r.postFm.progress.completed_plans, 54, 'derived completed_plans (54 > 50) ratchets up');
assert.strictEqual(r.postFm.progress.completed_phases, 5, 'curated completed_phases (5 > 1) survives — never down');
// min(54/54, 5/5-with-no-total... completed_phases 5, total_phases absent) —
// computeProgressPercent with only plan data present: min(1.0, plan)=100? No:
// phase data absent → phaseFraction=1 → min(1, 1) = 100.
assert.strictEqual(r.postFm.progress.percent, 100, 'percent is recomputed from the merged counters through the completion-ratio kernel');
});
test('resyncRatchetCoercesStringScalarsThroughToFiniteNumber', () => {
// Frontmatter scalars arrive as STRINGS ("2", not 2) — the comparison
// must coerce (scanMeasuredSomething's own convention), never `typeof`.
const r = resyncMerge(
{ total_phases: 18, completed_phases: 3, percent: 17 },
{ total_phases: '18', total_plans: '6', completed_phases: '2', completed_plans: '6', percent: 11 },
);
assert.strictEqual(r.postFm.progress.completed_phases, 3, 'string "2" must compare numerically against curated 3 — curated survives');
assert.strictEqual(r.postFm.progress.total_phases, '18', 'string totals take the derived value verbatim (both directions)');
assert.strictEqual(r.postFm.progress.percent, 17, 'percent recomputed from merged counters (3/18)');
});
test('resyncRatchetIsANoOpWhenDerivedEqualsCurated', () => {
// The #4094 withheld shape arrives here: the scan fell back to the stored
// counters, so derived == curated and the merge must not report a
// mutation (the #948 no-op-write family).
const block = { total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 };
const r = resyncMerge(block, { ...block });
assert.strictEqual(r.mutated, false, 'derived === curated → no mutation (withheld shape is untouched)');
assert.deepStrictEqual(r.postFm.progress, block);
});
test('resyncRatchetDoesNotResurrectAWithheldPercent', () => {
// Derived percent ABSENT (an upstream #1761/#3217 withhold nulled it) —
// the merge must not recompute one over counters it was withheld for.
// Percent follows the derived side (the deriveProgressKeys branch's own
// convention) and recomputation is gated on the derived block HAVING
// carried one — an absent percent stays absent, exactly as the
// pre-#4129 wholesale replace left it.
const r = resyncMerge(
{ total_phases: 5, completed_phases: 5, percent: 100 },
{ total_phases: 5, completed_phases: 2 },
);
assert.strictEqual(r.postFm.progress.completed_phases, 5, 'curated completed counter survives');
assert.ok(!('percent' in r.postFm.progress), 'no recomputed percent may appear when the derived block carried none');
});
test('explicitProgressFieldStillWholesaleReplacesOnMeasuredScan', () => {
// #3242/#3871 contract, unchanged by #4129: when the caller NAMED a
// progress field, the resync they asked for wins outright — the escape
// hatch for deliberate downward correction stays open.
const r = resyncMerge(
{ total_phases: 18, completed_phases: 3, percent: 17 },
{ total_phases: 18, completed_phases: 2, percent: 11 },
{ explicitProgressField: true },
);
assert.strictEqual(r.mutated, false, 'explicit progress write: the derived block stands untouched');
assert.deepStrictEqual(r.postFm.progress, { total_phases: 18, completed_phases: 2, percent: 11 });
});
});