Skip to content

TECH — Server-ledger cutover: task-view patch-server replaces ledger-cli (gates + guard port)

TECH — Server-ledger cutover: task-view patch-server replaces ledger-cli (gates + guard port)

Section titled “TECH — Server-ledger cutover: task-view patch-server replaces ledger-cli (gates + guard port)”
  • Task: ID-90 — Server-ledger cutover — task-view patch-server replaces ledger-cli (gates + guard port)
  • Subtask: {90.3} TECH
  • Session: S322 (authored 07/06/2026, fresh Planner per Q-PLANNER-2). Amended S335 (10/06/2026, fresh Planner) — surgical Phase 3 R-chain re-sequencing only (PRODUCT invariants untouched); root cause: the {90.21} flip’s __tests__/setup.ts KH_LEDGER_SERVER='0' force-pin invalidated the spec’s “tests already run server-side” premise. See §Phase 3 amendment note, §T-4, §Testing S335 amendment, and §Verification S335 re-verification.
  • Predecessors: docs/specs/ID-90-server-ledger-cutover/RESEARCH.md ({90.1}) and PRODUCT.md ({90.2}) — both ratified S322. Liam ratified ALL at the PRODUCT gate, including the §Simplicity review’s S2-staged single-source outcome: invariants 58–62 are binding (primitives single-source upstream at Phase 3; scoped-serialise.ts + ledger-budgets.ts relocate upstream; schemas migrate at {68.30}; the vendor-drift workflow shrinks-then-deletes; the CI twin is NOT ported).
  • Identity hygiene: this document lands on a public-track repo. Client identity tokens are FORBIDDEN strings here; the denylist is referred to generically as “the configured client denylist” throughout. Denylist inspection for this spec was shape-only (jq key/type projection — no token or pattern values were read into this document).

PRODUCT.md defines the behaviour (62 invariants); this spec maps each to a concrete change. The cutover converts scripts/ledger-cli.ts (4,367 lines, direct in-process primitives) into a thin transport façade over the task-view patch-server, with the three write gates, the conforming byte format, and four capability extensions ported upstream first (Phase 0), behind the ratified Option A two-phase shape.

Code-intelligence orientation is carried by the ratified RESEARCH §Context / PRODUCT §Context (Inv 2 satisfied there). Targeted supplements run for this document (07/06/2026):

  • ast-dataflow callers --symbol scripts/ledger-cli.ts:commitMutation12 call sites, all "enclosing":"fn:run", all "confidence":"exact", lines 2443–3443 of scripts/ledger-cli.ts ({"query":"callers","args":{"symbol":"scripts/ledger-cli.ts:commitMutation"},"results":[…12 entries, file scripts/ledger-cli.ts, enclosing fn:run…],"truncated":false}). commitMutation is therefore the single transport seam for every single-ledger mutation; the only write paths outside it are promote() (ledger-cli.ts:3551–3955) and the self-contained umbrella write (3957–3984, serialiseUmbrellas at 3982).
  • Canonical denylist shape verified at the private docs-site repo (ops/identity-denylist.json, shape-only jq): tokens[].{value: string, case_insensitive: boolean, class: string, covers_examples?/note?} (4 tokens; case_insensitive takes both true and false across tokens — per-token handling is mandatory) plus exclusion_patterns[].{pattern: string, reason: string} (4 entries) plus non_token_classes[], consumers[], metadata. The file’s own comment confirms the ledger guard consumes the same synced secret.
  • identity-guard.yml carve-out mechanics read at lines 375–410: CARVEOUT_PATTERNS built from exclusion_patterns[].pattern lowercased (lines 379–382); per-token loop with per-token case_insensitive flag (384–390); each matched line’s content is lowercased and substring-tested against every carve-out pattern — carved-out lines are excluded from the net count (case-statement at ~398–408).
  • docs/reference/umbrellas.json: document_name is the literal "umbrellas"; top-level keys {document_name, document_purpose, last_updated, related_documents, umbrellas}; 4 umbrella records shaped {id, title, substrate_doc, task_ids[], status, phase}.
  • Upstream vendored-schema drift discovered: task-view’s packages/schemas/src/task-list-schema.ts:224–243 carries the ID-20-era parseTaskListWithWarnings (25-Subtask ceiling only). KH’s canonical lib/validation/task-list-schema.ts:28 imports FIELD_BUDGETS and surfaces budget soft-warnings. Without a schema re-vendor, invariant 41 cannot hold server-side — addressed by U0.
  • ci.yml line drift vs RESEARCH: the ledger-mirror-parity job now sits at ci.yml:1087+ and TASK_VIEW_TAG: v0.3.1-task-view at ci.yml:1139 (RESEARCH cited 936–1006/988; the file has grown since — same content). regen-mirrors.sh:28–31 self-resolves the tag from ci.yml, so it needs no separate bump. The vendor-drift workflow pins the tag at task-view-vendor-drift.yml:86 and :214.

Key seams (all read 07/06/2026; substrate from the pinned clone .cache/task-view-v0.3.1-task-view @ 1644742762df3f202ab2a7bd3507aab5758fdd30):

SurfaceSite
Façade argv/envelopescripts/ledger-cli.tsparseArgs 349, flags 287–331, emit 1120, CliResult 150–165
Façade gatesrecord-set 1315–1486; budget 1488–1649 (GRAPHEME_SEGMENTER 1589); disciplineWarnings 1686
Façade write seamcommitMutation 1922–2068; promote 3551–3955; umbrella 3957–3984; nextId 645–674; withCreateDefaults 2237
Substrate handlerspackages/server/patch-server.tsreadCanonical 131–143, serialiseLedger 201–205, PATCH 526 (mtime check 578–601), POST record 719, DELETE 874, transaction 1051, regen 1157, dispatcher 1229–1291, factory startPatchServer 1307–1343 (loopback enforced 1308)
Substrate lifecyclepackages/server/ledger.tsstartTaskViewServer 182+, MAX_PORT_RETRIES = 5 (39), waitForExit
Substrate primitivesdetect-schema.ts:38–44 (KNOWN_DOCUMENT_NAMES, exactly three), patch-apply.ts (FieldPatch 81, applyPatches 420), record-mutate.ts (insertRecord 136, removeRecord 192), ledger-transaction.ts (two-ledger promote, fault seam), cross-ledger.ts:27–59 (LedgerSlug slug↔name map), apps/server/index.ts (parseCliArgs 57–80, runRegenCheck 224–261)
Guard lineagegit show 245c35ac:scripts/ledger-cli.ts 1895–1976 — note: guardClientName’s rejection/override messages interpolate [${names}] (the joined denylist). This is a redaction defect against invariant 32 and must NOT be ported as-is.

Decisions (PRODUCT OQ/T table — each: decision, rationale, rejected alternative)

Section titled “Decisions (PRODUCT OQ/T table — each: decision, rationale, rejected alternative)”

Decision: one singleton, multi-document, loopback daemon per ledger directory, spawned on demand by the façade, discovered via an atomic handle file, health-checked and version-pinned, with idle self-exit.

  • Topology: the daemon serves all in-scope documents in the directory (three ledgers + umbrellas.json) via slug-routed endpoints (U9). One process = one in-process mutation mutex per canonical path, which is the “single long-lived writer as enforcement chokepoint” the cutover exists to create (RESEARCH §Gap analysis, substrate advantages).
  • Lifecycle: façade’s ensureServer(ledgerDir) (K3) reads .cache/ledger-server/handle.json ({port, pid, version, ledgerDir}), validates with GET /api/health and a version-vs-pinned-tag check (tag parsed from ci.yml exactly as regen-mirrors.sh:28–31 does), and on miss/stale: takes an O_EXCL lock, kills any stale pid, spawns bun .cache/task-view-<TAG>/apps/server/index.ts --serve-dir <dir> --no-browser --port 0 --port-file <handle> --idle-exit 30 (flags added in U9), waits for the handle file + health 200 under a hard 10 s deadline (invariant 54: fails loudly, never hangs, never falls back to an ungated write). Child stdio routes to the façade’s stderr (invariant 13). Non-default --ledger-dir values (tests, the parity harness) get an ephemeral per-invocation server instead of the shared daemon — no handle-file pollution.
  • Idle self-exit (30 min, façade-supplied) prevents zombie daemons across sessions and guarantees stale-tag servers age out even if the version check is bypassed.

