ID-102 PRODUCT — ledger id unification (all ledger ids become STRING)
ID-102 — ledger id unification: all ledger ids become STRING (PRODUCT)
Section titled “ID-102 — ledger id unification: all ledger ids become STRING (PRODUCT)”Spec chain:
{102.1}RESEARCH (satisfied by the S333 dedicated investigation —docs/continuation-prompts/continuation-prompt-kh-s333-id90-acp1-green-2425-landed-tier2-next.md§STRATEGIC DECISION / §Root causes / §Tier-2 blast radius; no separateRESEARCH.md) →{102.2}PRODUCT (this document) →{102.3}TECH →{102.4}PLAN (if warranted). Artefact kind:{N.2}PRODUCT. Behaviour-only; mechanism, migration script, schema diffs, and validation live in the companionTECH.md(write-tech-spec).
Summary
Section titled “Summary”Every id in the Knowledge Hub ledgers should be a STRING. Today task / backlog-item /
roadmap-theme ids are strings but subtask ids are numbers, and the dependencies[]
element types diverge to match (task deps string[]; subtask deps number[]). That
asymmetry is an arbitrary Taskmaster-convention inheritance, invisible at the point of use,
and it makes agents mis-type subtask ids and deps every session (RC-1). This spec
defines the unified string-id contract the ledger CLI, the vendored schemas, and the
task-view server must converge on; the one-time live-JSON migration that flips the 714
stored subtask ids + 615 stored dependency entries from number to string; and the
sequencing gate that runs that migration exactly once, after the ID-90 server cutover soak
({90.21}) has settled the write path.
The acceptance bar is behavioural: the same ledger commands an agent runs today keep working, but the stored shape, the schema, the auto-id output, and the admission seam all treat ids as strings — eliminating the type-mismatch friction at write time.
Problem
Section titled “Problem”The ledger surface has a stored id-type asymmetry that is friction, not corruption: every mis-typed write is caught at write time by the Zod schema, but the failure is gratuitous because the asymmetry has no semantic justification — it is inherited from the Taskmaster JSON shape KH adopted (KH uses the TM JSON shape, not the TM tool).
Consumers of this surface (the “users” whose perspective this spec adopts):
- Agents and humans invoking the ledger CLI (
scripts/ledger-cli.ts:add-subtask,update-subtask,flip-subtask,append-journal,delete-subtask, plus the dotted-id subcommands) — they supply ids and dependency lists and must not have to remember that subtask ids are numbers while every other id is a string. This is the population that hits RC-1 friction every session. - The vendored Zod schemas in
lib/validation/(task-list-schema.tsand the roadmap / backlog siblings) — the code that reads and re-validates the ledger after every write. These are byte-faithful mirrors of the task-view source and must stay so. - The task-view server (
github.com/liam-jons/task-view, which we own) — itspatch-apply.tsandscoped-serialise.tscarry a relocated copy of the same coercion seam (Number(subtaskIdRaw)), and its vendored schemas round-trip back into KH. - task-view editor consumers — the human opening a ledger in the task-view editor, who sees subtask ids and dependency chips rendered from the stored shape.
Code-intelligence orientation (Inv 2 — cited verbatim)
Section titled “Code-intelligence orientation (Inv 2 — cited verbatim)”gitnexus_query({query: 'ledger subtask id coercion validation', repo: 'knowledge-hub'})
returned one weakly-relevant process (proc_230_get — GET → ParseBody, priority
0.108, the API parseSearchParams flow, NOT the ledger surface) and a set of standalone
definitions rooted in scripts/ledger-cli.ts
(Interface:scripts/ledger-cli.ts:BudgetGate lines 1583–1599,
Interface:scripts/ledger-cli.ts:WarningScope lines 1743–1746,
File:scripts/ledger-cli.ts) plus Function:scripts/ledger-renormalise.ts:parseLedgerDir
(168–172) and :main (174–207). The id-coercion seam surfaces only as standalone
definitions — no named execution flow indexes it — consistent with the CLI being a
single large run() dispatcher rather than a graph of small processes.
gitnexus_context({name: 'coerceSubtaskRecord', repo: 'knowledge-hub'}) — verdict-level
context: symbol Function:scripts/ledger-cli.ts:coerceSubtaskRecord (function-keyword span
lines 2480–2545; JSDoc from 2462); incoming.calls: 1
(Function:scripts/ledger-cli.ts:run); outgoing calls to cliErr and withCreateDefaults;
processes: [] (not indexed into a named flow). The GitNexus single-caller figure
under-counts the real call sites because run() is one giant function — ast-dataflow callers resolves the two exact call sites at scripts/ledger-cli.ts:3044:22
(add-subtask — single) and scripts/ledger-cli.ts:3194:24 (add-subtasks —
bulk; the two deliberately share coerceSubtaskRecord so they stay byte-identical). This
is the RC-1 admission seam: it accepts a string --id, runs Number(record.id), rejects
non-coercible strings with an invalid-id envelope, and stamps the id back as a
number (line 2499) — and does the same number[] coercion for dependencies (lines
2501–2542).
update-subtask does not go through coerceSubtaskRecord — it is a field-editor that
parses its dotted positional via parseDottedSubtaskId and coerces the edited value via
coerceFieldValue('subtask', …) (line 2879). It carries its own Number(subId) cast
sites, which need their own TECH treatment separate from the admission seam:
s.id === Number(subId) in the changed-subtask lookup (line 2891), subId: Number(subId)
in the result payload (line 2897), and recordId: Number(subId) in the budget gate (line
2915) — plus String(subId) already in the field path (line 2881). Under the string-id
contract these Number(subId) casts are deleted and subId flows through as a string (the
field path already stringifies it; the lookups and payload must compare/emit the string).
gitnexus_context({name: 'parseDottedSubtaskId', repo: 'knowledge-hub'}) — verdict-level
context: symbol Function:scripts/ledger-cli.ts:parseDottedSubtaskId (lines
1359–1381); incoming.calls: 1 (run); outgoing call to cliErr; processes: [].
ast-dataflow callers resolves the four exact dotted-id parser call sites at
scripts/ledger-cli.ts:2786:24, :2860:22, :2947:24, and :3667:24 (the
flip-subtask, update-subtask, append-journal, and delete-subtask dotted-id
subcommands inside run). Critically, parseDottedSubtaskId already returns subId as a
STRING (line 1380, arg.slice(dot + 1)); callers do Number(subId) only where the
schema currently demands a number, and otherwise use the string verbatim in fieldPath
arrays (e.g. ['tasks', taskId, 'subtasks', subId, 'status'], line 2810). The dotted-arg
parser surface is therefore already string-native — the format decision (bare vs
dotted) is about the stored id value, not about this parser.
Ground-truth admission seams, schema sites, and migration size (read directly):
- Schema (
lib/validation/task-list-schema.ts): subtaskid: z.number().int().min(1)(line 67); subtaskdependencies: z.array(z.number().int().min(1))(line 84); the sibling-depsuperRefinebuilds aSetof numeric ids and compares numerically (lines 145–159). Task ids are alreadyz.string().regex(BARE_ID_REGEX)(line 101;BARE_ID_REGEX = /^\d+$/,lib/validation/schemas.ts:50) and task deps are alreadyz.array(z.string())(line 111). - CLI admission (
scripts/ledger-cli.ts):coerceSubtaskRecord(function span 2480–2545; JSDoc from 2462) stamps id→number and deps→number[]; thenextIdsubtask branch (714–728) returns a number (Math.max(...ids) + 1) for subtasks while returning a bare-digit string for tasks/themes/items; theupdate-subtask-onlyNumber(subId)consumption sites (2891 / 2897 / 2915) are downstream ofparseDottedSubtaskId. - Server (task-view, vendored mirror): the relocated seam is
patch-apply.ts:248(Number(subtaskIdRaw)) +scoped-serialise.ts:166–178. - Migration size (counted from live
docs/reference/task-list.json, 09/06/2026): 91 tasks, 714 subtask ids, 615 subtask dependency entries → 1,329 number→string conversions in one pass, plus the mirror regen.
The asymmetry is invisible at the point of use precisely because nothing in the command shape signals it —
add-subtask --id 15andadd-task --id 186look identical, but one stores a number and one stores a string. RC-1 is the WHY of this Task: the format that minimises future mis-typing is the format to choose.
Goals / Non-goals
Section titled “Goals / Non-goals”Goals. (a) One canonical id primitive — STRING — across all four ledgers, top-level and nested. (b) Eliminate the subtask-id coercion seam so a mis-typed id is impossible to express, not merely caught. (c) Migrate the live ledger exactly once, against a settled write path, with byte-stability / mirror / round-trip guards green across the flag-day.
Non-goals (scope boundaries — what Tier-2 does NOT cover).
- Task-id, backlog-id, theme-id semantics. Those ids are already strings; this Task does not renumber them, change their auto-id rule, or alter their meaning. Only their type is already correct and stays correct.
- The Tier-1 input-coercion bridge. Explicitly CANCELLED at ratification — no throwaway “accept either type” shim. This Task goes straight to the durable string-only contract.
- Mirror file formats. The markdown mirror regeneration is in scope as a guard (it must stay byte-stable modulo the id-string change), but the mirror’s layout, columns, and rendering are not redesigned.
- task-view editor UI redesign. The editor must keep rendering ids and dependency chips correctly post-migration, but no visual or interaction change is specified here.
- The ID-90 server cutover itself. {90.21} soak, {90.22}/{90.23} retirement, and the
delete-backlogserverIntent gap are separate ID-90 work; this Task only sequences after the soak settles. - A general id-format/validation framework. The only change is
number → stringfor subtask ids and subtask deps, with the existing/^\d+$/-shaped value contract preserved (see inv 3). No UUIDs, no prefixes beyond the bare-vs-dotted decision in inv 2.
Decisions requiring ratification
Section titled “Decisions requiring ratification”Each decision carries a RECOMMENDATION + rationale for Liam to ratify. The numbered Behaviour invariants below are written against the recommended option; if Liam selects a different option, the affected invariants are amended before TECH.
D1 — Subtask id format: bare "15" vs dotted "90.15"
Section titled “D1 — Subtask id format: bare "15" vs dotted "90.15"”RECOMMENDATION: bare "15" (stringified integer, parent-scoped — i.e. exactly today’s
numeric value, only retyped as a string; no resequencing).
Rationale, scored against the five axes the brief names:
- Global uniqueness. Dotted
"90.15"would be globally unique; bare"15"is unique only within its parent Task. But subtask ids are never referenced globally — sibling deps are within-Task (thesuperRefineforcing function), and every CLI subcommand already addresses a subtask via the dottedtaskId.subIdpositional (flip-subtask 90.15 in_progress). The compound address already exists at the command layer; baking the parent into the stored value duplicates it. - Dotted-arg parser surface (
parseDottedSubtaskIdcallers). This is the decisive axis.parseDottedSubtaskId(and its four callers — code-intel above) splits the dotted arg on the first.intotaskId+subId. If the stored id became"90.15", either the parser must producesubId = "90.15"(so the stored value matches) — which breaks the existing['tasks', taskId, 'subtasks', subId, …]field-path lookups that expect the bare sub-key — or every dotted subcommand grows ambiguous parsing (90.15vs a hypothetical90.15.3). Bare"15"leaves the parser, all four call sites, and the field-path walker untouched:subIdstays bare, only its downstreamNumber()cast is deleted. - JSON readability. Bare
"15"nested under task"90"reads naturally ("id": "15"inside task 90’ssubtasks). Dotted"90.15"repeats the parent on every row and reads as redundant once you know the nesting. - Migration size. Bare is a pure
String(n)over 714 ids + 615 deps (mechanical, reversible, diffable). Dotted requires composing${task.id}.${n}per subtask and rewriting everydependencies[]entry to the dotted sibling form — larger diff, and the deps rewrite must resolve each numeric dep to its sibling’s new dotted id, which is more error-prone for no functional gain. - Agent ergonomics (the RC-1 WHY — which format minimises future mis-typing?). Bare
string
"15"is the minimal change from today: agents already type--id 15; the only difference is the stored value is"15"not15, and the schema accepts the string they were already typing. Dotted would force agents to type--id 90.15for a new subtask — redundant (the parent is already implied byadd-subtaskcontext) and a fresh mis-typing surface (wrong parent prefix). Bare minimises mis-typing because the unified rule becomes trivial: “every ledger id is a string of digits; subtask ids restart at 1 per Task, exactly as the dotted command address already implies.”
The “globally-unique bonus” of dotted is real but unused: nothing consumes a globally unique subtask id. The dotted address already lives at the command layer where it is needed; promoting it into the stored value buys nothing and costs parser churn.
D2 — dependencies[] element format
Section titled “D2 — dependencies[] element format”RECOMMENDATION: follows D1 — bare stringified-integer sibling ids (["1", "3"]),
i.e. z.array(z.string().regex(/^\d+$/)), with the sibling-only superRefine rewritten to
compare strings.
Rationale: subtask deps reference siblings only (the §3.3 / A6 forcing function,
enforced by the superRefine). A sibling id under D1 is a bare string, so a dep is a bare
string. This keeps subtask deps type-identical to task deps (string[]) — the very
symmetry this Task exists to create. If D1 chose dotted, deps would be dotted sibling ids;
since D1 recommends bare, deps are bare.
D3 — RC-2 better-errors companion (echo expected record shape on schema-error)
Section titled “D3 — RC-2 better-errors companion (echo expected record shape on schema-error)”RECOMMENDATION: IN SCOPE, as a small companion slice (not throwaway; survives unification).
Rationale: today a wrong-shape write surfaces only as a raw Zod issues array
(scripts/ledger-cli.ts:2280–2281, error: 'schema-error', issues: applied.zodError.issues). The S334 datapoint is concrete: an Orchestrator ran
flip-subtask 90.26 in-progress (hyphen instead of underscore) and got back a raw Zod
issues array instead of the expected in_progress | done | … value list — costing one
retry. That friction is adjacent to RC-1 (both are “the CLI knows the right shape but
won’t tell you”) and is a cheap, durable win: echoing the expected record/enum shape on
schema-error survives the id unification unchanged and reduces exactly the retry loop
this whole Task is about. Recommending IN to land it alongside Tier-2 while the same files
are open. If effort pressure forces a cut, D3 is the safe thing to defer — it is
independent of the id contract and can ship as a follow-up without blocking the migration.
D4 — Migration sequencing + flag-day guards
Section titled “D4 — Migration sequencing + flag-day guards”RECOMMENDATION: one-time migration, gated AFTER {90.21} soak stabilises; run ONCE against the settled write path; byte-stability + mirror-regen + round-trip guards green across the flag-day. (Not contested — restating the ratified sequence as a testable invariant; see inv 10–12.)
Rationale: the ID-90 server cutover relocates the coercion seam into the server
(patch-apply.ts). Migrating the live JSON before the write path is settled risks the
migration running against one write path and being partially re-coerced by another. Gating
after the soak guarantees a single, settled target.
D5 — Backward-compat window: fail-loud vs coerce-with-warning on old-format reads
Section titled “D5 — Backward-compat window: fail-loud vs coerce-with-warning on old-format reads”RECOMMENDATION: FAIL LOUD (no transition window). After the migration commit, a
number-typed subtask id or number[] dep is a hard schema-error, not a silently-coerced
read.
Rationale: this is the explicit anti-pattern the ratification rejected — “no throwaway code.” A coerce-with-warning read is the cancelled Tier-1 bridge in disguise. The migration is a single atomic flag-day commit (schema + live JSON + mirror + server tag move together); there is no period in which both old and new shapes legitimately coexist in the canonical ledger. A stray old-format record after the flag-day is a bug to surface loudly, not a shape to tolerate. The one bounded exception is the migration script’s own input (it must, by definition, read the pre-migration number shape) — that is the migration tool’s contract, not a general read path (see inv 11).
Behaviour
Section titled “Behaviour”Numbered, testable invariants. The Checker maps each acceptance check to one invariant. Invariants are written against the recommended options (D1 bare, D2 bare, D3 in scope, D4 gated-once, D5 fail-loud); if Liam ratifies differently, the affected invariants are amended before TECH.
Unified id type
Section titled “Unified id type”-
Every id stored in any of the four ledgers (
task-list.json,product-backlog.json,product-roadmap.json,product-retros.json) is a JSON string — top-level record ids (task / item / theme) and nested subtask ids alike. No ledger record id is a JSON number anywhere in the canonical files or their mirrors. -
A subtask id is a bare stringified integer scoped to its parent Task. The first subtask of a new Task has id
"1"; auto-id isString(max(existingSubtaskIds) + 1), monotonic and non-gap-filling (a freed id is never reused; gaps left by deletes are not filled). The migration does not resequence existing ids — each existing numeric subtask idnbecomes the stringString(n)in place, preserving its value and any pre-existing gaps. The dottedtaskId.subIdform ("90.15") remains the command-line address only; it is never the stored value of a subtask’sidfield. -
A subtask id string matches
/^\d+$/(one or more digits, no leading sign, no decimal point, no whitespace) — the same value contract as task ids (BARE_ID_REGEX)."0","","15.0","15 ","-1", and"abc"are all rejected at write time with aninvalid-idenvelope.
Dependencies
Section titled “Dependencies”-
A subtask’s
dependencies[]is an array of bare stringified-integer sibling ids (each matching/^\d+$/). It is type-identical to a Task’sdependencies[](string[]). An empty array is the default and is valid. -
The sibling-only dependency constraint is unchanged in meaning and is enforced over string ids: every entry in a subtask’s
dependencies[]must equal theidof some sibling subtask within the same parent Task. A dependency referencing a non-sibling id (including a stringified id that belongs to another Task) is rejected with the same sibling-violation schema error, now phrased over string ids. -
After migration, a
dependencies[]entry that is a JSON number is a hard schema-error — the array element type isstring, nevernumber.
CLI admission seam (RC-1 elimination)
Section titled “CLI admission seam (RC-1 elimination)”-
The subtask admission path accepts a subtask id supplied as a string (e.g.
add-subtask --id 15or--id "15") and stores it as the string"15"with no number round-trip. Supplying a value that violates inv 3 yields theinvalid-idenvelope; supplying a valid digit-string never produces a number-typed stored id. -
The subtask auto-id (
nextIdfor thesubtaskscollection) returns a bare stringified integer ("max(existingIds)+1"computed over the parent Task’s existing subtask ids), consistent with how task / theme / item auto-id already returns a string. It preserves monotonicmax+1semantics (never reuses a freed id; does not fill gaps). -
The dotted-id subcommands continue to accept their existing argument forms unchanged:
flip-subtask,append-journal, anddelete-subtaskaccept both the canonical dottedtaskId.subIdpositional and the legacy space-separated<taskId> <subId>form;update-subtaskis dotted-only (it was already dotted-only before this Task and stays so — no legacy space-separated form is introduced). Across all four, the parsedsubIdis used as a string throughout (field-path keys, warning scopes, result payloads); no subcommand coerces it to a number to address the stored record. Addressing subtask"15"of task"90"via90.15resolves to the same record as before the migration.
One-time live migration + flag-day guards
Section titled “One-time live migration + flag-day guards”-
A one-time migration converts every stored subtask
idand every stored subtaskdependencies[]entry in the live ledgers from number to itsString(...)form, in a single atomic flag-day commit that also lands the updated schemas, the regenerated mirrors, and the bumped task-view tag together. The migration runs exactly once: re-running it against an already-migrated ledger is a no-op (idempotent) — every id is already a string, so there is nothing to convert. -
The migration is gated to run after the ID-90 server cutover soak ({90.21}) stabilises — it executes against the settled (post-cutover) write path, so it is not re-coerced by a concurrently-changing seam. The migration tool is the only code permitted to read the pre-migration number shape; it reads number ids, writes string ids, and is not a general runtime read path.
-
Across the flag-day commit, the existing ledger guards stay green: byte-stability (a re-serialise of the migrated ledger is byte-identical to the committed file modulo the intended id-string change), mirror-regen (the regenerated markdown mirror matches, with subtask ids rendered as strings), and round-trip (read → serialise → read produces an identical document). No guard is disabled to land the migration.
Schema convergence + server round-trip
Section titled “Schema convergence + server round-trip”-
The vendored
lib/validation/*-schema.tsfiles and the task-view source schemas converge on the same string-id shape: subtaskidanddependencies[]element types arestring(digit-constrained) in both, so the vendor-drift check (task-view-vendor-drift.yml) is satisfied by the post-migration vendored copy. The server’s relocated coercion seam (patch-apply.tsNumber(subtaskIdRaw)+scoped-serialise.ts) no longer numifies the subtask id; a patch addressed at subtask"15"resolves and serialises the string id unchanged. -
After the schema converges, the
TASK_VIEW_TAGpin is bumped wherever it is asserted (the three places named in the blast radius), so CI’s vendored-schema parity checks reference the new task-view tag carrying the string-id schema. A stale tag that still expects the number-id schema fails the parity check (this is the guard that the re-vendor happened).
Fail-loud post-migration (no transition window)
Section titled “Fail-loud post-migration (no transition window)”- There is no backward-compatibility window: after the migration commit, a
number-typed subtask id or a
number[]dependency entry read from a canonical ledger is a hardschema-error(fail loud), never silently coerced to a string. The cancelled Tier-1 “accept either type” bridge is not reintroduced as a tolerant read path.
RC-2 better-errors companion (D3 — in scope)
Section titled “RC-2 better-errors companion (D3 — in scope)”- On a
schema-error(e.g. an invalid status value, a number-typed id, a wrong field shape), the CLI error surface echoes the expected record/enum shape alongside (or in place of) the raw Zodissuesarray — e.g. a rejected status value lists the accepted status enum values (in_progress | done | pending | blocked | deferred | cancelled), and a wrong-typed id states the expectedstring of digitsshape. The raw issues remain available, but the caller no longer has to consult a separateschema/--helpsurface to learn the expected shape. (If D3 is deferred at ratification, this invariant moves to a follow-up Task and is struck from the Checker contract.)
Out of scope of every invariant above: task / item / theme id values and semantics (already strings — untouched), mirror layout (only the id-string rendering is guarded), the task-view editor UI, and the ID-90 cutover work itself (this Task only sequences after it). See Goals / Non-goals.
Provenance
Section titled “Provenance”- Ratified strategic decision: S333 dedicated investigation,
docs/continuation-prompts/continuation-prompt-kh-s333-id90-acp1-green-2425-landed-tier2-next.md(§STRATEGIC DECISION, §Root causes RC-1/RC-2, §Tier-2 blast radius). The Tier-1 input-coercion bridge was CANCELLED there in favour of this durable fix. - RC-2 datapoint: S334 — an Orchestrator ran
flip-subtask 90.26 in-progressand received a raw Zod issues array instead of the expected status-value list, costing one retry. This motivates D3 (inv 16). - Sibling-dep forcing function: §3.3 / A6 — subtask deps reference siblings only;
enforced by
TaskSchema.superRefine(lib/validation/task-list-schema.ts:141–159). - Migration size (live, 09/06/2026): 91 tasks, 714 subtask ids, 615 subtask dependency entries = 1,329 number→string conversions + mirror regen.