The index moves without the truth, in both directions
TASK-IMP-120 - the index moves without the truth, in both directions
Summary
Make backlog-mutate flip REFUSE unless the task's frontmatter already carries the target status. The frontmatter is the record of truth and BACKLOG.md is its index; nothing binds the two writes, so they diverge silently. Twice on 2026-07-17, in opposite directions, from two different actors.
Problem
STATUS-REFERENCE.md §1 is unambiguous: frontmatter IS the record of truth, BACKLOG.md is only its index. backlog-mutate executes the index write with optimistic concurrency, a pre-image check, a 3-line footprint ceiling and a full retally. The frontmatter write is a separate hand edit with no mechanism at all.
Two instances, one day, opposite directions:
| when | actor | what moved | what did not |
|---|---|---|---|
| TASK-IMP-116 | me | the index, twice (reviewing -> ready_to_test -> testing) | the frontmatter, which still said reviewing |
| TASK-IMP-028 | a swarm sub-agent | the frontmatter (draft -> duplicate, per 110 §1.7) | the index, which still said draft |
Neither is carelessness. 116's own coverage gate caught the first by comparing Totals against an independent frontmatter count - 544 index rows, 543 specs. The second was caught the same way, at the batch close. Both times the reconcile was a manual step someone happened to run.
The asymmetry is the tell: the index write is a hardened tool and the truth write is a sed. We built the mechanism on the derived artefact and left the authoritative one bare.
TASK-IMP-116's goal file already names this gap and the candidate fix. This task is that fix.
Proposed Solution
flip <id> <from> <to> reads the task's frontmatter FIRST. If it does not already say <to>, the flip REFUSES with exit 6 (the existing pre-image refusal code) and names both values. The truth becomes the precondition rather than the afterthought, and the index can only ever catch up to it - never lead it.
This makes the order structural instead of advisory: write the truth, then run the tool. A caller who does it backwards gets a refusal, not a divergence.
Alternatives Considered
- Have
flipWRITE the frontmatter too. Rejected: then one tool owns both the truth and its index, and a bug in it corrupts the record with no second opinion. The tool's whole value is that it is narrow and refuses; widening it to write specs makes it the thing it guards against. - A reconcile step at each flip. Rejected:
task-reconcile.mjsalready exists and already finds this - it just is not required, so it is not run. Another optional check is another thing to skip. - Lint it. Rejected: the divergence is between two files at one instant, not a property of either.
- Leave it; the gates catch it. Rejected: both instances were caught by a manual reconcile someone chose to run. That is luck with a good habit attached.
Success Metrics
- Replaying 116's exact sequence (index flipped, frontmatter untouched) REFUSES at the first flip instead of succeeding twice.
- Replaying 028's (frontmatter flipped, index untouched) leaves the index refusable, so the divergence cannot be committed.
insertkeeps its current behaviour: it creates a row for a spec that already exists, so the truth is already there by construction. No new refusal there.
AI Authorship Disclosure
- Tools used: Claude (Fable 5), 2026-07-17 hardening run, via create-tasks.
- Scope: spec drafted by the agent. Both instances in the table are the agent's own - one written directly, one by a sub-agent it dispatched - and both were found by reconciling rather than by any gate. The fix was already named in TASK-IMP-116's goal file before the second instance occurred, which is why this is a task rather than a discovery.
- Human review: @stephencheng recorded the decision to task it at the batch close gate.
Dependencies
None. backlog-mutate already has exit 6 for pre-image refusals, and flip already resolves and rewrites a single BACKLOG row. It did NOT previously parse spec frontmatter anywhere: insert reads only BACKLOG rows and never opens a spec, so before this task no command read a spec's status:. THIS task adds that frontmatter read to flip (resolveSpecPaths + frontmatterStatus, mirroring task-reconcile's id->spec resolver) and gates the existing index write on it, reusing exit 6. A precondition on an existing refusal path, plus the small reader it needs - not a new writer, and not a new refusal code.
Scope
In scope: the flip precondition in backlog-mutate.mjs, its suite arms, and the ordering contract in backlog-state-update-author/SKILL.md.
Out of scope / Non-Goals
- Making
flipwrite frontmatter. See Alternatives - that is the failure mode, not the fix. - Any change to
insert. A row is inserted for a spec that exists; the truth precedes it already. - The status page. Group A already binds the page to the cell; this binds the cell to the truth.
- Retroactively reconciling the corpus. It agrees today, on all eight live statuses. Keeping it that way is this task; proving it stays that way is TASK-IMP-109's territory.
1. Clauses
1.1 flip <id> <from> <to> MUST read <id>'s spec frontmatter and REFUSE with exit 6 unless status already equals <to>, naming both the frontmatter value and the requested target. Test: t14_flip_refuses_when_truth_disagrees
1.2 When the frontmatter DOES equal <to>, flip proceeds exactly as today - same pre-image checks, same 3-line footprint, same retally. This adds a precondition and changes nothing else. Test: t15_flip_proceeds_when_truth_agrees
1.3 A spec that cannot be found or read MUST refuse, not proceed. An unreadable truth is not a matching truth. Test: t16_unreadable_spec_refuses
1.4 insert MUST be unaffected. Test: t17_insert_unchanged
1.5 backlog-state-update-author/SKILL.md MUST state the ordering as a contract: write the truth, then the index; the tool enforces it. Both vendored copies. Test: t18_skill_states_the_order
1.6 The seam test tools/install/tests/test_e2e_skeleton.sh - the end-to-end spine that drives flip -> coverage-scope -> reconcile -> uninstall - MUST exercise its lifecycle flips in the truth-first order this task mandates (the spec frontmatter status is written to <to> BEFORE the index flip), and MUST positively assert that an index-first flip (the truth still lagging) REFUSES with exit 6 and does NOT move the row. The guard's behaviour change broke this seam - it had encoded index-first, truth-lagging flips as correct, the exact shape 1.1 forbids - so the cone is grown to correct the seam to the new contract, never to weaken the guard. Test: t05_index_first_flip_refuses (index-first flip refuses exit 6, row unmoved; the truth-first flip then proceeds) and t01_spine_green (the full spine drives every lifecycle flip truth-first).
3. Edge case matrix
| # | Category | Trigger | Expected | Test |
|---|---|---|---|---|
| 1 | NULL/EMPTY | spec has no status field | refuse - absent is not agreement | t16 |
| 2 | NULL/EMPTY | spec file missing entirely | refuse, name the path | t16 |
| 3 | BOUNDS | frontmatter equals <to> exactly | proceed | t15 |
| 4 | BOUNDS | frontmatter equals <from> (caller ran it backwards) | refuse - this is the whole defect | t14 |
| 5 | MALFORMED | status: done # comment (FM-001, live in 501 specs) | trailing comment stripped before compare | t14 |
| 6 | MALFORMED | two status: lines | refuse - ambiguous truth is not truth | t16 |
| 7 | MALFORMED | status value with trailing whitespace | trimmed, compares equal | t15 |
| 8 | CONCURRENT | frontmatter changes between read and write | existing row pre-image still catches it | t15 |
| 9 | SECURITY | id resolves to a spec outside docs/tasks | refuse - relUnderRoot, as everywhere else | t16 |
| 10 | DEGRADATION | corpus has duplicate ids for <id> | refuse - existing duplicate-row refusal path | t16 |
| 11 | DEGRADATION | a status not in STATUS_ORDER | refuse before touching the file | t14 |
| 12 | CONCURRENT | seam driver moves the index before writing the truth (index-first) | refuse exit 6, row unmoved - truth precedes index, end to end | t05_index_first_flip_refuses (test_e2e_skeleton.sh) |
4. Out of scope / non-goals
See "## Scope -> ### Out of scope / Non-Goals" above.
Acceptance criteria
- AC1 (traces_to #1.1): flip refuses, exit 6, naming both values. Test:
t14_flip_refuses_when_truth_disagrees. - AC2 (traces_to #1.2): flip proceeds unchanged when the truth agrees. Test:
t15_flip_proceeds_when_truth_agrees. - AC3 (traces_to #1.3): unreadable or ambiguous truth refuses. Test:
t16_unreadable_spec_refuses. - AC4 (traces_to #1.4): insert unaffected. Test:
t17_insert_unchanged. - AC5 (traces_to #1.5): the skill states the ordering contract, both copies. Test:
t18_skill_states_the_order. - AC6: replaying TASK-IMP-116's sequence (index flipped, frontmatter untouched) refuses at the FIRST flip. A fix that cannot stop the case that motivated it is decoration. Test:
t19_replays_the_116_divergence. - AC7 (traces_to #1.6): the seam test
test_e2e_skeleton.shdrives its lifecycle flips truth-first and positively asserts an index-first flip refuses (exit 6) without moving the row - the guard's behaviour change is covered by the seam it broke, not merely tolerated. The operator-approved cone expansion (the fix belongs in this task's cone, not a separate one). Test:t05_index_first_flip_refuses,t01_spine_green.
Audit
Audit - TASK-IMP-120
Machine floor first per TASK-IMP-084: re-ran task-lint on the AMENDED spec - 0 errors, 1 info (TRACE-001, the ## 1. Clauses heading shape, as with 117/118/119). VERIFIED by command (node tools/install/docs-tools/task-lint.mjs docs/tasks/improvement/TASK-IMP-120-truth-precedes-index/spec.md; exit 0).
Amendment (2026-07-18 re-audit) - operator-approved cone growth
The guard shipped and committed at 38199b27 (clauses 1.1-1.5). Its behaviour change broke one out-of-cone seam - tools/install/tests/test_e2e_skeleton.sh::t01_spine_green (and the mini-spine in t04_scratch_isolation), which deliberately flipped the INDEX first and asserted the frontmatter still read the OLD status: the exact index-first, truth-lagging shape 1.1 forbids. The prior ship-manifest's integration_finding had already SIZED this and left it for a follow-up per cone discipline (IMP-119). The operator approved GROWING this task's cone to correct the seam rather than opening a separate task. This re-audit blesses the amendment:
modified_filesgainstools/install/tests/test_e2e_skeleton.sh(the cone entry).- Clause 1.6 + AC7 + edge row 12: the seam MUST drive its flips truth-first and MUST positively assert an index-first flip REFUSES (exit 6) without moving the row. Cited tests:
t05_index_first_flip_refusesandt01_spine_green(truth-first spine). - Dependencies corrected - see ISS-008.
The amendment WEAKENS no existing clause or AC. 1.1-1.5 and AC1-AC6 are byte-unchanged; the edit touched only the frontmatter cone list, the Dependencies paragraph, and appended 1.6 / edge-12 / AC7 (VERIFIED: git diff of the spec shows only those four regions).
Findings
ISS-001 (info, accepted): TRACE-001 heading shape. 1.1-1.6 cite tests; AC1-AC7 cite back.
ISS-002 (accepted, and the reason this is p1): two instances in one day, opposite directions, two different actors - the agent on TASK-IMP-116 and a swarm sub-agent on TASK-IMP-028. Both were caught by a manual reconcile someone chose to run. The fix was already named in 116's goal file BEFORE the second instance happened, which is why this is a task and not a discovery.
ISS-003 (accepted): the Alternatives section rejects having flip WRITE the frontmatter. One tool owning both the truth and its index means a bug in it corrupts the record with no second opinion. The tool's value is that it is narrow and refuses; widening it to write specs makes it the thing it guards against.
ISS-004 (accepted): edge case 5 - status: done # comment must be stripped before comparing. Live in 501 specs today, so a naive compare would refuse every flip on an FM-001-carrying spec and wedge the workflow on contact. Caught in the matrix rather than in production.
ISS-005 (accepted): edge case 6 - two status: lines refuse. Ambiguous truth is not truth, and picking the first would be the machine choosing which record is authoritative.
ISS-006 (accepted): AC6 replays 116's exact sequence and requires a refusal at the FIRST flip. A fix that cannot stop the case that motivated it is decoration.
ISS-007 (NEW, accepted - the amendment): cone grown to test_e2e_skeleton.sh with clause 1.6 / AC7 / edge row 12. The seam encoded "flip is index-only, truth may lag" as CORRECT (its header comment and t01's inline assertion) - exactly the behaviour 1.1 forbids - so the guard's landing made it 3/3 RED. Growing the cone to fix the seam to the new contract (truth-first drive + a positive index-first-refuses assertion) is the operator's approved disposition. 1.6's verbs and the tests that discharge them are recorded under TRACE-006 below. No existing clause/AC weakened; the seam's other coverage (spine green, reconcile recommendation, corpus survival, scratch isolation) is preserved, not deleted.
ISS-008 (NEW, accepted - stale originated claim corrected): the Dependencies section claimed "backlog-mutate already parses frontmatter for the insert path." FALSE. cmdInsert reads only BACKLOG rows and never opens a spec; before this task NO command parsed a spec's frontmatter - the resolveSpecPaths + frontmatterStatus reader is what THIS task ADDED, inside cmdFlip.
- BEFORE: "None.
backlog-mutatealready parses frontmatter for the insert path and already has exit 6 for pre-image refusals. This adds a precondition, not a mechanism." - AFTER: "None.
backlog-mutatealready has exit 6 for pre-image refusals, andflipalready resolves and rewrites a single BACKLOG row. It did NOT previously parse spec frontmatter anywhere:insertreads only BACKLOG rows and never opens a spec ... THIS task adds that frontmatter read toflip(resolveSpecPaths+frontmatterStatus...) and gates the existing index write on it, reusing exit 6. A precondition on an existing refusal path, plus the small reader it needs - not a new writer, and not a new refusal code." This is the same class of defect as the session's governing finding (authors do not check what they originate) and the AC5-on-IMP-117 correction the operator called out. VERIFIED against source:cmdInsertand its helpers (parseRow/parseCountsHeader/retallyTotals/retallyHeader/sectionBlocks/ blockOf) contain no spec read;resolveSpecPathsandfrontmatterStatusare referenced only fromcmdFlip.
TRACE-006 (semantic sufficiency - per clause, judgment family)
For each §1 clause: [verb it demands] vs [what its cited test asserts]. 1.1-1.5 carry over from the prior audit (their clauses are byte-unchanged); 1.6 is added here.
- 1.1 refuse (exit 6, naming both) -> t14 asserts exit 6 + names the frontmatter value and the target, and the index row is unmoved. Discharges "refuse" (absent side effect + signalled refusal). PASS.
- 1.2 proceed unchanged -> t15 asserts the flip succeeds and the row/header/Totals retally exactly as before. PASS.
- 1.3 refuse on unreadable/ambiguous -> t16 asserts refusal on missing / no-status / two-status. PASS.
- 1.4 insert unaffected -> t17 asserts insert runs identically with the guard present. PASS.
- 1.5 SKILL states the order (both copies) -> t18 asserts the ordering contract in source + vendored payload. PASS.
- 1.6 (two verbs) - "exercise flips truth-first" AND "assert index-first REFUSES (exit 6), row unmoved."
- Verb "refuse": demands the guarded index write did NOT happen AND a refusal was signalled.
t05_index_first_flip_refusesasserts BOTH: the flip exits 6, the refusal names the truth-precedes-index contract, AND the BACKLOG row is still[draft](unmoved); then the truth-first flip proceeds and moves the row. Discharges "refuse". - Verb "exercise truth-first":
t01_spine_greenwrites the frontmatter to<next>BEFORE each flip and asserts the index catches up (row ==<next>after the flip) through the whole lifecycle todone. Discharges the truth-first drive. - PASS (verb-demand vs test-assertion recorded above). SCOPE NOTE: this is the spec-correctness gate (draft -> ready_to_implement), so it blesses the clause and its declared tests; the tests themselves are this cone's Phase B seam-fix and are run GREEN in the Phase B commit (the coverage concern, a different gate - RUBRIC.md §9). This audit does NOT claim the tests are green; it verifies the spec's contract for them.
Rubric families
- FM: clean (task-lint re-run, 0 errors). SEC: seven required sections present and non-empty. COND: three-bullet AI-authorship disclosure. Dependencies now truthful (ISS-008).
- QA: 12 edge rows across all six categories (row 12 added: the index-first seam case, proven end-to-end by t05). 1 SECURITY, 2 DEGRADATION. QA-008 clean (Dependencies leads with "None.").
- SAFE: adds a precondition to an existing refusal path; reuses exit 6 and relUnderRoot. The cone growth adds a test file to the cone (coverage), no new writer, no widened runtime surface.
- TRACE: 1.1-1.6 -> tests; AC1-AC7 -> clauses. AC6 (the motivating 116 replay) and AC7 (the seam the guard broke) are load-bearing.
Verdict
pass - 10/10. The amendment closes the loop the guard opened: the behaviour change that broke the seam is now covered BY that seam (truth-first spine + a positive index-first-refuses assertion), inside this task's cone, and the one false originated claim in Dependencies is corrected to the truth. The binding is rebound to the amended normative half (audited_body_sha256_prefix: 8aacd63f9e6fc0bb).
Verified vs reconstructed:
- VERIFIED by command: task-lint on the amended spec (0 errors, 1 info); the sha bindings (recomputed via task-reconcile's
normativeHalfover the amended file);cmdInserthas no spec read (source inspection of backlog-mutate.mjs). - SPEC-GATE SCOPE (not yet green at this commit): clause 1.6's cited tests
t05_index_first_flip_refusesand the truth-firstt01_spine_greenare blessed here as the clause's contract; they are implemented and verified GREEN in this cone's Phase B commit (coverage gate), which follows this Phase A spec+audit commit.