Second sub-slice of SB-S3 (after S3a's reader landed). Adds an operator-gated,
explicitly-signalled supersede op to the consolidation engine: a temporal-update
candidate retires a stale fact (status: superseded, re-minted to an archival id,
replaced in place, retained as audit) and installs the new winner under the
canonical key-id. Bounded to scripts/brain/ — pure, deterministic, fully
unit-testable.
Operator-confirmed scope: temporal-update→supersede ONLY · winner→dynamic
(re-earn) · minimal `supersedes?: string` · retain superseded as audit.
Light-Voyage-hardened:
- brief-reviewer APPROVE-WITH-FIXES — 8 FIX folded (the load-bearing one: id
lifecycle → winner-takes-key-id, retired fact re-minted, so mintEntityId(key)
always points at the live fact).
- plan-critic REVISE → 2 BLOCK + 6 FIX folded: applyDiff must REPLACE-IN-PLACE
(not copy → would dup the canonical key-id); proposeDiff must value-guard the
supersede fork (a re-sent stale signal would self-supersede every run); plus
--gather active-only filter + decay excludes superseded (else retired facts
leak back as live context / spam stale flags).
- scope-guardian ALIGNED (0 creep / 0 gap).
Also reconciles brief-sb-s3a.md's stale DRAFT status (S3a landed 4fa411f).
No code yet — awaiting go-before-code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
14 KiB
Plan — SB-S3b: the supersede arm
Brief:
docs/second-brain/brief-sb-s3b.md. Slice: SB-S3b. Operator-confirmed (2026-06-23): temporal-update→supersede ONLY · winner→dynamic (re-earn) · minimalsupersedes?: string· retain superseded as audit. Light-Voyage: brief-review APPROVE-WITH-FIXES (8 folded); plan-critic REVISE → 2 BLOCK + 6 FIX folded below; scope-guardian ALIGNED (0 creep/0 gap, 1 minor SC9 reconciliation folded). Order is TDD: failing engine unit tests land BEFORE the engine logic (iron law); each rule pinned before the CLI is wired (brief-sb-s2.md:115).
Goal
The consolidation engine can retire a fact via an operator-confirmed, explicitly-signalled supersede: a temporal-update candidate flips the stale fact to status: "superseded" (re-minted to an archival id, replaced in place, retained as audit) and installs the new winner under the canonical key-id. Pure, idempotent, deterministic; rides the existing --propose/--apply --confirm gate; superseded facts never leak back into gather or decay; no grammar change; fully unit-testable.
Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
scripts/brain/src/consolidate.ts |
Candidate (+supersedes?: string, 33-39) · ProfileDiff (+supersedes: SupersedeOp[], 41-47; SupersedeOp carries provenance) · proposeDiff routing fork before keep-both (~120), value-guarded + intra-batch touched-guard · applyDiff replace-in-place supersede branch, state-checked, ordered (144-162) · decay scan excludes superseded (132-134) · local archivalId() helper · engine docstring :11 |
SC1–SC5,7,8,11,12 |
scripts/brain/src/cli.ts |
renderDiffMd (+## Supersessions (old → new), appended last via section(), 132-145) · validateCandidates (+optional single-line supersedes, 147-157) · --gather profileFacts filtered to status === "active" (:174) |
SC6, SC10 |
scripts/brain/tests/consolidate.test.ts |
unit tests SC1, SC2, SC3a, SC3b, SC4, SC5, SC7, SC8, SC11, SC12 | engine |
scripts/brain/tests/consolidate-cli.test.ts |
CLI tests SC6 (render + fidelity non-vacuity + --confirm gate), SC10 (gather active-only) |
CLI |
scripts/test-runner.sh |
bump BRAIN_TESTS_FLOOR 82→(82+N) + breakdown comment (:716); ASSERT_BASELINE_FLOOR UNCHANGED at 80 |
SC9 |
docs/second-brain/consolidation-loop.md |
reconcile "no supersede" claims (:34, :71) to true state + honest-limit line (engine guarantees mechanics; temporal-update-vs-contradictory is session+operator judgement — the operator gate is the only classification net) |
doc |
Not touched (scope fence): types.ts (FactStatus already has superseded) · profile.ts (grammar already round-trips superseded) · id.ts (reuse mintEntityId/mintContentId; archivalId is local to consolidate.ts) · ingest.ts/scaffold.ts/dataRoot.ts · hooks/scripts/** · agents/** (S3a reader unchanged) · architecture.md (its :69 design promise is fulfilled; :79 "S2 had no supersede" stays historically true — arc-status note deferred, not load-bearing) · condition-dependent/distractor branches (Q-SCOPE).
Step 0 — pre-flight (verify baseline, no edits)
(cd scripts/brain && npm install) if node_modules absent. Confirm (cd scripts/brain && npm test) = 82/82 and bash scripts/test-runner.sh green (95/0/0, BRAIN_TESTS_FLOOR=82, ASSERT_BASELINE_FLOOR=80). Confirm renderDiffMd/--gather/CLI-propose coverage lives in consolidate-cli.test.ts (host SC6/SC10 there). Re-read applyDiff (:144-162) to see how promotions are removed via filter (:156) — the supersede branch mirrors that.
Step 1 — (RED) types + failing tests
- Type scaffolding only (suite still compiles, existing 82 green):
Candidate(:33-39):supersedes?: string.ProfileDiff(:41-47):supersedes: SupersedeOp[], withinterface SupersedeOp { oldId: string; oldValue: string; newId: string; newValue: string; provenance: Provenance }.proposeDiffreturned object: initialisesupersedes: [](no routing yet).applyDiff: unchanged for now (ignores the new array).
- Add failing unit tests (mirror the SC1e keep-both test at
:78-91): SC1, SC2, SC3b, SC4, SC8, SC11, SC12 → expected FAIL; SC3a, SC5, SC7 → guards (may pass). SC6, SC10 inconsolidate-cli.test.ts→ FAIL.
RED gate (single, unambiguous): run (cd scripts/brain && npm test) directly → the new behavioural tests fail, the 82 existing pass. Do NOT run bash scripts/test-runner.sh at RED — Section 16b (test-runner.sh:717) fails the whole gate on a non-zero brain exit, which is design-noise, not signal. The direct brain-suite RED is the failing-test proof. (The gate is run GREEN only, after Step 4.)
Step 2 — (GREEN) engine logic
proposeDiffrouting fork — BEFORE the keep-both branch (~:120), after the ai-draft reject (:105, so a supersede can never carry ai-draft provenance — confirmed):- If
c.supersedesis set:targetId = mintEntityId({kind:"observed", key: c.supersedes});target = byId.get(targetId). - Route to
supersedes[]only whentargetexists ANDtarget.status === "active"ANDtarget.value !== c.valueANDtargetIdnottouchedthis batch. Thetarget.value !== c.valueguard is load-bearing (plan-critic BLOCK 2): a re-sent stale signal whose target already holds the candidate's value is NOT a supersession — it falls through to the ordinary bump path, so SC3a holds. - On route: push
{ oldId: targetId, oldValue: target.value, newId: mintEntityId({kind:"observed", key: c.key}), newValue: c.value, provenance: c.provenance };touched.add(targetId)+touched.add(newId)(intra-batch guard — a later same-key candidate degrades; winner id reserved). Do not also push toadditions.continue. - Otherwise (no target / not active / value already equal / touched): fall through to the ordinary add/bump path (SC4 graceful no-target, SC8 intra-batch). A supersede only retires a fact present at batch start (
byIdbuilt once fromcurrent).
- If
archivalId()helper (local toconsolidate.ts):archivalId(oldId, oldValue) = mintContentId(\superseded:${oldId}::${oldValue}`)`. Seeding with the retired fact's pre-archival id (unique among active facts by the no-dup invariant) makes the archival id collision-free in practice (plan-critic NICE 9 — stronger than a key+date seed).applyDiffsupersede branch — REPLACE IN PLACE, not copy (:144-162; plan-critic BLOCK 1). Explicit ordering:- (a) Compute
passing = diff.supersedes.filter(s => { const f = factById(current, s.oldId); return f && f.status === "active" && f.value === s.oldValue }). Thef.value === oldValuestate-check is the idempotency + stale-diff guard: on re-apply the winner holdsoldIdwithnewValue ≠ oldValue→ not passing → skipped (SC3b); a profile mutated under the diff → skipped (R3). BuildretireById = new Map(passing.map(s => [s.oldId, s])). - (b) When mapping
current.staticandcurrent.dynamic: a fact whoseid ∈ retireByIdis transformed in place to{ ...f, id: archivalId(f.id, f.value), status: "superseded" }(keeps its layer + its OWNprovenance/first_seen— apublished→humanupdate must NOT rewrite the archived fact's provenance, plan-critic FIX 3). It is excluded from bump/promote (a superseded fact is never bumped or promoted — resolves the ordering hazard, plan-critic FIX 6: supersede-wins over a same-id bump/promote in one diff). - (c) Append each winner
{ id: newId, value: newValue, first_seen: today, last_seen: today, evidence_count: 1, provenance, status: "active" }to dynamic (Q-LAYER re-earn). Because (b) vacatesoldIdand the winner re-occupies it (whenkey===supersedes), there is exactly one fact at the canonical key-id (the winner) + one archival fact — no duplicate id. Winnerfirst_seen: todayis intentional (a genuinely new observation; the original first-observation date lives on the archived copy — plan-critic FIX 8a). applyDiffstays pure (newProfileDoc); existing bump/promote/addition handling preserved.
- (a) Compute
- Decay scan excludes superseded (
consolidate.ts:132-134; plan-critic FIX 8c): addf.status !== "superseded"to thecurrent.dynamicdecay filter so a retained dynamic archival fact never throws perpetualstaleFlags. (Static archival facts are already decay-exempt — dynamic-only scan.)
GREEN gate: (cd scripts/brain && npm test) → all engine SCs pass; existing 82 pass.
Step 3 — (GREEN) CLI render + validation + gather filter
renderDiffMd(cli.ts:132-145): add a## Supersessions (old → new)section, appended LAST (after Stale,:143) via the existingsection()helper, only whensupersedes.length > 0— one line per entry- \` → ```. Zero supersessions → no section → byte-identical to today (plan-critic FIX 5).validateCandidates(:147-157): ifsupersedespresent, assert non-empty single-line string; absent is valid.--gatheractive-only filter (cli.ts:174; plan-critic FIX 8b):profileFactsemitted to the extraction session is filtered tostatus === "active"— superseded archival facts must NOT be re-presented as live context (else the slice re-introduces the retired values it exists to retire). One-line filter.- CLI tests (
consolidate-cli.test.ts): SC6 — (a)--proposewrites one## Supersessionsline per entry; (b) render-fidelity non-vacuity — 2 entries → 2 lines, 0 entries → no section (decoy); (c)--applywithout--confirmrefuses (exit 1, no write); with--confirmwrites superseded status. SC10 —--gatheroutput excludes superseded facts.
Step 4 — gate floor + doc reconciliation
test-runner.sh:716:BRAIN_TESTS_FLOOR82 → 82 + N (N = the EXACT counted new brain tests from Steps 1–3 — set after counting, never guessed). Extend the breakdown comment… + SB-S2 19 + SB-S3b N.ASSERT_BASELINE_FLOORstays 80 — no new unconditionaltest-runner.shstructure-lint check is added; the render-fidelity check is a brain-suite test counted byBRAIN_TESTS_FLOOR. (This reconciles brief SC9/§3.7 "ASSERT bump" wording: the render-fidelity check lives in the brain suite, soBRAIN_TESTS_FLOORis the only floor that moves — see SC9 below.)consolidation-loop.md: edit:34+:71to the true state — temporal-update supersede landed in SB-S3b (explicit signal, operator-gated, replace-in-place, audit-retained); contradictory still keep-both; decay still advisory. Add the honest-limit line (engine guarantees mechanics; the temporal-update-vs-contradictory call is session+operator judgement).consolidate.ts:11docstring: update the "no supersede in S2 — that's S3" line to note S3b implements temporal-update supersede.
Step 5 — Land
STATE "Telling" + "👉 NESTE" updated (S3b done → S3c/S3d remain); brief+plan committed as docs alongside code. Commit (Conventional; [skip-docs] on code/feat; brief+plan+consolidation-loop+docstring are docs → feat-dominant [skip-docs]). Push only inside the window (date '+%u %H:%M' first) and confirm before push (origin is PUBLIC open/). No version bump (additive within v0.5.2 dev).
Verification (testable)
| SC | Check | Expected |
|---|---|---|
| SC1 | routing | supersede candidate (target value differs) → 1 supersedes[] entry, 0 keep-both |
| SC2 | mutation+ids | winner id=mintEntityId(c.key), active, ev=1, value=new; retired→status:superseded, id=archival≠key-id, OWN provenance/first_seen kept; both present (no dup at key-id) |
| SC3a | propose-idempotent | post-land re-propose (value now matches) → 0 supersedes[], 1 bump — incl. when a stale signal persists (value-guard) |
| SC3b | apply-idempotent | re-apply same diff → value-match skip → no double winner, no re-supersede, no dup id |
| SC4 | graceful no-target | signal + no active fact at key-id → plain add, no throw |
| SC5 | round-trip | parse(serialize(applyDiff(...))) deep-equals applyDiff(...) (superseded fact from the real path) |
| SC6 | CLI render + gate | ## Supersessions line/entry; 0 entries → no section (byte-identical); --apply needs --confirm |
| SC7 | no regression | contradictory still keep-both; bump/promote/published-only unchanged |
| SC8 | intra-batch | two candidates same key / supersede-a-batch-created-fact → deterministic, no dup id |
| SC9 | gate green | bash scripts/test-runner.sh green; brain suite ≥ new BRAIN_TESTS_FLOOR; ASSERT_BASELINE_FLOOR unchanged at 80 |
| SC10 | gather active-only | --gather output excludes status:superseded facts |
| SC11 | decay excludes superseded | a retained dynamic superseded fact throws no staleFlags |
| SC12 | supersede-wins ordering | a diff that bumps/promotes AND supersedes the same id → supersede wins (superseded fact not bumped/promoted) |
| (red proof) | failing-test-first | (cd scripts/brain && npm test) BETWEEN Step 1 and Step 2 → new SCs fail, 82 pass |
Risks
- R1 — id corruption (plan-critic BLOCK 1).
applyDiffmust replace in place (filter+map, mirroring promotions:156), never copy — else two facts share the canonical key-id. Idempotency/stale guard keys offoldIdANDoldValue. Pinned by SC2 + SC3b. - R2 — propose self-supersede (plan-critic BLOCK 2). A persisted signal whose target already holds the candidate's value must NOT route to supersede (value-guard); else spurious archival churn every run. Pinned by SC3a.
- R3 — superseded facts leak back.
--gather(FIX 8b) and decay (FIX 8c) must filterstatus:active/excludesuperseded— else the slice regresses its own goal / spams stale flags. Pinned by SC10 + SC11. - R4 — stale diff vs mutated profile. Value-matched state-check skips. Pinned by SC3b/SC4.
- R5 — render JSON↔MD divergence. SC6 render-fidelity non-vacuity (every entry → a line; 0 → no section).
- R6 — floor-count drift.
BRAIN_TESTS_FLOORbumped by the EXACT counted N;ASSERT_BASELINE_FLOORdeliberately unchanged at 80 (no new unconditional structure-lint check). Verified by the green gate. - R7 — unguarded doc. No lint protects
docs/;consolidation-loop.mdreconciled by discipline (as the S3a:67edit).