Rationale: parallel cmux sessions write the main checkout’s ledgers (the Orchestrator owns ledger writes), so one per-checkout daemon funnels every mutation through one mutex — closing the cross-process TOCTOU window between mtime check (patch-server.ts:578–601) and rename that no per-invocation design can close. The transaction endpoint already directory-scans siblings (resolveTransactionSiblings, patch-server.ts:1002–1023); a directory-scoped daemon matches the substrate’s own model. Rejected: (a) ephemeral server per mutation — loses single-writer serialisation, reopens the TOCTOU race across two server processes, pays startup per invocation; (b) launchd/system daemon — operator-managed state, stale-version hazard, violates invariant 54’s “operator never manages instances”; (c) four per-document daemons — 4× lifecycle complexity and promote spans documents anyway (invariant 56).

OQ-4 — append-journal server-side semantics

Section titled “OQ-4 — append-journal server-side semantics”

Decision: first-class server-side append patch op (U6): FieldPatch widens to {fieldPath, newValue} | {fieldPath, appendText}; the server resolves the leaf at apply time, inside the per-document mutex, and concatenates.

Rationale: journal blocks are the workflow system’s audit evidence — the worst loss class. Under the mutex + apply-time read, an interleaved-append loss is structurally impossible rather than retry-mitigated; invariants 39 + 43 hold by construction, and the retry loop (T-2) only remains responsible for cross-write 409s. Rejected: façade read-concatenate-PATCH under baseMtime — correctness rides entirely on the client retry loop; a retry bug silently drops journal evidence; doubles round-trips under contention; and replicates append logic per client, against invariant 9’s spirit.

OQ-7 — exclusion_patterns[] applicability to ledger byte-delta counting

Section titled “OQ-7 — exclusion_patterns[] applicability to ledger byte-delta counting”

The two coherent semantics, from the identity-guard.yml:375–410 study and the canonical shape read:

  • Semantics A — apply carve-outs: mirror identity-guard exactly — when counting hits over the bytes-about-to-be-written and the prior bytes, a hit whose surrounding line content (lowercased) contains any exclusion_patterns[].pattern as a substring is excluded from both counts. One unified semantics across all denylist consumers.
  • Semantics B — do not apply (parse-but-ignore): the ledger guard counts all per-token hits; exclusion_patterns[] remain identity-guard-only carve-outs. The parser still accepts the field (invariant 29’s shape includes it) but does not evaluate it.

Recommendation: Semantics B. Rationale: (i) the four recorded carve-out reason strings are all anchored to specific repo source files (an env-example placeholder line, a UI component placeholder, parameterised-mechanism config keys) — none describes ledger document content; (ii) the ledger guard is delta-based: pre-existing carve-out-shaped content counts equally on both sides and can never cause a rejection, so carve-outs would matter only for net-new content — and a net-new ledger write embedding a denylist token inside a config-key-shaped string is precisely the write the guard should challenge (the KH_LEDGER_ALLOW_CLIENT_NAME=1 override covers the rare legitimate case, invariant 33); (iii) importing grep-line carve-out semantics into a byte-delta counter adds a silent pass-open path for zero demonstrated need. Rejected: Semantics A — uniformity is not worth a pass-open path in a write-time guard. RATIFIED: Semantics B (Liam, S322 TECH gate) — carve-outs parse-but-ignore in the ledger guard; the override remains the escape hatch.

T-1 — Transitional dual-shape denylist parser

Section titled “T-1 — Transitional dual-shape denylist parser”

Decision: JSON-only from day one. The guard parses KH_CLIENT_NAME_DENYLIST strictly as the canonical JSON (shape predicate mirroring identity-guard.yml:89); any set-but-unparseable or wrong-shape value — including a legacy comma-separated string — is a loud configuration error blocking the write (invariant 35). Rationale: the live secret is already full JSON ({68.15} widening); a JSON-first/comma-fallback parser converts every malformed-JSON typo into silent comma-split garbage tokens — the exact mis-count class that created bl-244. Migration is a one-time operator action (sync .env.local from the canonical private file), documented in the P1 runbook line. Rejected: dual-shape transitional parser — violates invariant 35’s fail-loud mandate via the silent semantic flip.

Decision: retry only on HTTP 409 mtime-mismatch; up to 3 automatic retries after the initial attempt (4 submissions max); backoff 50 ms / 150 ms / 400 ms with ±50% jitter. Each retry re-reads via the server (fresh baseMtime), re-derives intent per invariant 43’s intent classes (the server re-runs all gates regardless), and re-submits. Exhaustion → standard failure envelope error: "mtime-mismatch", exit 1, nothing written (invariant 44). Success-after-retry → warnings[] entry mtime-conflict: write succeeded after N retry/retries (invariant 45). Connection-refused gets one lifecycle respawn attempt (K3), separate from the 409 budget, then fails loudly. Rationale: under the daemon mutex, conflicts only arise from a sibling session writing between this invocation’s read and submit — rare and sub-second; 3 bounded retries absorb that without hang risk. Rejected: unbounded retry (hang, invariant 54); zero retries (turns routine parallel-session interleaving into operator-visible failures, regressing Goal G-C).

Decision: per-request JSON body fields on every mutation body: {dryRun?: boolean, force?: boolean, allowClientName?: boolean, regenMirrors?: boolean} (U10). The façade maps --dry-run/--force/--no-regen-mirrors and the KH_LEDGER_ALLOW_CLIENT_NAME=1 env to these fields per invocation. The server holds no override state between requests (invariants 26, 33). Rejected: HTTP headers — out-of-band to the typed, Zod-validated body; invisible to the OQ-T3 body-shape log discipline; easier to leak into a default client config and thereby become session-global.

T-4 — S2 interim consumption mechanism + lib/ledger deletion sequencing

Section titled “T-4 — S2 interim consumption mechanism + lib/ledger deletion sequencing”

Decision: schemas remain canonical in KH until {68.30} (invariant 62) — the interim “consumption mechanism” is the status-quo schema vendoring refreshed at the v0.4.0 cut (U0); no git dependency or tag-pinned vendor read is introduced. lib/validation/ledger-budgets.ts stays in KH through Phase 3 (it is imported by lib/validation/task-list-schema.ts:28 for parse-time soft warnings, which live until {68.30}); its relocated upstream twin (packages/schemas/src/ledger-budgets.ts, U0) becomes the server gate’s registry, and the KH copy joins the vendor-drift schema-arm watch list (R3) — the “deliberate revision of the watched-path list” invariant 59 itself mandates.

Deletion sequencing at Phase 3 is an ordered commit chain. This ordering was re-derived in S335 (see §Phase 3 amendment note) because the original R1 (direct-path removal) → … → R4 (test migration) order is unsatisfiable under the green-per-commit gate: the {90.21} flip added __tests__/setup.ts:55–70, which FORCE-SETs KH_LEDGER_SERVER='0' process-wide, so 16 of the 17 run-importing ledger-cli-*.test.ts suites (248 run() mutation calls) exercise the DIRECT WRITE path (the 17th, ledger-cli-discoverability.test.ts, is read-only). Removing the direct path before migrating those tests fails compilation (four suites import __setRegenRunnerForTest/mirrorReminderFor/assertRecordSet — all R1-deletion export-block targets at scripts/ledger-cli.ts:4639–4653; plus ledger-cli-umbrella.test.ts + ledger-cli-input.test.ts import lib/ledger primitives that R2 deletes) and behaviour (the mirror suite asserts the in-process regenSpy, a direct-path-only seam). The re-derived chain is test-migration-first: R1a (un-pin + re-target the in-process suites to the transport path against ephemeral servers WHILE the direct path still exists → direct path becomes dead code) → R1b (façade hard-cut: now a near-dead-code deletion of the direct write path + flag) → R2 (delete lib/ledger/{atomic-write,detect-schema,patch-apply,record-mutate,scoped-serialise}.ts; retire scripts/{ledger-renormalise,ledger-normalise-oqls2}.ts + tests) → R3 (drift-workflow primitive arm retires; budgets path added to schema arm) → R4 (residual disposition accounting) → R5 (docs) → R6 (journal evidence). R1a/R1b/R2/R3 live in {90.22}; R4/R5/R6 in {90.23}. Rejected: introducing a git-dependency for schemas now — needless mechanism for a window already bounded by {68.30}, and it would prematurely demote the KH canonical that invariant 62 protects. Rejected (S335): keeping the original R1-first order with an exception carve-out for the affected suites — the suites cannot compile against a partially-deleted export block, so no carve-out exists; the migration is a hard precondition, not a parallel concern.

