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).
Context
Section titled “Context”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.superRefinebuildsconst siblingIds = new Set(task.subtasks.map((s) => s.id))and comparessiblingIds.has(depId). Under the string contract this Set is nowSet<string>anddepIdis a string — the comparison works unchanged in logic, only the element type flips. Task ids are alreadyz.string().regex(BARE_ID_REGEX)(L101) and task deps alreadyz.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, runsNumber(record.id), rejects!Number.isInteger(n) || n <= 0 || trim === ''withinvalid-id, stampsid: n(number) back at L2499. L2501–2542:dependencies→number[]coercion (samen <= 0guard), stampsdependencies: coerced(number[]) at L2542.nextId(L714–728 subtask branch). Forsubtasks, returnsids.length === 0 ? 1 : Math.max(...ids) + 1— a number (L728); for tasks/themes/items it already returns a bare-digit string (L730–740).update-subtaskconsumption (dotted-only, L2860–2935): parses viaparseDottedSubtaskId; fieldPath already stringifies (String(subId), L2881); buts.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 baresubIdstring already (L2810['tasks', taskId, 'subtasks', subId, 'status']) — noNumber()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 === nlookup (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 oncenextIdand the schema return/store strings; flows intoexpectedDelta: { kind: 'add', id: newSubId }(L3114),budgetGate.recordId(L3119),serverIntent: { kind: 'subtask-create', … }(L3132),warningScope(L3138).add-subtasksbulk (L3148+): allocatesnewSubIdsrunning counter,expectedDelta: { kind: 'add-many', ids: newSubIds }(L3286).- Record-set gate (
assertRecordSetL1483–1495;beforeCollectionIdsL1453–1466;RecordSetDeltaL1404–1406id: string | number).beforeCollectionIdsmaps subtask ids vianew Set((task?.subtasks ?? []).map((s) => s.id))(L1460) → currentlySet<number>for subtasks.assertRecordSetusesSet.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(nowSet<string>) and everyexpectedDelta.id/idsMUST flip to string together, or the gate throws a falserecord-set-violationon every add/delete-subtask. - schema-error surface (L2274–2283
fieldPatchMutation, L1301, L3537–3542, L3615–3620, L3959–3963): emitserror: '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 unlessKH_LEDGER_SERVER === '0'.commitMutation(L2010) delegates toserverCommitMutation→ the task-view patch-server (/api/ledger/:slug/..., L2221+) whenserverEnabled() && serverIntent && scoped !== false.- KH-local
lib/ledger/{patch-apply,scoped-serialise,record-mutate,detect-schema,atomic-write}.tsare 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”; L241subtaskIdRaw = afterTask[1]; L248const subtaskIdNum = Number(subtaskIdRaw); L249!Number.isInteger(subtaskIdNum)reject; L255task.subtasks.findIndex((s) => s.id === subtaskIdNum).packages/server/scoped-serialise.ts: L166const subIdNum = Number(afterTask[1]); L167!Number.isInteger(subIdNum)reject; L174subtasks?.find((s) => s.id === subIdNum). Record-level splice (L334–441):recordId: string | number; L441collection.filter((rec) => rec.id !== op.recordId)(subtask-delete uses this withrecordId = subId).packages/schemas/src/task-list-schema.ts(vendored mirror, byte-faithful to KH modulo header + inlinedBARE_ID_REGEX = /^\d+$/at L42): L81id: z.number().int().min(1), L98dependencies: z.array(z.number().int().min(1)), L159siblingIds = new Set(task.subtasks.map((s) => s.id)).
Vendor / CI plumbing:
TASK_VIEW_TAGcurrentlyv0.4.0-task-viewat 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(theProvision .cache/task-view-<tag>step env-block —ln -sfn "$RUNNER_TEMP/task-view" ".cache/task-view-$TASK_VIEW_TAG"forensureServer). The fourth site (ci.yml:1177) was added by ID-90.22’s pre-condition commitba8976d42, 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.ymlhas TWO legs: schema drift (KHlib/validation/*↔ task-view release assets, L78–195, normalised diff stripping headers/imports/inlined-regex) and primitive drift (lib/ledger/*↔ task-viewpackages/server/*, L197+, paths-trigger L43–48). {90.22} P3 shrinks the primitive-drift leg (thelib/ledger/*paths-triggers and primitive-diff step) sincelib/ledger/*is deleted; the schema-drift leg stays (thelib/validation/*schemas remain in KH until {68.30}).ci.ymluses task-view as the mirror generator (node "$RUNNER_TEMP/task-view/bin/task-view.js" --check docs/reference/*.json, ~L1155) with agit diff --exit-codebyte-drift gate ondocs/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 KHlib/validation/*as a façade and round-trip into task-view via the re-vendor chain. This TECH targets the currentlib/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 storedid. Implementation consequence: the parser and its four call sites are untouched; only downstreamNumber(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-onlysuperRefinecompares 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:
- ID-90 P3 retirement landed. {90.22} (direct-path removal,
lib/ledger/*deletion, drift-workflow shrink — currentlyin_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. - 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.
Proposed changes
Section titled “Proposed changes”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 existingn > 0contract the CLI already enforced (coerceSubtaskRecordL2489). - 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 nowSet<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 theTask "${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 theNumber.isInteger && > 0 && trim !== ''check) but delete therecord = { ...record, id: n }restamp at L2499 — leave the validated string id in place. Update theinvalid-idmessage (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 astring[]instead: for a number token,String(dep); for a string token that passes the guard, push the string verbatim (notn). Update theinvalid-dependsmessages 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 nowstring[]. Change the return toids.length === 0 ? '1' : String(Math.max(...ids.map(Number)) + 1).Math.maxover raw strings would mis-order (Math.max("9","10")coerces butString(...).mapof mixed-width strings is unsafe); mapping toNumberfirst thenString-wrapping the result preserves the monotonicmax+1semantics 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: stringonce the subtask branch returns a string — verify no caller depends on thenumberreturn (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-stringnextIdreturn 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 gaterecordIdaccepts 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-keyedfindIndexafter 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, validateNumber(subIdRaw) > 0) but do not carry the numericnforward as the id. Bind the validated string (e.g.const subId = subIdRaw) for all downstream uses. - L3700
s.id === n→s.id === subId(string); L3705 message usessubId. - L3715
task.subtasks.filter((s) => s.id !== n)→!== subId. - L3727
subId: npayload →subId(string). - L3739
expectedDelta: { kind: 'remove', id: n }→id: subId(string). Load-bearing:beforeCollectionIdsnow yieldsSet<string>so the remove delta MUST be the string (a numericnwould never.delete()from aSet<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 splicerecordIdfilter at task-viewscoped-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)iftypeof 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 touchestask-list.jsononly. (Verify with ajqcount that nonumber-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: L81id, L98dependencies, mirror the KH P1 change byte-faithfully (the inlinedBARE_ID_REGEXat L42 stays inlined — that is the one intentional vendor difference the drift normaliser strips). ThesuperRefine(L159) flipsSet<number>→Set<string>automatically.packages/server/patch-apply.tsL248Number(subtaskIdRaw)+ L255findIndex(s.id === subtaskIdNum): stop numifying. The fieldPath segmentafterTask[1]is already a string; the storeds.idis now a string; compares.id === subtaskIdRawdirectly (dropsubtaskIdNumand theNumber.isIntegerinteger 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.tsL166Number(afterTask[1])+ L174find(s.id === subIdNum): same treatment. L441 record-splicerec.id !== op.recordId:recordIdarrives as the stringsubIdfrom KH’ssubtask-deleteintent (P5) → string-vs-string filter; no edit beyond the type of the incomingrecordId.- 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 vendoredpackages/schemas/src/task-list-schema.tsunder the drift normaliser (runtask-view-vendor-drift.yml’snormalise()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(theProvision .cache/task-view-<tag>step — its env literal must mirror the Install step’s literal at :1139, elseensureServerresolves 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’sgit diff --exit-codemirror-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:214site 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-grepTASK_VIEW_TAGat 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.
Testing and validation
Section titled “Testing and validation”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 inv | Verification |
|---|---|
| 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.
Risks and mitigations
Section titled “Risks and mitigations”- Record-set-gate false violation (HIGH if missed).
Setmembership is type-strict (new Set([15]).has("15") === false). IfbeforeCollectionIdsflips toSet<string>but anyexpectedDeltacarries a numeric id (or vice-versa during a partial landing), every add/delete-subtask throwsrecord-set-violation. Mitigation: P1+P3+P5 land atomically (flag-day); the §Testing inv-8/9 round-trip test is the canary. Math.maxover string ids (MEDIUM).nextIdmustmap(Number)beforeMath.max(P3) —Math.max("9","10")coerces, but relying on string→number coercion insideMath.maxis brittle and aString()of mixed-width digit strings sorts lexically wrong elsewhere. Mitigation: explicitNumbermap 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
:214site is in the primitive-drift leg {90.22} shrinks. Mitigation: re-grepTASK_VIEW_TAGat flag-day HEAD; do not trust the cited line numbers blindly. - Other three ledgers assumed string-clean (LOW). Verified by inv-1
jqscan, 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(KHpackage.json+ task-viewpackage.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"→ successtrue; number15→false;"15.0"→false;""→false;"-1"→false.z.string().regex(/^\d+$/).safeParse("0") → successtrue— FINDING: 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 primitiveSettype-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. NoABSENT/SIGNATURE_DRIFT/BEHAVIOUR_DRIFT. Spec is clear for ratification.
Follow-ups
Section titled “Follow-ups”- {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, thelib/validation/*façade and the re-vendor chain collapse — at that point thetask-view-vendor-drift.ymlschema-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.tsis one-shot — delete it (or move toknowledge-hub-archive) after the flag-day commit lands and is soaked. nextIdreturn-type narrowing. Once the subtask branch returns a string, the: string | numbersignature can narrow to: string(P3) — a small cleanup once all callers are confirmed string-only.