Skip to content

TECH — Ledger id unification: every ledger id becomes a digit-string (KH + task-view, migration, re-vendor chain)

TECH — Ledger id unification: every ledger id becomes a digit-string (PRODUCT inv 1–16)

Section titled “TECH — Ledger id unification: every ledger id becomes a digit-string (PRODUCT inv 1–16)”
  • Task: ID-102 — Ledger id unification — all ids string (Tier-2), subtask number→string + deps types.
  • Subtask: {102.3} TECH.
  • Session: S335 (authored 10/06/2026, FRESH Planner per Q-PLANNER-2 — NOT the {102.2} PRODUCT author plan-1022).
  • Predecessor: src/content/docs/specs/id-102-ledger-id-unification/PRODUCT.md ({102.2}) — authored S334, Checker-loop PASS, ratified S335 (D1 bare ids, D2 deps bare, D3 RC-2 in scope; D4/D5 restate the already-ratified strategy). RESEARCH ({102.1}) satisfied by the S333 dedicated investigation (docs/continuation-prompts/continuation-prompt-kh-s333-id90-acp1-green-2425-landed-tier2-next.md).
  • Ratified decisions consumed (load-bearing): D1 = bare stringified-integer subtask ids ("15", not dotted "90.15"). D2 = dependencies[] follows D1 (bare digit-strings). D3 = RC-2 better-errors companion IN scope. See §Decisions (ratified inputs).

This Task retypes every ledger record id from the current asymmetry — task / backlog-item / roadmap-theme ids are strings, subtask ids are numbers, and dependencies[] element types diverge to match — to a single canonical digit-string primitive (PRODUCT inv 1). The behaviour bar is the PRODUCT’s: the same CLI commands keep working, but the stored shape, schema, auto-id output, and admission seam all treat subtask ids and deps as strings, eliminating the RC-1 type-mismatch friction at write time. PRODUCT.md owns the user-visible behaviour; this document owns the mechanism, migration, schema diffs, and the task-view re-vendor chain.

Code-intelligence orientation (Inv 2 — cited verbatim, fresh pass at S335 HEAD)

Section titled “Code-intelligence orientation (Inv 2 — cited verbatim, fresh pass at S335 HEAD)”