Decision: Phases 1–2: show/get/schema stay façade-local (current in-process read code, both flag states) — byte-identity (invariant 15) is trivial and a read never spawns the daemon. Phase 3 (after R2 deletes the vendored detect-schema): show/get re-route through GET /api/ledger/:slug + GET /api/ledger/:slug/record/:id; the schema subcommand stays façade-local — it renders from Schema.shape off lib/validation/*, which remain canonical in KH until {68.30}. Rejected: routing reads through the server from Phase 1 — couples every read-only invocation to server lifecycle for zero behaviour gain; rejected: keeping a KH-local document-name switch after Phase 3 — a parallel re-implementation of detectSchema against invariant 58’s spirit.

Error-vocabulary note for the Checker (invariant 14): the transport adds exactly one code, mtime-mismatch, as PRODUCT states. The guard port additionally surfaces client-name-guard (rejection) and client-name-guard-config (set-but-invalid denylist, invariant 35) — these are mandated by invariants 28–36/AC-H2 themselves (the codes previously existed only on the id68 branch lineage) and appear only when the guard is configured. Recorded here as a deliberate clarification of invariant 14’s “exactly one”, not a deviation; flag to Liam alongside OQ-7.

Phase 0 — upstream task-view (new tag v0.4.0-task-view; zero KH impact)

Section titled “Phase 0 — upstream task-view (new tag v0.4.0-task-view; zero KH impact)”
  • U0 — Schema re-vendor + budgets/umbrellas vendor. Refresh packages/schemas/src/{task-list-schema,roadmap-schema,backlog-schema,work-status}.ts from KH canonical (brings the budget-aware parseTaskListWithWarnings — the drift found in §Context). Add packages/schemas/src/ledger-budgets.ts (relocated registry: LEDGER_BUDGETS/FIELD_BUDGETS/DISCIPLINE_DOC, plain data; header carries the never-.max() mandate verbatim per invariants 24/59) and packages/schemas/src/umbrellas-schema.ts (vendored from lib/validation/umbrellas-schema.ts).
  • U1 — Conforming serialisation port. New packages/server/scoped-serialise.ts — port of KH lib/ledger/scoped-serialise.ts (408 lines: escapeNonAscii, escapeSerialise, scopedSerialise, scopedSpliceSerialise) extended with an umbrellas walk (['umbrellas', id, field]). Delete serialiseLedger (patch-server.ts:201–205). Handler routing: handlePatchRecord folds scopedSerialise left over canonical.rawText per FieldPatch (Zod via applyPatches stays the validation oracle; written bytes come from the parsed-original); handlePostRecord/subtask POST use scopedSpliceSerialise; handleDeleteRecord uses escapeSerialise(result.detected.data) (whole-file conforming — matches the CLI’s {65.2}/{65.3} whole-file deletes, byte-compatible post-OQ-LS-2); promoteTransaction staged contents: splice (ADD leg), escapeSerialise (REMOVE leg), scopedSerialise (roadmap link leg).
  • U2 — Budget gate. New packages/server/gates/budget-gate.ts — port of checkBudget + graphemeLength/GRAPHEME_SEGMENTER (ledger-cli.ts:1488–1649), registry from U0. Hook: post-mutation / pre-serialisation. PATCH: each patched field is mutatedField (can hard-reject); untouched over-budget fields soft-warn. Creates (record/subtask/promote task leg): create mode, first over-budget field fatal. force: true body field downgrades to a (forced) budget-exceeded: warning in the response warnings[].
  • U3 — Record-set gate. New packages/server/gates/record-set-gate.ts — port of collectionIds/assertRecordSet/checkRecordSet (ledger-cli.ts:1315–1486). Hook: post-serialisation / pre-atomicWriteFile in every mutating handler, on the exact bytes about to land. Deltas: PATCH/umbrella-PATCH none; POST record/subtask add (add-many bulk); DELETE remove; promote per-leg at stage time: task-list +1, backlog −1, roadmap .
  • U4 — Client-name guard. New packages/server/gates/client-name-guard.ts: parseDenylist() — strict JSON per T-1, shape predicate mirroring identity-guard.yml:89; per-token regex-escaped matcher honouring each token’s case_insensitive flag ('g' vs 'gi', mirroring identity-guard’s per-token loop at 384–390); exclusion_patterns[] parsed-but-ignored per OQ-7 recommendation (pending ratification); net-new delta over (rawText, serialised bytes); rejection → 422 {error: 'client-name-guard', detail: '<delta count + paths only>'}fully redacted (the 245c35ac [${names}] interpolation is deliberately NOT ported; no token, pattern text, or matched content in any response, log, or warning); allowClientName: true body field → redacted warning + allow; set-but-invalid env → 500 {error: 'client-name-guard-config'} blocking every mutation; unset env → inactive, unless the server runs with --require-denylist (passed by the façade lifecycle when process.env.CI is truthy) in which case unset is the same loud config error (invariant 34). Hook: post-serialisation / pre-write on all four mutating handlers, each transaction leg, and the umbrella path.
  • U5 — Subtask CRUD + auto-id + create defaults. Extend packages/server/record-mutate.ts (or sibling subtask-mutate.ts): insertSubtasks(detected, taskId, subtasks[]) (fold-left; per-record nextId max+1 semantics ported from ledger-cli.ts:645–674; duplicate-id pre-check; whole-doc Zod re-parse) and removeSubtask(detected, taskId, subId). Server-side defaults on create when absent (subtask: status: 'pending', dependencies: []; record: ported withCreateDefaults semantics from ledger-cli.ts:2237). POST /api/ledger/record allocates nextId when record.id is absent. New endpoints: POST /api/ledger/:slug/record/:taskId/subtask ({baseMtime, subtasks: […], …overrides} → 201 {ok, newMtime, subtaskIds, warnings?}), DELETE /api/ledger/:slug/record/:taskId/subtask/:subId ({baseMtime}).
  • U6 — Append op. packages/server/patch-apply.ts: FieldPatch widens to {fieldPath, newValue} | {fieldPath, appendText}; the walk concatenates at the leaf at apply time. Same op supported in scopedSerialise’s parsed-original application. Serves append-journal and the --append forms of update-backlog/update-roadmap.
  • U7 — Promote third leg. ledger-transaction.ts: PromoteTransactionInput gains capabilityTheme?: {roadmapPath, roadmapBaseMtime, themeId}; pre-stage validation rejects unknown themes (422 unknown-theme, nothing staged); idempotent linked_tasks[] push; three staged temps; commit order task-list (ADD) → roadmap (idempotent link) → backlog (REMOVE) — additive first, removal last, preserving the benign-transient-duplicate property. handlePostTransaction body gains capabilityThemeId? + roadmapBaseMtime?. Record-set + guard run per leg at stage time.
  • U8 — Umbrellas document kind. detect-schema.ts: KNOWN_DOCUMENT_NAMES gains the fourth literal "umbrellas" (lines 38–42); new union arm {kind: 'umbrellas', data: UmbrellasSchema.parse(…)}. Ripple: mirror-generator returns the empty plan for 'umbrellas' (no mirror dir — invariant 53); patch-apply gains the umbrellas walk; cross-ledger.ts LedgerSlug gains 'umbrellas'. Umbrella membership edits are field PATCHes on ['umbrellas', id, 'task_ids'] — record-set delta over the umbrella id-set; no budget entries fabricated (invariant 50).
  • U9 — Multi-document daemon, mutex, lifecycle affordances. apps/server/index.ts gains --serve-dir <dir> (scan + register all known-document files incl. umbrellas), --port-file <path> (atomic handle write {port, pid, version, ledgerDir} once listening), --idle-exit <minutes>, --require-denylist. patch-server.ts: routes gain the slug segment /api/ledger/:slug/… (bare /api/ledger/* keeps routing to the launch document for viewer back-compat); per-canonical-path mutation mutex (promise-queue) wrapping every mutating handler body; the transaction acquires its two/three paths in fixed lexicographic order. GET /api/health{ok, version, ledgerDir, documents: [{slug, document_name, path}]}.
  • U10 — Request/response envelope extensions. All mutation bodies accept {dryRun?, force?, allowClientName?, regenMirrors?} (T-3). Success responses gain warnings?: string[] — ported disciplineWarnings (ledger-cli.ts:1686, with {35.30} warningScope bounding) over the U0 vendored parse helpers, plus budget soft-warns, forced downgrades, and guard-override warnings. dryRun: true runs the full gate chain and returns the would-be payload with no write, regen, or mtime change (invariant 16). regenMirrors: false skips regen and reports it.
  • U11 — Upstream test ports. packages/server/scoped-serialise.test.ts (port of __tests__/lib/ledger/scoped-serialise.test.ts); packages/server/gates/{budget-gate,record-set-gate}.test.ts (coverage ported from ledger-cli-budget.test.ts / ledger-cli-record-set.test.ts); packages/server/gates/client-name-guard.test.ts — relocation of the 245c35ac 7 evaluator tests + 52ef3d5b 3 mutation-guard tests, JSON-shaped synthetic fixtures only (AC-H2 / AC-I), plus new redaction assertions (response bodies and logs are grepped for the synthetic fixture tokens) and config-error tests (invalid JSON, empty tokens[], legacy comma string → loud error); concurrency suite (mutex serialisation, auto-id race → no duplicate ids, interleaved appends → both blocks present); umbrellas detect/patch suite; transaction third-leg + fault-injection extension.
  • U12 — Tag. Cut v0.4.0-task-view + release assets (schemas bundle now incl. ledger-budgets + umbrellas-schema).

Phase 1 — KH façade (flag OFF by default)

Section titled “Phase 1 — KH façade (flag OFF by default)”
  • K1 — Pin bumps. ci.yml:1139 TASK_VIEW_TAG: v0.4.0-task-view; task-view-vendor-drift.yml:86 and :214; regen-mirrors.sh self-resolves (cache re-keys to .cache/task-view-v0.4.0-task-view). Fix the stale lib/ledger/README.md pin (OQ-9: v0.2.0v0.4.0).
  • K2 — Transport client. New scripts/ledger-server-client.ts: builds requests from a ServerIntent (K4); envelope mapping layer — HTTP {error, detail, issues} passes through into CliResult {ok: false, subcommand, error, detail?, issues?} (codes preserved: schema-error, walk-error, duplicate-id, record-not-found, budget-exceeded, record-set-violation, unknown-theme, …); 409 mtime-mismatch is consumed by the T-2 retry loop, surfacing only on exhaustion; 500 mirror-regen-failed with canonicalWritten: true maps to {ok: true, …, mirrorStale: true, mirrorStaleReason: 'regen-failed'} (preserving the {35.32} post-write-alert contract); regenMirrors: false round-trips as mirrorStaleReason: 'suppressed'; server warnings[] pass through; retry-success warning appended (invariant 45).
  • K3 — Lifecycle manager. New scripts/ledger-server-lifecycle.ts implementing OQ-2: handle-file discovery, health + version check, O_EXCL-locked spawn, kill-and-respawn on tag mismatch, ephemeral servers for non-default --ledger-dir, child stdio → stderr, 10 s hard deadline.
  • K4 — Façade flag branch. scripts/ledger-cli.ts: serverEnabled() reads KH_LEDGER_SERVER (=== '1' in Phase 1). CommitMutationOptions gains serverIntent?: ServerIntent (discriminated union: field-patch | record-create | subtask-create | subtask-delete | record-delete | umbrella-patch, each carrying slug/ids/patches); the 12 commitMutation call sites (ast-dataflow, §Context) populate it. commitMutation branches at the top: flag ON → transportCommit(opts) (K2/K3); flag OFF → existing body, untouched. promote() branches to POST /api/ledger/transaction (incl. capabilityThemeId). The umbrella arm branches to a slug-routed PATCH. parseArgs, the flag tables (287–331), emit (1120), reads, and schema are untouched — invariants 10–13, 15, 17 hold structurally.
  • K5 — Differential parity harness. New scripts/ledger-differential-parity.ts (invariant 21 / AC-P1): copy the live ledgers + umbrellas.json into two $TMPDIR fixture dirs; run the full mutating-subcommand matrix (× scoped/--whole-file × --force × --dry-run) via flag-OFF against dir A and flag-ON (ephemeral server) against dir B; assert byte-identical files (cmp), deep-equal envelopes (modulo absolute paths and retry warnings), equal exit codes, byte-identical mirrors. CI: a new step inside the ledger-mirror-parity job (ci.yml:1087+), after its existing tag-keyed clone + bun install (reuses the checkout); the step exports a synthetic denylist so the guard arm is exercised. Local: bun scripts/ledger-differential-parity.ts.
  • K6 — Umbrellas normalisation (invariant 51). One isolated commit: re-emit umbrellas.json via escapeSerialise (one-off script or an extension of scripts/ledger-renormalise.ts), with a pre-commit deep-equal parse assertion (content-neutral); the same commit flips serialiseUmbrellas (ledger-cli.ts:3982–3984) to escapeSerialise — otherwise the next flag-OFF update-umbrella would silently denormalise the file — and updates ledger-cli-umbrella.test.ts byte expectations.
  • F1. serverEnabled() default flips ON (KH_LEDGER_SERVER=0 becomes the explicit rollback switch — invariant 5 preserved). Soak ≥1 full SDLC session per AC-P2: scoped one-line diffs observed on real writes, ledger-mirror-parity green, zero gate regressions, ≥1 conflict-retry exercised (live, or synthetic via the U11 concurrency test), rollback drill recorded.

Phase 3 — retirement + single-source (S2-i)

Section titled “Phase 3 — retirement + single-source (S2-i)”

Amendment note (S335, escalation-driven). The original Phase 3 chain (R1 direct-path removal → R2 → R3 → R4 test migration → R5 → R6) was re-derived after two executors (exec-9021, exec-9022-r1) verified a ground-truth change that postdates the spec: the {90.21} default-flip commit (9160d0e3b, Checker-endorsed) added __tests__/setup.ts:55–70, which FORCE-SETs KH_LEDGER_SERVER='0' for the whole Vitest process. The spec premise — “the in-process ledger-cli-*.test.ts suites already run server-side post-flip” — is therefore FALSE: of the 17 suites importing run, 16 exercise the direct WRITE path (248 in-process run() mutation calls; the 17th, ledger-cli-discoverability.test.ts, is read-only and stays façade-local), four of them importing R1-deletion export-block targets (__setRegenRunnerForTest, mirrorReminderFor, assertRecordSet at scripts/ledger-cli.ts:4639–4653) and the mirror suite asserting the direct-path-only in-process regenSpy. Root cause: the {90.21} pin. Removing the direct path before migrating those tests violates the green-per-commit gate (compile failure + behavioural divergence). R1 is split into R1a (test-migration-first un-pin/re-target) → R1b (direct-path removal as near-dead-code deletion); R4 is reduced to residual disposition accounting. Each commit below still leaves full bun run test green AND tsc compiling. PRODUCT invariants are untouched — this is sequencing only. Pre-condition commit 0 (ci.yml explicit .cache/task-view-<tag> provisioning, ba8976d42) is already merged; sequencing starts after it.

  • R1a — Test migration (un-pin + re-target; direct path still present). Remove the __tests__/setup.ts:55–70 global KH_LEDGER_SERVER='0' force-pin. Precise scope (S335 fix 2): 17 ledger-cli-*.test.ts suites import run from @/scripts/ledger-cli; exactly 16 exercise the direct WRITE path and get re-targeted to the transport; the 17th — ledger-cli-discoverability.test.ts — is READ-ONLY (it calls only schema/get/show/show-task + an unknown-subcommand case) so reads stay façade-local per T-5 and it must NOT be wired to an ephemeral server. Each re-targeted write suite runs against the transport path (an ephemeral server over its beforeEach scratch --ledger-dir, allocated once per suite, reused across the suite’s run() calls to bound spawn cost — NOT one server per call). Relocate the direct-path-only seam assertions: ledger-cli-mirror.test.ts’s regenSpy/__setRegenRunnerForTest semantics are acknowledged moved upstream (server-side regen) and the façade form keeps only the mirrorStale/suppressed/regen-failed mapping it can observe over the transport; ledger-cli-record-set.test.ts’s assertRecordSet import drops (byte coverage upstream, façade keeps record-set-violation envelope assertions); ledger-cli-friction-s299.test.ts’s mirrorReminderFor import drops (stderr-reminder text is still emitted by the façade’s emit() path, asserted via stderr capture, not symbol import). ledger-cli-stdout-purity.test.ts’s __setRegenRunnerForTest import drops (stdout purity is now asserted over a real ephemeral-server run). lib/ledger import drops from Retained suites (S335 fix 1 — these survive R1b and would break tsc at the R2 boundary otherwise): ledger-cli-umbrella.test.ts:35 imports escapeSerialise from @/lib/ledger/scoped-serialise (used only to build expected bytes) → replace with an inline fixture serialiser or source the byte-conforming expected from the upstream .cache bundle; ledger-cli-input.test.ts:18 imports detectSchema from @/lib/ledger/detect-schema → replace with a structural assertion not tied to detectSchema (this suite tests parseArgs/readRecordInput/nextId, not the document-kind detector, and is itself NOT a write-path suite — it imports parseArgs/readRecordInput/nextId, not run). Determinism: the transport path reads KH_LEDGER_NOW via ledgerNow() (scripts/ledger-cli.ts:2314–2315); the same seam pins create-time timestamps CLI-side in withCreateDefaults, so flag-OFF and flag-ON byte outputs match — timestamp-sensitive byte assertions pin the clock exactly as the K5 harness does. Exit state of R1a: the direct write path is DEAD CODE — no test exercises it; no Retained suite imports a lib/ledger symbol that R1b/R2 will delete; the K5 OFF-vs-ON differential (invariant 8 proof) still runs because both paths still exist; bun run test green; tsc clean.
  • R1b — Direct-path removal (near-dead-code deletion). scripts/ledger-cli.ts sheds the in-process write machinery: local serialise(), the gate functions (collectionIds/assertRecordSet/checkRecordSet and checkBudget/graphemeLength/disciplineWarnings, :1426:1810), the regenRunner/maybeRegenMirrors shell-out (:1808:1903), atomicWriteFile usage, the serverEnabled() flag branch (server unconditional — commitMutation + promote + the updateUmbrella self-contained read-mutate-write at invariant 49, which K4 deferred its server wiring to here). (Line ranges are approximate — ~70 lines drifted since S322 authoring; resolve by function name, not line.) The now-unused export-block symbols (__setRegenRunnerForTest, mirrorReminderFor, assertRecordSet) are removed from the :4639–4653 block (R1a already dropped every importer). Argv surface, envelopes, exit codes intact (invariant 8). Reads re-route per T-5 (show/getGET /api/ledger/:slug + /record/:id; schema stays façade-local). The K5 harness (scripts/ledger-differential-parity.ts) converts to transport-only mode in this commit: the OFF arm has no direct path to compare, so the harness runs only the ON arm as a regression check (asserting success + OQ-LS-2-conforming bytes), retained until {68.30} re-homes the gate (TECH §Testing). Exit state: suite already on transport (R1a), so green; tsc clean.
  • R2 — Deletions. Delete lib/ledger/{atomic-write,detect-schema,patch-apply,record-mutate,scoped-serialise}.ts (+ README delete-or-rewrite as a tombstone pointer); retire scripts/ledger-renormalise.ts, scripts/ledger-normalise-oqls2.ts + ledger-renormalise.test.ts + __tests__/lib/ledger/scoped-serialise.test.ts (their suites live upstream per U11). Also retire the last non-CLI lib/ledger importer (S335 fix 1): scripts/ledger-sweep-s269.ts — a historical one-off sweep script (untested, in no CI lane) that imports escapeSerialise from @/lib/ledger/scoped-serialise; its sweep was a point-in-time migration already applied, so delete it (or, if a future need is suspected, re-source escapeSerialise from the upstream .cache bundle rather than the deleted KH primitive). lib/validation/ledger-budgets.ts stays (T-4) with its header annotation revised (no longer “KH-only/never-vendor”; now schema-arm-watched). Pre-deletion gate (S335 fix 1 — covers test + scripts + production importers, not just production): ast-dataflow importers / dead-exports over each lib/ledger/* module confirms zero remaining importers across scripts/, __tests__/, AND production before any rm. By this point the importer set has been fully drained: R1a dropped the two Retained-suite imports (-umbrella, -input); R1b dropped the scripts/ledger-cli.ts imports; this R2 step removes/retires ledger-renormalise.ts, ledger-normalise-oqls2.ts, ledger-sweep-s269.ts, ledger-renormalise.test.ts, scoped-serialise.test.ts. The gate must show an empty importer set on the EXACT module list.
  • R3 — Drift-workflow shrink (invariants 59–60). Remove the four lib/ledger/* watched paths + the primitive-diff step (task-view-vendor-drift.yml) — the primitive arm retires; add lib/validation/ledger-budgets.ts to the schema-arm watched paths + asset diff (the watched-path revision invariant 59 mandates; arm-level shrink per invariant 60 preserved — no new arms). The schema arm + workflow file deletion ride {68.30}, not this Task.
  • R4 — Residual disposition accounting. Most test migration is done in R1a (the green-per-commit precondition); R4 now records the residual AC-P3 18-file disposition that is NOT a precondition of R1b: confirm the budget/record-set/scoped-serialise/scoped-create/promote-scoped/bulk-add/delete-subtask/capability-bundle byte-shape coverage is accounted upstream (U11) with thin façade intent-mapping tests retained; confirm ledger-cli-integration.test.ts re-pointed at the transport; confirm the two module-coupled tests (ledger-renormalise.test.ts, scoped-serialise.test.ts) retired with their modules in R2. The full 18-file disposition table (§Testing) is the accounting oracle. No new direct-path-removal work lands here.
  • R5 — Docs. docs/reference/task-list-discipline.md §3 (enforcement point = substrate, façade = operator surface — prose only, invariant 57); docs/runbooks/ci.md; enforcement-point prose in workflow-orchestration (SKILL.md:390, 415–422), update-roadmap-backlog (SKILL.md:10, 24), triage-finding, workflow-curator.md, task-planner.md — documented invocations themselves remain valid unchanged.
  • R6 — Journal evidence. ID-90 cutover journal block with commit SHAs covering AC-H1 (i)–(iii) and AC-H2; AC-H1 (iv) recorded as held on the OQ-3 private-home decision (Liam + Orchestrator) — the journal states this dependency explicitly.

S335 execution errata (recorded {90.23} R5, 10/06/2026). The amendment text above was authored ahead of execution and predicted two things that the executors found could not happen as written. These are recorded as deltas — NOT a re-write of the amendment — with the authoritative record being the {90.22} details journal blocks (the four S335/S336/S337 <info added on …> entries). Canonical commits: R1a 2f221448b (+ fix-pass 1d2412072), R1b 5e1ba5714, R2 988dd4406, R3 156b746e0.

  • ERRATUM #1 — Retained-suite re-targeting WHEN-claim (affects this note + §Testing line 216, R1a bullet line 126). The text states the umbrella/promote-scoped Retained suites’ re-targeting from the direct path to the transport “executes in R1a”. It did NOT. R1a’s setup.ts un-pin routed their run() calls through the transport implicitly (ephemeral server per scratch --ledger-dir), but promote and updateUmbrella had ZERO server branch at R1a (promote used a staged direct write; updateUmbrella was a self-contained read-mutate-write with the umbrella-patch intent defined but never constructed — K4-deferred dead intent). Their write functions only got server wiring in R1b (escalation 3, Orchestrator option 2 — absorbed into R1b under invariant 49 + T-5, no third Planner loop). So the WHAT is unchanged (coverage lands on the transport; byte-shape upstream per U11); only the WHEN moved from R1a to R1b. The ledger-cli-promote-scoped.test.ts (10) + ledger-cli-umbrella.test.ts (18) suites were not edited in R1b — R1a’s un-pin already carried them onto the transport once the server branch existed.
  • ERRATUM #2 — R2 deletion list + R3 primitive arm (affects R2 bullet line 128, R3 bullet line 129). R2 was specified to delete all five lib/ledger/{atomic-write,detect-schema,patch-apply,record-mutate,scoped-serialise}.ts. The R2-mandated ast-dataflow importers pre-deletion gate found detect-schema, patch-apply, record-mutate are NOT drained: they are the CLI-side validation oracle (loadLedgerdetectSchema parses+validates before every server intent; fieldPatchMutationapplyPatches is the field-mutation schema oracle; insertRecord/removeRecord are the create/delete/promote duplicate-id oracle) — the invariant-8 schema-error/walk-error/duplicate-id envelopes depend on them. Escalation 4 / Orchestrator option (A): R2 deleted only lib/ledger/{atomic-write,scoped-serialise}.ts; the other three are RETAINED with header annotations stating the retained oracle role, disposition rides {68.30}. Consequently R3 kept the primitive-diff arm for the three retained modules (dropping only the deleted atomic-write.ts from the watched paths; scoped-serialise.ts was KH-authored and never on the watch list). A further consequence: the T-5 show/get read re-route to GET /api/ledger/:slug/record/:id was predicated on detect-schema deletion — with detect-schema retained, loadLedger still works façade-local, so show/get/schema stay FAÇADE-LOCAL (no server round-trip). This is a documented deviation from the T-5 prose, a direct consequence of erratum #2.

Proposed-changes ↔ PRODUCT invariants 1–62 (one-to-one)

Section titled “Proposed-changes ↔ PRODUCT invariants 1–62 (one-to-one)”
InvChangeConcrete note
1Migration plan §belowProcess invariant: phase ordering + AC-gated exits; Orchestrator-enforced; no single code artefact.
2U0–U12All Phase-0 changes land in the task-view repo; KH consumes nothing until K1.
3K4serverEnabled() === '1'; flag-OFF path is the untouched existing commitMutation/promote/umbrella body.
4K2+K4, proven by K5Same argv → same envelope/exit/bytes via transport; differential harness is the proof.
5K4Branch at one seam; both paths emit the identical on-disk format (U1 ≡ scoped-serialise) — no migration/cleanup on flip.
6K4Zero .claude/ edits; AC-P1 git diff check over .claude/skills+.claude/agents.
7F1 + Migration planP2 entry gated on AC-P1; P3 entry gated on AC-P2 soak evidence.
8R1a+R1bR1a re-targets tests to transport (direct path dead); R1b removes the direct write machinery; façade keeps argv contract. Proven byte-identical through R1a by the still-live K5 OFF-vs-ON differential.
9U2/U3/U4 hook placementGates execute inside the server handlers; any client (façade or direct HTTP) gets identical gate outcomes.
10K4 (no change to dispatch)run() dispatch (2332–3539) + SUBCOMMAND_ALIASES (895) untouched.
11K4 (no change to flag tables)VALUE_FLAGS/BOOLEAN_FLAGS (287–331) + unknown-flag rejection (375–379) untouched.
12K2Mapping layer emits via the existing emit() (1120–1157); CliResult shape unchanged.
13K2+K3Transport diagnostics + spawned-server stdio → stderr; stdout carries only the envelope.
14K2Code-preserving pass-through map; one transport addition (mtime-mismatch); guard-port codes noted in §Decisions error-vocabulary note (Checker/Liam attention).
15T-5No change in Phases 1–2 (reads façade-local in both flag states); P3 re-route (R1b) covered by the retained envelope tests already re-targeted to transport in R1a.
16U10 + K4dryRun: true body field — full gate chain server-side, no write/regen/mtime change.
17No change needed--pretty formatting lives in emit(), façade-local in all phases.
18U1escapeSerialise/scopedSerialise ported; serialiseLedger deleted.
19U1PATCH = scoped fold over rawText; creates = splice; ~1417-line class structurally impossible on those paths.
20U1Both paths emit OQ-LS-2-conforming bytes; no-op round-trip test upstream.
21K5Differential harness, CI step in ledger-mirror-parity job + local script; OFF-vs-ON through R1a, transport-only from R1b.
22U3Gate on serialised bytes pre-atomicWriteFile, every handler; record-set-violation, nothing written.
23U3+U7Per-leg at stage time: +1/−1/.
24U0+U2Registry relocated as plain data (packages/schemas/src/ledger-budgets.ts, never .max()); Intl.Segmenter grapheme counting ported.
25U2PATCH → per-patched-field mutatedField mode; creates → create mode.
26U10+K4force is a per-request body field; server holds no override state.
27U2subtask.details absent from the relocated registry — exempt on every path.
28U4Wired post-serialisation/pre-write in all four handlers + three transaction legs + umbrella path; no ungated byte route remains.
29U4 (T-1)Strict JSON parser, shape predicate per identity-guard.yml:89; comma parser never applied.
30U4Per-token regex with per-token case_insensitive flag (identity-guard.yml:384–390 model).
31U4Net-new delta over (rawText, serialised); equal-or-decreasing passes.
32U4Counts + paths only; the 245c35ac [${names}] echo deliberately not ported; log/response redaction tested in U11.
33U10+K4KH_LEDGER_ALLOW_CLIENT_NAME=1allowClientName: true per invocation → redacted warning + allow.
34U4+K3Unset → inactive locally; --require-denylist (passed when CI is set) → loud config failure.
35U4Set-but-invalid → client-name-guard-config, blocks every mutation in every context.
36U117 + 3 tests relocated to packages/server/gates/client-name-guard.test.ts, synthetic JSON fixtures, green = AC-H2 evidence.
37U5+K4Server-side nextId port, create defaults, bulk fold-left, per-record budgets (U2 in bulk mode).
38U5+U9Allocation at apply time inside the per-document mutex; duplicate-id pre-check retained → second writer re-derives or fails loudly.
39U6Apply-time leaf concatenation under the mutex; prior bytes preserved verbatim.
40U7Validate-first three-leg staging; unknown-theme pre-stage; additive-first commit order; idempotent link push.
41U0+U10Budget-aware vendored parse helpers + ported disciplineWarnings populate response warnings[]; façade passes through.
42No change needed (extended)mtime check already enforced (patch-server.ts:578–601 et al.); U5/U7/U9 apply the same check on every new endpoint/leg.
43K2Per-intent re-derive on retry; append re-derives trivially (server-side op); creates re-derive ids server-side; deletes surface record-not-found.
44K2 (T-2)3-retry bound; exhaustion → mtime-mismatch, exit 1, nothing written, idempotently re-runnable.
45K2Retry-count warning appended to the success envelope warnings[].
46U9+K2Mutex serialises intra-daemon; retry absorbs inter-read conflicts; concurrency suite (U11) is the regression proof.
47No change needed (mapped)Substrate scoped regen retained (patch-server.ts:671/828; full regen on DELETE); regenMirrors: false (U10) → suppressed; regen failure → K2 maps to mirrorStale/regen-failed, never rollback.
48K1One tag literal governs CI (ci.yml:1139), regen cache key (self-resolved), vendor-drift (lines 86/214), and the daemon the façade spawns (K3 version check).
49U8+K4"umbrellas" registered as the fourth KNOWN_DOCUMENT_NAMES literal; update-umbrella → slug-routed gated PATCH; self-contained raw write (updateUmbrella, K4-deferred) retired at R1b.
50U3+U4+U8Guard always; record-set over umbrella id-set; no budget entries fabricated.
51K6Isolated, content-neutral (deep-equal-asserted) normalisation commit + serialiseUmbrellasescapeSerialise flip in the same commit.
52U1+U8Umbrellas walk in the ported scoped serialiser → minimal-diff membership edits.
53U8mirror-generator returns the empty plan for kind 'umbrellas'; no mirrors created.
54K3Transparent ensure-server: handle file + health + locked spawn + 10 s deadline; fail-loud; structurally no ungated fallback (the flag-ON path has no local write branch; R1b makes the server path unconditional).
55No change needed (preserved)resolveServerHostname throws on non-loopback (patch-server.ts:1308); U9 changes routing, not binding.
56U9+U7Single multi-document daemon; transaction spans siblings (+ roadmap leg) within one process.
57U2/U3/U10 + R5Behaviour preserved by the gate ports; §3 prose update at Phase 3 only.
58R1b+R2Vendored primitives + scoped-serialise deleted from KH (R2, after R1b removes the last importers); gates exist only upstream; façade carries transport, not gate logic.
59U0+U2+R2+R3Plain-data registry wherever it lives; annotations revised; budgets path added to the drift watch list — deliberate, not silent.
60R3Primitive arm retires at Phase 3 (paths removed with the files — never watching a non-existent path); schema arm + file deletion ride {68.30}.
61No change needed (deliberate absence) + R6No ledger-client-name-guard.yml lands in KH CI; R6 journal records the substrate guard as the enforcement point; any private-home lint twin rides OQ-3.
62T-4 (no new mechanism)Schemas stay canonical in KH; U0 refreshes the vendored copies; consumer migration executes at {68.30} per the PRODUCT §(b) enumeration.

Migration plan (phase boundaries sized for {90.4} decomposition)

Section titled “Migration plan (phase boundaries sized for {90.4} decomposition)”
PhaseWhereChangesTest strategyRollbackExit gate
P0task-view repo (parallel-cmux sub-track; zero KH impact)U0 → U1 → {U2, U3, U4} → {U5, U6, U7, U8} → U9 → U10 → U11 → U12. Internal order: serialisation before gates (gates assert on conforming bytes); routing/mutex (U9) before the concurrency suite.Upstream bun test per slice; U11 suites green; no-op round-trip byte test on copies of the live KH ledgers; fault-injection extension for U7.Nothing to roll back KH-side; upstream slices revert independently pre-tag.AC-P0 — tag exists; gates + serialisation + parity affordances in; guard/gate test ports green; any P1-deferred gap named in the exit record.
P1KH (flag OFF)K1 (pins) ∥ K6 (umbrellas normalisation) ∥ {K2 → K3 → K4} → K5. K6 lands before any flag-ON umbrella testing.Full existing suite green flag-OFF (unchanged expectations); K5 harness green flag-ON against scratch dirs; ledger-mirror-parity green at the new tag; new façade unit tests (K2 mapping, K3 lifecycle with mocked spawn seam).Flag defaults OFF (inert); revert K1 commit restores the v0.3.1 ecosystem; K6 reverts as one isolated commit.AC-P1 — incl. zero .claude/ consumer edits and the OQ-9 README fix.
P2KH (flag ON default)F1 only (one-line default + docs note).Soak ≥1 full SDLC session; diff-shape spot checks; conflict-retry evidence (live or synthetic); rollback drill (KH_LEDGER_SERVER=0 → verify → flip back) recorded.Instant: KH_LEDGER_SERVER=0; or revert F1.AC-P2.
P3KHOrdered commits R1a → R1b → R2 → R3 ({90.22}) → R4 → R5 → R6 ({90.23}) — re-derived S335 (test-migration-first; see §Phase 3 amendment note).Full bun run test after each commit; R1a un-pins setup.ts + re-targets the in-process suites to transport (direct path becomes dead code, suite green); R1b converts the K5 harness to transport-only mode (OFF arm gone); retained façade tests green against ephemeral servers throughout; identity-hygiene grep (AC-I) over all ID-90 artefacts.git revert of the chain (ledgers still in-repo pre-{68.30}, so the direct path restores wholesale; reverting R1b then R1a restores both paths + the K5 differential); soak evidence precedes, narrowing the need.AC-P3 + AC-H1(i–iii) + AC-H2 recorded; AC-H1(iv) explicitly held on OQ-3.

End-to-end flow (flag ON, update-subtask 90.3 status done shape): façade parses argv (unchanged) → builds field-patch ServerIntent → ensureServer(docs/reference) (K3: handle file → health → reuse) → GET /api/ledger/task-list for baseMtimePATCH /api/ledger/task-list/record/90 {patches: [{fieldPath: ['tasks','90','subtasks',3,'status'], newValue: 'done'}], baseMtime, force: false} → server: mutex acquire → mtime check → applyPatches (Zod oracle) → budget gate (U2) → scoped fold over rawText (U1) → record-set gate (U3) → client-name guard (U4) → atomicWriteFile → scoped mirror regen → {ok, newMtime, warnings} → façade maps to the standard envelope, exit 0.

Differential parity (invariants 4, 21; AC-P1). K5 as specified: per-subcommand matrix, byte-level cmp on files and mirrors, envelope deep-equal, exit-code equality. Runs in the ledger-mirror-parity CI job (reusing its tag-keyed clone, now with explicit .cache/task-view-<tag> provisioning per pre-condition commit ba8976d42) and locally. The OFF-vs-ON differential requires both code paths to exist, so it stands through R1a as the invariant-8 proof; R1b converts it to transport-only mode (ON arm only, asserting success + OQ-LS-2-conforming bytes), retained as a regression harness until {68.30} re-homes the gate.

Existing 18 CLI test files — Phase 3 disposition (AC-P3). S335 amendment: the re-targeting of the “Retained” suites from the in-process direct path to the transport path executes in R1a (the green-per-commit precondition — the {90.21} setup.ts pin made them direct-path-bound), not in R4. The disposition below describes WHERE coverage lands; R1a/R4 split is WHEN. R4 records the residual accounting only (see §Phase 3 R4).

FilesDisposition
ledger-cli.test.ts, -input, -field-editors, -discoverability, -stdout-purity, -promote-input, -friction-s299, -autoid-coercion, -umbrella (9 files total)Retained as façade tests (argv, envelope, exit codes, stdout purity, input coercion) re-targeted at the transport path against a real ephemeral server.
ledger-cli-budget.test.ts, -record-set.test.tsCoverage moves upstream (U11); KH keeps the K5 parity assertions.
ledger-cli-scoped-create.test.ts, -promote-scoped, -bulk-add, -delete-subtaskByte-shape coverage moves upstream with the serialiser; thin façade intent-mapping tests retained.
ledger-cli-mirror.test.tsRegen semantics upstream; façade keeps the mirrorStale/suppressed/regen-failed mapping tests.
ledger-cli-capability-bundle.test.tsThird-leg coverage upstream (U7 suite); façade intent test retained.
ledger-cli-integration.test.tsRe-pointed at the transport (full subcommand round-trip).
ledger-renormalise.test.ts (+ __tests__/lib/ledger/scoped-serialise.test.ts)Retired with their modules (R2); scoped-serialise suite lives upstream (U11).

Guard tests (AC-H2): relocated per U11 with synthetic JSON fixtures; green upstream is the {68.27} release evidence; the id68-branch files are thereby superseded, never merged to canonical (Inv 42).

New suites: upstream — gates, serialisation round-trip, subtask CRUD, append interleave, concurrency/mutex, umbrellas, third-leg fault injection, health/lifecycle flags. KH — K2 envelope-mapping unit tests (incl. the mirror-regen-failed → success-with-stale mapping and retry-exhaustion path), K3 lifecycle tests, retained façade suites.

Mock discipline (docs/reference/test-philosophy.md): transport tests run against real ephemeral patch-server instances (port 0, loopback) — the transport contract is the behaviour under test, so fetch is never mocked. Mocking is confined to the K3 process-spawn seam (lifecycle unit tests) and fault-injection seams the substrate already exposes (faultBeforeCommit). Guard fixtures are synthetic only; redaction tests assert the absence of fixture tokens in observable output. Date-sensitive retry/backoff tests pin time per the CLAUDE.md gotcha.

  • mtime millisecond granularity: two writes inside the same millisecond defeat the strictly-greater check. The U9 mutex closes this entirely intra-daemon; the residual exists only when flag-OFF and flag-ON writers mix during Phases 1–2 — equal to today’s CLI-vs-CLI hazard, never worse; the phased flip minimises the mixing window.
  • Vendored-schema drift (found live): the upstream parse-helper drift (§Context) shows schema-direction drift goes unnoticed under the non-blocking reminder. U0 re-vendors at the cut; the vendor-drift diff at K1 verifies; schema single-sourcing at {68.30} removes the class.
  • Umbrella denormalisation hazard: a flag-OFF update-umbrella after K6 would have re-emitted raw UTF-8; closed by flipping serialiseUmbrellas in the same K6 commit.
  • Invariant-14 vocabulary clarification (guard codes) — recorded in §Decisions; Liam confirms alongside OQ-7 at TECH review.
  • Bun/tag skew: the façade always spawns from the tag-keyed cache and verifies /api/health version against the ci.yml-parsed tag (K3); idle-exit ages out survivors.
  • Worktree daemons: handle files live in each checkout’s gitignored .cache/ — worktrees get isolated daemons over their own docs/reference; ledger writes remain Orchestrator-owned in the main checkout, so contention stays where the mutex is.
  • R1a ephemeral-server spawn cost (S335). Un-pinning setup.ts routes ~248 in-process run() mutation calls across ~16 suites through ensureServer, which spawns a real loopback patch-server per non-default --ledger-dir. Mitigation: each suite allocates one ephemeral server over its beforeEach scratch dir and reuses it across the suite’s run() calls (server lifecycle keyed on --ledger-dir, idle-exit reaps it) — spawn cost is per-suite, not per-call. Vitest runs suites in parallel workers, so concurrent spawns bind to distinct loopback ports (port 0). The CI ledger-mirror-parity job’s explicit .cache/task-view-<tag> provisioning (commit ba8976d42) guarantees the server entry resolves regardless of arm order. Residual risk: a slower full-suite wall-clock under R1a; bounded and one-time (R1b removes nothing that re-adds spawns). If suite-time regresses materially, the fallback is a shared per-worker daemon over a common scratch root — but the per-suite-dir isolation that the existing suites rely on argues against it; flagged for the R1a executor to measure, not pre-optimise.
  • Identity hygiene: all ID-90 artefacts (specs, commits, fixtures, journal) are AC-I-checked against the configured client denylist; this spec refers to the denylist generically throughout; upstream test fixtures are synthetic.

Verification (Q-EX2 / OQ-3 empirical-check discipline)

Section titled “Verification (Q-EX2 / OQ-3 empirical-check discipline)”
  • Date: 07/06/2026. Scope: no new third-party external-library APIs are cited — the design uses Bun built-ins (Bun.serve, Bun.file), Node built-ins (node:fs, node:util parseArgs), Intl.Segmenter (stdlib), and zod only via existing, already-vendored first-party code (no new Zod API shapes).
  • Substrate (first-party): every task-view symbol cited was read from the pinned clone .cache/task-view-v0.3.1-task-view @ tag v0.3.1-task-view = 1644742762df3f202ab2a7bd3507aab5758fdd30 — result PRESENT for all cited symbols (startPatchServer, readCanonical, serialiseLedger, applyPatches, insertRecord/removeRecord, promoteTransaction, detectSchema/KNOWN_DOCUMENT_NAMES, startTaskViewServer, resolveServerHostname, parseCliArgs, runRegenCheck, slugForDocumentName).
  • KH side: all scripts/ledger-cli.ts, lib/ledger/*, lib/validation/*, workflow and test-file citations read from the working tree at canonical-pipeline-setup HEAD 876c68acPRESENT.
  • One BEHAVIOUR_DRIFT-class finding recorded: upstream packages/schemas/src/task-list-schema.ts:224–243 parseTaskListWithWarnings lacks the budget soft-warnings present in KH canonical (lib/validation/task-list-schema.ts:28, 212) — handled in-spec by U0 (not an escalation: the drift is repaired by this Task’s own Phase 0).

S335 re-verification (Phase 3 amendment — 10/06/2026). No new third-party external-library APIs are cited by the amendment (the re-derivation re-sequences internal commits only; Q-EX2 external-check scope does not apply). All ground-truth KH symbols underpinning the amendment re-verified PRESENT against the working tree at canonical-pipeline-setup HEAD a216d5b6d and confirmed via gitnexus_context/impact (repo knowledge-hub):

  • __tests__/setup.ts:55–70KH_LEDGER_SERVER='0' force-pin PRESENT (added by {90.21} flip 9160d0e3b).
  • scripts/ledger-cli.ts:4639–4653 export block — assertRecordSet, __setRegenRunnerForTest, mirrorReminderFor, subcommandHelp all PRESENT.
  • Export-block importers verified by grep: ledger-cli-mirror.test.ts + ledger-cli-stdout-purity.test.ts import __setRegenRunnerForTest; ledger-cli-mirror.test.ts + ledger-cli-friction-s299.test.ts import mirrorReminderFor; ledger-cli-record-set.test.ts imports assertRecordSet.
  • Suite-count precision (S335 fix 2): 17 ledger-cli-*.test.ts suites import run from @/scripts/ledger-cli, none setting a KH_LEDGER_SERVER override; exactly 16 exercise the direct WRITE path (verified by subcommand: each calls at least one of open-task/add-subtask(s)/update-*/flip-*/delete-*/promote/create-*). The 17th, ledger-cli-discoverability.test.ts, is READ-ONLY — its run() calls are only schema/get/show/show-task + an unknown-subcommand (frobnicate) case — so it stays façade-local and is NOT re-targeted. 248 run() mutation call sites counted across the 16 write suites.
  • Surviving lib/ledger importers (S335 fix 1 — verified by grep -rn 'from .*lib/ledger' __tests__ scripts): three importers survive R1b and would break tsc at the R2 boundary — ledger-cli-umbrella.test.ts:35 (escapeSerialise from @/lib/ledger/scoped-serialise, Retained suite), ledger-cli-input.test.ts:18 (detectSchema from @/lib/ledger/detect-schema, Retained suite — imports parseArgs/readRecordInput/nextId, NOT run), and scripts/ledger-sweep-s269.ts:32 (escapeSerialise, historical one-off, untested, in no R-chain or CI lane). The other importers (scripts/ledger-cli.ts, ledger-renormalise.ts, ledger-normalise-oqls2.ts, ledger-renormalise.test.ts, __tests__/lib/ledger/scoped-serialise.test.ts) are all removed/retired by R1b/R2 as already specified. Fix: R1a drops the two test imports; R2 retires ledger-sweep-s269.ts.
  • serverEnabled (scripts/ledger-cli.ts:139–141) gitnexus_impact upstream = LOW (1 direct caller commitMutationrunmain; 0 processes affected); gitnexus_context confirms commitMutation as the sole caller. Transport stack serverCommitMutation (:2225) → ledger-server-client.ts (mapSuccess, TransportRequest) → ledger-server-lifecycle.ts (ensureServer, spawnAndWait) all PRESENT. Clock seam: ledgerNow() (:2314–2315) reads KH_LEDGER_NOW; it is consumed by withCreateDefaults (:2447) for create-time updatedAt (S335 fix 3 — the seam pins both flag paths; the earlier serverCommitMutation attribution of the :2456 comment was wrong — that comment lives in withCreateDefaults).
  • K5 harness scripts/ledger-differential-parity.ts main()/runCli() PRESENT; the OFF arm pins KH_LEDGER_SERVER='0' and the ON arm '1' (lines 266–273), confirming the structural OFF-vs-ON dependency on both paths existing.
  • Pre-condition commit 0 (ba8976d42, ci.yml lines 1163–1180 .cache/task-view-<tag> symlink provisioning) PRESENT in canonical-pipeline-setup.
  • OQ-3 (post-cutover private home + parity-gate re-home) — Liam + Orchestrator; blocks AC-H1(iv) only; recorded as the explicit hold in R6.
  • OQ-8 (ID-68 records’ “ID-20” referent re-point to ID-90) — Orchestrator, before {68.30} dispatch.
  • OQ-7 ratification + the invariant-14 vocabulary clarification — Liam, at this TECH review.
  • Any future de-façade (skills speaking HTTP directly) is out of scope per PRODUCT §Non-goals; revisit only after the substrate has soaked as sole writer.