gitnexus_query({query: 'ledger subtask id validation coercion', repo: 'knowledge-hub'}) returned processes: [] and process_symbols: [] — no named execution flow indexes the id-coercion seam (consistent with the {102.2} finding that run() is a single large dispatcher, not a graph of small processes). The definitions list rooted the surface at File:scripts/ledger-cli.ts, File:lib/ledger/scoped-serialise.ts, File:lib/validation/task-list-schema.ts, File:lib/ledger/patch-apply.ts, File:lib/validation/backlog-schema.ts, File:lib/validation/roadmap-schema.ts, and File:scripts/ledger-differential-parity.ts. Staleness note: the gitnexus index still lists lib/ledger/* as live KH files; on the authoring branch (canonical-pipeline-setup) they are still present on disk, but ID-90 P3 retirement ({90.22} in_progress, {90.23} pending) is deleting them this session — the index has not re-indexed the in-flight P3 deletions. This document targets the POST-P3 surface (see §Concurrency gate) while recording the pre-P3 seam locations as ground truth.

gitnexus_context({name: 'coerceSubtaskRecord', repo: 'knowledge-hub'}) — symbol Function:scripts/ledger-cli.ts:coerceSubtaskRecord (startLine 2479, endLine 2544); incoming.calls: 1 (Function:scripts/ledger-cli.ts:run); outgoing calls to cliErr and withCreateDefaults; processes: []. Verdict level: MEDIUM (single graph-caller, but ast-dataflow callers resolves two real call sites — scripts/ledger-cli.ts:3044 add-subtask and scripts/ledger-cli.ts:3194 add-subtasks bulk — that deliberately share the helper to stay byte-identical; the GitNexus single-caller figure under-counts because run() is one giant function). Top-3 affected flows: none indexed (processes: []); the affected surface is the four CLI subcommands enumerated below.

gitnexus_context({name: 'parseDottedSubtaskId', repo: 'knowledge-hub'}) — symbol Function:scripts/ledger-cli.ts:parseDottedSubtaskId (startLine 1359, endLine 1381); incoming.calls: 1 (run); outgoing call to cliErr; processes: []. Verdict level: LOW for THIS Task — ast-dataflow callers resolves four call sites (:2786 flip-subtask, :2860 update-subtask, :2947 append-journal, :3667 delete-subtask), and the parser already returns subId as a STRING (arg.slice(dot + 1)). The format decision (D1 bare) is about the stored value, not this parser, so parseDottedSubtaskId and all four call sites are untouched — only their downstream Number(subId) casts are deleted.

Ground-truth seam map (line numbers verified at S335 HEAD on canonical-pipeline-setup)

Section titled “Ground-truth seam map (line numbers verified at S335 HEAD on canonical-pipeline-setup)”

KH schema — lib/validation/task-list-schema.ts:

  • L67: subtask id: z.number().int().min(1) → becomes a digit-string.
  • L84: subtask dependencies: z.array(z.number().int().min(1)) → becomes a digit-string array.
  • L141–160: TaskSchema.superRefine builds const siblingIds = new Set(task.subtasks.map((s) => s.id)) and compares siblingIds.has(depId). Under the string contract this Set is now Set<string> and depId is a string — the comparison works unchanged in logic, only the element type flips. Task ids are already z.string().regex(BARE_ID_REGEX) (L101) and task deps already z.array(z.string()) (L111); BARE_ID_REGEX = /^\d+$/ (lib/validation/schemas.ts:50).

KH CLI — scripts/ledger-cli.ts (4,653 lines):

  • coerceSubtaskRecord (L2480–2545). The RC-1 admission seam. L2487–2499: accepts string --id, runs Number(record.id), rejects !Number.isInteger(n) || n <= 0 || trim === '' with invalid-id, stamps id: n (number) back at L2499. L2501–2542: dependenciesnumber[] coercion (same n <= 0 guard), stamps dependencies: coerced (number[]) at L2542.
  • nextId (L714–728 subtask branch). For subtasks, returns ids.length === 0 ? 1 : Math.max(...ids) + 1 — a number (L728); for tasks/themes/items it already returns a bare-digit string (L730–740).
  • update-subtask consumption (dotted-only, L2860–2935): parses via parseDottedSubtaskId; fieldPath already stringifies (String(subId), L2881); but s.id === Number(subId) lookup (L2891), subId: Number(subId) payload (L2897), recordId: Number(subId) budget gate (L2915) all coerce to number.
  • flip-subtask (L2786–2839): fieldPath uses bare subId string already (L2810 ['tasks', taskId, 'subtasks', subId, 'status']) — no Number() cast; resultPayload: { taskId, subId, status } (L2819) already string. This subcommand needs no CLI change (it is the proof D1-bare leaves the parser surface untouched).
  • append-journal (L2938–2972): s.id === Number(subId) lookup at L2966(this site was NOT in the {102.2} corrected caller-map; recorded here for completeness).
  • delete-subtask (L3660–3750): Number(subIdRaw) coercion + positive-int guard (L3692–3699), s.id === n lookup (L3700, L3715 filter), expectedDelta: { kind: 'remove', id: n } (L3739, numeric), serverIntent: { kind: 'subtask-delete', …, subId: n } (L3747, numeric).
  • add-subtask (L3044–3139): newSubId = validatedSubtasks[last].id as IdValue (L3077) — self-correcting once nextId and the schema return/store strings; flows into expectedDelta: { kind: 'add', id: newSubId } (L3114), budgetGate.recordId (L3119), serverIntent: { kind: 'subtask-create', … } (L3132), warningScope (L3138).
  • add-subtasks bulk (L3148+): allocates newSubIds running counter, expectedDelta: { kind: 'add-many', ids: newSubIds } (L3286).
  • Record-set gate (assertRecordSet L1483–1495; beforeCollectionIds L1453–1466; RecordSetDelta L1404–1406 id: string | number). beforeCollectionIds maps subtask ids via new Set((task?.subtasks ?? []).map((s) => s.id)) (L1460) → currently Set<number> for subtasks. assertRecordSet uses Set.has/add/delete, which are value-AND-type strict for primitives (new Set([15]).has("15") === false — empirically confirmed, see §Verification). This is the load-bearing co-change invariant: beforeCollectionIds (now Set<string>) and every expectedDelta.id/ids MUST flip to string together, or the gate throws a false record-set-violation on every add/delete-subtask.
  • schema-error surface (L2274–2283 fieldPatchMutation, L1301, L3537–3542, L3615–3620, L3959–3963): emits error: 'schema-error', issues: applied.zodError.issues — a raw Zod issues array (the RC-2 friction; D3 target, inv 16).

KH server-transport routing (post-{90.21}, default ON):

  • serverEnabled() (L140) returns true unless KH_LEDGER_SERVER === '0'. commitMutation (L2010) delegates to serverCommitMutation → the task-view patch-server (/api/ledger/:slug/..., L2221+) when serverEnabled() && serverIntent && scoped !== false.
  • KH-local lib/ledger/{patch-apply,scoped-serialise,record-mutate,detect-schema,atomic-write}.ts are imported (L88–102) as the direct-write fallback still present today. {90.22} P3 deletes these — the server becomes the sole subtask-id resolution seam.

KH-local lib/ledger/patch-apply.ts (pre-P3 fallback walker): L130 subtaskIdRaw = afterTask[1], L137 subtaskIdNum = Number(subtaskIdRaw), L144 task.subtasks.findIndex((s) => s.id === subtaskIdNum). Deleted by {90.22}.

task-view (github.com/liam-jons/task-view, WE OWN IT) — local checkout /Users/liamj/Documents/development/task-view @ tag v0.4.0-task-view on main:

  • packages/server/patch-apply.ts: L130 walker comment “subtaskId is an INTEGER id … we Number()-parse on subtask lookup”; L241 subtaskIdRaw = afterTask[1]; L248 const subtaskIdNum = Number(subtaskIdRaw); L249 !Number.isInteger(subtaskIdNum) reject; L255 task.subtasks.findIndex((s) => s.id === subtaskIdNum).
  • packages/server/scoped-serialise.ts: L166 const subIdNum = Number(afterTask[1]); L167 !Number.isInteger(subIdNum) reject; L174 subtasks?.find((s) => s.id === subIdNum). Record-level splice (L334–441): recordId: string | number; L441 collection.filter((rec) => rec.id !== op.recordId) (subtask-delete uses this with recordId = subId).
  • packages/schemas/src/task-list-schema.ts (vendored mirror, byte-faithful to KH modulo header + inlined BARE_ID_REGEX = /^\d+$/ at L42): L81 id: z.number().int().min(1), L98 dependencies: z.array(z.number().int().min(1)), L159 siblingIds = new Set(task.subtasks.map((s) => s.id)).

Vendor / CI plumbing:

  • TASK_VIEW_TAG currently v0.4.0-task-view at four assertion sites: .github/workflows/task-view-vendor-drift.yml:86 (schema-drift leg) and :214 (primitive-drift leg); .github/workflows/ci.yml:1139 (mirror-generator clone) and .github/workflows/ci.yml:1177 (the Provision .cache/task-view-<tag> step env-block — ln -sfn "$RUNNER_TEMP/task-view" ".cache/task-view-$TASK_VIEW_TAG" for ensureServer). The fourth site (ci.yml:1177) was added by ID-90.22’s pre-condition commit ba8976d42, which merged after the {102.2} research snapshot — PRODUCT inv 14 says “three places” but the live blast radius is now four (the inv’s intent — every asserted pin — is unchanged; only the count drifted). All four must be bumped together.
  • task-view-vendor-drift.yml has TWO legs: schema drift (KH lib/validation/* ↔ task-view release assets, L78–195, normalised diff stripping headers/imports/inlined-regex) and primitive drift (lib/ledger/* ↔ task-view packages/server/*, L197+, paths-trigger L43–48). {90.22} P3 shrinks the primitive-drift leg (the lib/ledger/* paths-triggers and primitive-diff step) since lib/ledger/* is deleted; the schema-drift leg stays (the lib/validation/* schemas remain in KH until {68.30}).
  • ci.yml uses task-view as the mirror generator (node "$RUNNER_TEMP/task-view/bin/task-view.js" --check docs/reference/*.json, ~L1155) with a git diff --exit-code byte-drift gate on docs/reference/{tasks,roadmap,backlog} — this is the inv-12 mirror-regen guard.
  • Parity harness scripts/ledger-differential-parity.ts (AC-P1 byte-parity proof, ID-90.20) exercises real CLI subcommands and asserts byte-identity of the final document.

Façade-local schema home (forward dependency, not designed-around here): S335 ratified that the ledger’s private home moves to docs-site src/content/docs/ledgers/ and the schemas single-source upstream at {68.30} (OQ-3). Until {68.30}, the schemas stay in KH lib/validation/* as a façade and round-trip into task-view via the re-vendor chain. This TECH targets the current lib/validation/* home; {68.30} is noted as the eventual relocation, not a blocker.


Decisions (ratified inputs — S335, Liam)

Section titled “Decisions (ratified inputs — S335, Liam)”
  • D1 — subtask id format: BARE "15" (RATIFIED). Stored subtask id is a bare stringified integer scoped to its parent Task — exactly today’s numeric value, retyped as a string, no resequencing. The dotted "90.15" form remains the command-line address only (parseDottedSubtaskId), never the stored id. Implementation consequence: the parser and its four call sites are untouched; only downstream Number(subId) casts are deleted. (PRODUCT §D1 rationale: dotted address already lives at the command layer; bare = minimal change + leaves the field-path walker untouched.)
  • D2 — dependencies[] element format: BARE digit-strings (RATIFIED). z.array(z.string().regex(/^\d+$/)), sibling-only superRefine compares strings. Type-identical to task deps (string[]). (PRODUCT §D2.)
  • D3 — RC-2 better-errors companion: IN SCOPE (RATIFIED). On schema-error, the CLI echoes the expected record/enum shape alongside the raw Zod issues (inv 16). Independent of the id contract; survives unification unchanged. Implemented as a small companion slice (§Proposed change P9).
  • D4 — migration sequencing: one-time, gated, idempotent, guards-green (ratified strategy). See §Concurrency gate + P6.
  • D5 — fail-loud, no transition window (ratified strategy). No tolerant coerce-on-read (that is the cancelled Tier-1 bridge in disguise). See inv 15 / §Proposed change P1.

Concurrency gate (the migration sequencing constraint — inv 11)

Section titled “Concurrency gate (the migration sequencing constraint — inv 11)”

The Tier-2 live-JSON migration (P6) stays GATED behind two preconditions, sequenced in this order:

  1. ID-90 P3 retirement landed. {90.22} (direct-path removal, lib/ledger/* deletion, drift-workflow shrink — currently in_progress) and {90.23} (test migration, docs, cutover journal — pending) must be merged so the server transport is the sole, settled write path. Migrating against a surface where both the KH-local fallback walker AND the server walker can re-coerce ids risks a partial re-numification.
  2. Cutover soak declared settled. Per the brief and PRODUCT inv 11, the Tier-2 migration is held until the cutover soak is declared settled (a separate gate from AC-P2, which passed S335). The migration then runs exactly once against the post-cutover write path.

Until both gates pass, ID-102’s schema-and-CLI changes (P1–P5, P3b, P7–P9) may land (they are forward-compatible: a string-only schema rejects the not-yet-migrated number-typed live ledger, so they CANNOT land before the data migration) — correction: the schema flip (P1) and the live-data migration (P6) are a single atomic flag-day commit (inv 10). The CLI seam edits (P2–P5, P3b) and the server re-vendor (P7) must be staged so they are ready but the flag-day commit is what flips schema + data + mirrors + TASK_VIEW_TAG together. The RC-2 slice (P9) is the one piece that is genuinely independent and may land ahead as a standalone PR.


Each change maps to PRODUCT invariants. Implementation order respects the atomic flag-day constraint (inv 10): P1 (schema) + P6 (data) + P7 (server tag) commit together; P2–P5 + P3b (CLI seam edits) stage ahead; P8 (mirrors + tag-bump) lands inside the flag-day commit; P9 (RC-2) is independent.

P1 — Schema flip: subtask id and dependencies[] become digit-strings (inv 1, 3, 4, 6, 15)

Section titled “P1 — Schema flip: subtask id and dependencies[] become digit-strings (inv 1, 3, 4, 6, 15)”

lib/validation/task-list-schema.ts:

  • L67 id: z.number().int().min(1)id: z.string().regex(BARE_ID_REGEX, 'Subtask id must be a string of digits'). /^\d+$/ alone admits "0" (empirically confirmed, §Verification) but inv 3 requires "0" rejected. Add a positive guard: .regex(BARE_ID_REGEX, …).refine((s) => Number(s) > 0, 'Subtask id must be a positive integer'). This preserves the existing n > 0 contract the CLI already enforced (coerceSubtaskRecord L2489).
  • L84 dependencies: z.array(z.number().int().min(1))dependencies: z.array(z.string().regex(BARE_ID_REGEX, 'dependency must be a string of digits').refine((s) => Number(s) > 0, 'dependency must be a positive integer')).
  • L145 superRefine: new Set(task.subtasks.map((s) => s.id)) is now Set<string> automatically; siblingIds.has(depId) compares string-to-string. No structural change to the refine; the message at L154 already interpolates ${depId} (works for strings). Verify the Task "${task.id}" interpolation is unchanged.

BARE_ID_REGEX is already imported from @/lib/validation/schemas (L27) — reuse it; do not inline a new regex (keeps the vendor-drift normaliser’s inlined-regex strip symmetric). Fail-loud (D5/inv 15): a number-typed id or number[] dep now fails z.string() parse — no tolerant coerce path is added.

P2 — coerceSubtaskRecord admission seam: stop numifying (inv 7)

Section titled “P2 — coerceSubtaskRecord admission seam: stop numifying (inv 7)”

scripts/ledger-cli.ts L2480–2545:

  • L2487–2499 id branch: keep the validation (Number(record.id) for the Number.isInteger && > 0 && trim !== '' check) but delete the record = { ...record, id: n } restamp at L2499 — leave the validated string id in place. Update the invalid-id message (L2495) from “subtask.id must be a number” to “subtask.id must be a string of digits”.
  • L2501–2542 deps branch: keep the per-token positive-int validation but stop building a number[]. Build a string[] instead: for a number token, String(dep); for a string token that passes the guard, push the string verbatim (not n). Update the invalid-depends messages from “must be number[]” to “must be string[] of digits”.

This is the seam where “a mis-typed id is impossible to express, not merely caught” (PRODUCT Goal b): the schema accepts the digit-string the agent already types; nothing converts it to a number.

P3 — nextId subtask branch returns a digit-string (inv 8)

Section titled “P3 — nextId subtask branch returns a digit-string (inv 8)”

scripts/ledger-cli.ts L714–728:

  • L727–728: const ids = (task?.subtasks ?? []).map((s) => s.id) is now string[]. Change the return to ids.length === 0 ? '1' : String(Math.max(...ids.map(Number)) + 1). Math.max over raw strings would mis-order (Math.max("9","10") coerces but String(...).map of mixed-width strings is unsafe); mapping to Number first then String-wrapping the result preserves the monotonic max+1 semantics over the numeric value. Update the return-type annotation comment (L706–708) and the JSDoc that says subtasks → NUMBER.
  • The function signature : string | number (L718) can narrow to : string once the subtask branch returns a string — verify no caller depends on the number return (all callers assign into the record id, now string).

P3b — add-subtasks bulk running counter: numeric increment, string stamp (inv 2, 8)

Section titled “P3b — add-subtasks bulk running counter: numeric increment, string stamp (inv 2, 8)”

scripts/ledger-cli.ts L3180–3204. This is the load-bearing follow-on of P3: the bulk path seeds a running counter from nextId and assign-then-increments it per id-less record so batch ids never collide. Today (L3184) let counter = nextId(...) as number, then (L3201–3202) record = { ...record, id: counter }; counter += 1. Once P3 makes nextId return a string, counter += 1 would string-concatenate ('5' + 1 === '51'), corrupting every bulk-allocated id and breaking the monotonic auto-id contract (inv 8) and the bare-scoped-id rule (inv 2). Fix:

  • L3184: let counter = Number(nextId(loaded.detected, 'subtasks', taskId)) — coerce the now-string nextId return back to a number for arithmetic.
  • L3201: stamp record = { ...record, id: String(counter) } — store the digit-string id.
  • L3202: keep counter += 1 (now a genuine numeric increment). Update the L3180–3183 comment.

This keeps add-subtask (single) and add-subtasks (bulk) byte-identical in OUTCOME (both produce digit-string ids) while the bulk path retains its collision-free sequential allocation. The bulk add-many record-set delta (newSubIds, L3286) is derived from the stamped string ids, so it flows into the type-strict gate as strings (see §P5 co-change note).

P4 — update-subtask / append-journal consumption: drop the Number(subId) casts (inv 9)

Section titled “P4 — update-subtask / append-journal consumption: drop the Number(subId) casts (inv 9)”

scripts/ledger-cli.ts:

  • L2891 s.id === Number(subId)s.id === subId (both strings now).
  • L2897 subId: Number(subId)subId (string payload).
  • L2915 recordId: Number(subId)subId (string; budget gate recordId accepts the union).
  • L2966 (append-journal) s.id === Number(subId)s.id === subId.
  • L2881 already String(subId) in the fieldPath — leave unchanged (the walker keys are strings; the now-string stored id matches the walker’s string-keyed findIndex after P7).
  • flip-subtask (L2786–2839): no change — already string-native.

P5 — delete-subtask consumption + record-set delta type (inv 9, plus the co-change gate)

Section titled “P5 — delete-subtask consumption + record-set delta type (inv 9, plus the co-change gate)”

scripts/ledger-cli.ts L3660–3750:

  • L3692–3699: keep the positive-int validation of subIdRaw (string in, validate Number(subIdRaw) > 0) but do not carry the numeric n forward as the id. Bind the validated string (e.g. const subId = subIdRaw) for all downstream uses.
  • L3700 s.id === ns.id === subId (string); L3705 message uses subId.
  • L3715 task.subtasks.filter((s) => s.id !== n)!== subId.
  • L3727 subId: n payload → subId (string).
  • L3739 expectedDelta: { kind: 'remove', id: n }id: subId (string). Load-bearing: beforeCollectionIds now yields Set<string> so the remove delta MUST be the string (a numeric n would never .delete() from a Set<string>new Set(["15"]).delete(15) is a no-op — and the gate would report a false violation). Update the L3736–3738 comment.
  • L3747 serverIntent: { kind: 'subtask-delete', …, subId: n }subId (string; consumed by the server splice recordId filter at task-view scoped-serialise.ts:441, now string-vs-string).

Record-set gate (assertRecordSet L1483, beforeCollectionIds L1453, RecordSetDelta L1404): no edit needed — RecordSetDelta.id is already the string | number union and beforeCollectionIds derives the type from s.id. The correctness requirement is that the add deltas (newSubId L3114, newSubIds L3286) and the remove delta (P5 above) all carry strings post-flip; the add deltas are self-correcting because newSubId reads the validated post-mutation record id (string after P1+P3). Verify (test, §Testing inv 8/9): an add-subtask then delete-subtask round-trip passes the gate with no record-set-violation.

P6 — One-time live-JSON migration (inv 10, 11) — GATED (§Concurrency gate)

Section titled “P6 — One-time live-JSON migration (inv 10, 11) — GATED (§Concurrency gate)”

A migration script scripts/migrate-ledger-ids-to-string.ts (one-shot, removable after the flag-day) that, for docs/reference/task-list.json:

  • For every task, for every subtask: subtask.id = String(subtask.id) if typeof subtask.id === 'number'; subtask.dependencies = subtask.dependencies.map((d) => typeof d === 'number' ? String(d) : d).
  • Idempotent (inv 10): the typeof === 'number' guard makes a re-run a no-op — already-string ids/deps pass through untouched.
  • Bounded pre-migration read exception (inv 11): this script is the ONLY code permitted to read the pre-migration number shape. It reads the raw JSON directly (not via the now-string-only TaskListSchema, which would reject number ids); it writes the migrated JSON, then the migrated file is validated against the new string schema as the script’s own exit gate.
  • Migration size (PRODUCT, live count 09/06/2026): 91 tasks, 714 subtask ids + 615 dep entries = 1,329 number→string conversions. The other three ledgers (product-backlog.json, product-roadmap.json, product-retros.json) carry no subtask ids — their record ids are already strings (inv 1 holds for them already), so the migration touches task-list.json only. (Verify with a jq count that no number-typed id exists in the other three.)
  • The script must preserve serialisation byte-shape (2-space indent, key order, trailing newline) so the byte-stability guard (inv 12) sees only the intended id-string change — emit via the same serialiser the CLI/task-view uses (run the migrated JSON through task-view --check-equivalent, or re-serialise with the canonical formatter before commit).

P7 — task-view server + vendored schema flip + new tag (inv 13, 14)

Section titled “P7 — task-view server + vendored schema flip + new tag (inv 13, 14)”

In /Users/liamj/Documents/development/task-view (WE OWN IT), on a branch, following its CONTRIBUTING.md re-vendor procedure:

  • packages/schemas/src/task-list-schema.ts: L81 id, L98 dependencies, mirror the KH P1 change byte-faithfully (the inlined BARE_ID_REGEX at L42 stays inlined — that is the one intentional vendor difference the drift normaliser strips). The superRefine (L159) flips Set<number>Set<string> automatically.
  • packages/server/patch-apply.ts L248 Number(subtaskIdRaw) + L255 findIndex(s.id === subtaskIdNum): stop numifying. The fieldPath segment afterTask[1] is already a string; the stored s.id is now a string; compare s.id === subtaskIdRaw directly (drop subtaskIdNum and the Number.isInteger integer guard, OR retain a digit-string guard /^\d+$/.test(subtaskIdRaw) for the not-an-integer error path — prefer the digit-string guard so the “is not an integer” error becomes “is not a digit-string id”, preserving a structured reject).
  • packages/server/scoped-serialise.ts L166 Number(afterTask[1]) + L174 find(s.id === subIdNum): same treatment. L441 record-splice rec.id !== op.recordId: recordId arrives as the string subId from KH’s subtask-delete intent (P5) → string-vs-string filter; no edit beyond the type of the incoming recordId.
  • Update the doc comments in both server files (e.g. patch-apply L26–28 “subtaskId is an INTEGER id … we Number()-parse”) to state ids are digit-strings.
  • Cut a new tag (e.g. v0.5.0-task-view) carrying the string-id schema + server seam. Push the tag (the task-view repo push is owned by the task-view sub-track; report the fork commit SHA).
  • Re-vendor back into KH: confirm lib/validation/task-list-schema.ts (P1) is byte-faithful to the new vendored packages/schemas/src/task-list-schema.ts under the drift normaliser (run task-view-vendor-drift.yml’s normalise() locally on both, expect empty diff).

P8 — Bump TASK_VIEW_TAG (three sites) + mirror regen inside the flag-day (inv 12, 14)

Section titled “P8 — Bump TASK_VIEW_TAG (three sites) + mirror regen inside the flag-day (inv 12, 14)”
  • Bump the literal v0.4.0-task-view → the new tag at all four blast-radius sites: .github/workflows/task-view-vendor-drift.yml:86, :214, .github/workflows/ci.yml:1139 (mirror-generator clone), and .github/workflows/ci.yml:1177 (the Provision .cache/task-view-<tag> step — its env literal must mirror the Install step’s literal at :1139, else ensureServer resolves the wrong tag-keyed cache path and the parity arms break). A stale tag still expecting the number-id schema fails the parity/drift check — that failure IS the guard that the re-vendor happened (inv 14).
  • Mirror regen (inv 12): regenerate docs/reference/{tasks,roadmap,backlog} mirrors with the new task-view generator inside the same flag-day commit so subtask ids render as strings; ci.yml’s git diff --exit-code mirror-drift gate stays green. The byte-stability + round-trip guards (parity harness) must pass against the migrated ledger.
  • {90.22} interaction: if P3’s drift-workflow shrink (removing the lib/ledger/* primitive-drift leg) has landed, the :214 site lives in a shrunk workflow — verify the tag literal’s location at flag-day HEAD (it may have moved or the primitive-drift leg may be gone, leaving two bump sites). Re-grep TASK_VIEW_TAG at flag-day time rather than trusting these line numbers.

P9 — RC-2 better-errors companion (inv 16, D3) — INDEPENDENT, may land ahead

Section titled “P9 — RC-2 better-errors companion (inv 16, D3) — INDEPENDENT, may land ahead”

At each schema-error emission site (scripts/ledger-cli.ts L2280, L3541, L3619, L3963 — the error: 'schema-error', issues: <…>.zodError.issues envelopes; note L3962 is the subcommand: 'promote' line, the key line is L3963), enrich the error envelope: alongside issues: applied.zodError.issues, add an expected field that echoes the expected record/enum shape derived from the offending Zod issue(s) — e.g. for an invalid status value, list the SubtaskStatus enum values (done | pending | in_progress | blocked | deferred | cancelled); for a wrong-typed id, state string of digits. Implement as a small pure helper describeExpectedShape(zodError): string[] that maps Zod issue codes (invalid_enum_value → enum options; invalid_type → expected type label) to human-readable expected-shape lines. The raw issues array stays (no removal); the helper is additive. This directly closes the S334 datapoint (flip-subtask 90.26 in-progress returned a raw issues array instead of the accepted-status list).

Load-time path (L1301) is NOT a schema-error site — covered separately: L1298–1303 in loadLedger emits error: 'ledger-schema-invalid' with issues: err.issues (a direct ZodError catch on ledger load — a different error code and a flatter shape than the <…>.zodError.issues patch-time structure). The same describeExpectedShape(err) helper applies (it takes any ZodError), so RC-2 SHOULD also enrich the ledger-schema-invalid envelope at L1301 — but track it explicitly as a second shape: the helper is shared, the envelope key differs. After the flag-day, a load-time ledger-schema-invalid is the fail-loud signal (inv 15) for a stray number-typed id in a canonical ledger, so echoing string of digits there is the highest-value RC-2 surface.


Each PRODUCT invariant maps to a concrete verification. bun run test (Vitest) for KH; task-view’s own suite for P7. Tests verify real behaviour per ${KH_PRIVATE_DOCS_DIR}/src/content/docs/reference/test-philosophy.md — exercise the CLI/schema, not the implementation.

PRODUCT invVerification
1 (all ids string)jq assertion over all four migrated ledgers: no (.. | .id) | type == "number" anywhere, top-level or nested. Schema parse of each ledger succeeds.
2 (bare scoped id, no resequence, monotonic)Unit: migrate a fixture with subtask ids [1,2,5] (gap) → ["1","2","5"] (gap preserved, not ["1","2","3"]). nextId over ["1","2","5"] returns "6" (max+1, non-gap-filling). New Task’s first subtask id "1".
3 (/^\d+$/, positive)Unit on the schema: "15" accepts; "0", "", "15.0", "15 ", "-1", "abc", number 15 all reject with invalid-id/schema-error. ("0" rejection requires the .refine(>0) — without it the regex admits it; §Verification.)
4 (deps bare string[], empty default)Unit: dependencies: ["1","3"] accepts; [] accepts; deps type-identical to a Task’s string[].
5 (sibling-only over strings)Unit: subtask dep "3" referencing a sibling accepts; dep "99" (no sibling) rejects with the sibling-violation message phrased over strings.
6 (number dep = hard error)Unit: dependencies: [1] (number) rejects post-flip.
7 (admission stores string, no round-trip)CLI integration: add-subtask --id 15 and --id "15" both store "id": "15" (string); read-back asserts typeof === 'string'.
8 (auto-id digit-string, monotonic — single)CLI integration: add-subtask with no --id into a task with ["1","2"] stores "3"; into an empty task stores "1". Record-set add delta passes the gate (no false record-set-violation).
8 (auto-id digit-string, monotonic — bulk, P3b)CLI integration: add-subtasks (3 id-less records) into a task with existing subtask "5" stores "6", "7", "8" (sequential, NOT "6","61","611" — the string-concat regression P3b prevents). Also: a gap fixture ["2","10"] → bulk-of-1 → "11" (max+1 numeric, not "3"). Record-set add-many delta passes the gate.
9 (dotted subcommands, subId as string throughout)CLI integration: flip-subtask 90.15, update-subtask 90.15.field, append-journal 90.15, delete-subtask 90.15 all resolve subtask "15" of task "90" to the same record as pre-migration; legacy space-separated form still works for flip/append/delete; update-subtask stays dotted-only. Assert no payload field is a number.
10 (one-time atomic, idempotent)Migration test: run once on a number-shape fixture → all-string; run AGAIN on the output → byte-identical no-op. Flag-day commit lands schema + JSON + mirrors + tag together (PR diff review).
11 (gated, migration sole pre-migration reader)Gate is a sequencing/process check (verified by the Orchestrator: P3 landed + soak settled before P6 runs). Test: the migration script reads raw JSON (not TaskListSchema); the string-only TaskListSchema rejects the pre-migration fixture (proving no general tolerant read path).
12 (byte-stability + mirror-regen + round-trip green)scripts/ledger-differential-parity.ts passes against migrated ledger; ci.yml mirror git diff --exit-code clean after regen; round-trip (read→serialise→read) byte-identical. No guard disabled.
13 (schema convergence + server round-trip)task-view-vendor-drift.yml normalise() diff between KH lib/validation/task-list-schema.ts and the new vendored copy is empty. task-view server test: a PATCH /api/ledger/task-list/... addressed at subtask "15" resolves and serialises the string id unchanged.
14 (TASK_VIEW_TAG bumped, stale tag fails)CI: with the new tag, parity passes; a deliberately-stale tag (old number schema) trips the drift warning / parity mismatch. Verify all three (or post-shrink: remaining) bump sites updated.
15 (fail-loud, no compat window)Unit: a number-typed id or number[] dep read from a canonical ledger via TaskListSchema.parse throws schema-error; no silent coerce.
16 (RC-2 expected-shape echo)CLI integration: flip-subtask 90.26 in-progress (hyphen) returns an expected field listing the accepted status enum values; a wrong-typed id returns string of digits. Raw issues still present.

Guard suites that MUST stay green (existing, do not modify to pass): pipeline-parity.test.ts, mcp-fixture-sync.test.ts (run on every test — update fixtures only if they carry subtask ids), the parity harness, the vendor-drift workflow (now satisfied by the converged string schema). Date-sensitive assertions pin Date.now per CLAUDE.md.


  • Record-set-gate false violation (HIGH if missed). Set membership is type-strict (new Set([15]).has("15") === false). If beforeCollectionIds flips to Set<string> but any expectedDelta carries a numeric id (or vice-versa during a partial landing), every add/delete-subtask throws record-set-violation. Mitigation: P1+P3+P5 land atomically (flag-day); the §Testing inv-8/9 round-trip test is the canary.
  • Math.max over string ids (MEDIUM). nextId must map(Number) before Math.max (P3) — Math.max("9","10") coerces, but relying on string→number coercion inside Math.max is brittle and a String() of mixed-width digit strings sorts lexically wrong elsewhere. Mitigation: explicit Number map in P3; unit test with a gap fixture ["2","10"]"11" (not "3").
  • Migration runs against an unsettled write path (HIGH — the whole reason for the gate). If P6 runs before P3 retirement + soak, the KH-local fallback walker or a concurrently-changing server seam could re-coerce. Mitigation: the §Concurrency gate (inv 11) holds P6 until both preconditions pass; the Orchestrator verifies, not this spec.
  • "0" admitted by the bare regex (MEDIUM — inv 3 violation). /^\d+$/ matches "0". Mitigation: the .refine((s) => Number(s) > 0) in P1; covered by the inv-3 test. Empirically confirmed in §Verification.
  • Vendor-drift line-number drift from {90.22} (LOW). P8’s :214 site is in the primitive-drift leg {90.22} shrinks. Mitigation: re-grep TASK_VIEW_TAG at flag-day HEAD; do not trust the cited line numbers blindly.
  • Other three ledgers assumed string-clean (LOW). Verified by inv-1 jq scan, but re-run at migration time in case a stray number id was introduced.

Verification (Q-EX2 — pre-ratification empirical check)

Section titled “Verification (Q-EX2 — pre-ratification empirical check)”

External library cited in the schema: Zod. Pinned version (both repos): zod@^4.4.3, resolved node_modules/zod@4.4.3.

  • Date: 10/06/2026.
  • Pinned version: zod==4.4.3 (KH package.json + task-view package.json, identical pin).
  • Symbols / API shape checked (import-and-call against the installed pin, sandbox disabled for the bun runtime):
    • z.string().regex(/^\d+$/).safeParse — PRESENT. "15" → success true; number 15false; "15.0"false; ""false; "-1"false.
    • z.string().regex(/^\d+$/).safeParse("0") → success trueFINDING: the bare digit-regex admits "0", which inv 3 requires rejected. Drives the .refine((s) => Number(s) > 0) pairing in P1. Not a Zod defect — a spec-correctness pairing.
    • z.array(z.string().regex(/^\d+$/)).safeParse — PRESENT. ["1","3"]true; [1,3] (numbers) → false (confirms inv 4/6).
    • new Set([15]).has("15")false (JS primitive Set type-strictness — drives the record-set co-change invariant in P5).
  • Result: PRESENT for all cited Zod APIs at the pinned version; one spec-correctness finding ("0") folded into P1. No ABSENT / SIGNATURE_DRIFT / BEHAVIOUR_DRIFT. Spec is clear for ratification.

  • {68.30} schema relocation (forward dependency). When the ledger’s private home moves to docs-site src/content/docs/ledgers/ and the schemas single-source upstream, the lib/validation/* façade and the re-vendor chain collapse — at that point the task-view-vendor-drift.yml schema-drift leg also retires. This Task does not design around it; it leaves the schemas re-vendorable.
  • Migration script removal. scripts/migrate-ledger-ids-to-string.ts is one-shot — delete it (or move to knowledge-hub-archive) after the flag-day commit lands and is soaked.
  • nextId return-type narrowing. Once the subtask branch returns a string, the : string | number signature can narrow to : string (P3) — a small cleanup once all callers are confirmed string-only.