0011 — Question identifiers are frozen, registered, and never derived
- Status: Proposed
- Date: 2026-08-01
Context
Question identifiers are the join between four things: the question definitions in TypeScript (0004), the answers in the browser’s storage, the export envelope (0009), and the contract an assistant is asked to emit (0007). Change an identifier and an answer stops being findable.
Two things make this harder than the equivalent decision in a server-backed app.
There is nowhere to run a migration. Answers live only on the reader’s device. A migration is not something applied once to a database and then deleted — it is client code that has to keep working indefinitely, because the next person to open the app may arrive with data written under a schema three years old and none of the intervening versions applied.
Dormancy is the intended lifecycle, not an edge case. 0003 states plainly that this project is expected to be deployed and left alone for years. So “very old data turns up” is the normal path through this code, and it argues for making renames rare and deliberate rather than cheap and frequent.
The part that is easy to miss
Several worksheets repeat a group: five chapters on Day 1, five values on Day 2, four changes on Day 5 — and Day 1’s own instructions say five to eight, so the count is the reader’s to choose, not a fixed template.
If the storage key for those is positional — chapters[2].title — then deleting the
first chapter shifts every later answer up by one. The reader’s writing about their third
chapter silently becomes their writing about their second. Nothing errors, the app
carries on, and the corruption is discovered late or never.
Array indices are positional identity: the same failure mode that ruled out deriving identifiers from a file’s structure, reappearing one level down where it is easier to overlook.
Options
O1. Derive identifiers from content — from headings, file paths, or ordinal position. Rejected. Free to author and immediately wrong: editing a heading, renaming a file, or inserting a question silently renames the identifier and orphans every answer under it. The failure is invisible at the moment it is caused.
O2. Opaque generated identifiers — q_7f3a, assigned once and never meaningful.
Rejected. Maximally stable, and unreadable exactly where readability is load-bearing:
these identifiers appear in the prompt handed to an assistant and in the JSON it hands
back (0007). An assistant given q_7f3a cannot produce
anything sensible, and neither can a human reading a pasted reply to see why an import
went wrong.
O3. Semantic identifiers, frozen at birth and enforced by a registry. Chosen. Hand-authored and readable, but never computed from anything, so no edit to prose or structure can change one by accident.
Decision
Shape
An identifier is a dotted path of hand-chosen segments: day1.chapters.title.
The leading segment is a namespace chosen once per worksheet — day1, rig1, anchor —
and is not the filename. Deriving it from the path would reintroduce O1 through the
back door, renaming every identifier on a page the moment the file moves.
Repeat groups carry instance identity
A repeated group is stored as an ordered array of instances, each with an identifier
generated by crypto.randomUUID() when the reader adds it:
day1.chapters -> [
{ instance: "5f1c…", values: { title: "…", defined_by: "…", learned: "…" } },
{ instance: "9a34…", values: { title: "…", defined_by: "…", learned: "…" } },
]
The group (day1.chapters) and the field (title) come from the schema; the instance
comes from the reader’s own act of adding one. Order lives in the array, so instance
identifiers carry no ordering and need not be sortable. Reordering, inserting, and
deleting are all safe, and an orphan is traceable to a specific chapter rather than to
“slot 3”.
A registry makes freezing enforceable
Every identifier the project has ever used is listed in one committed file, each marked
active or retired. A retired entry may name its replacement.
The build fails when a question uses an identifier absent from the registry, and when a
registered active identifier no longer appears in any question. Renaming therefore
becomes two deliberate acts — retire the old entry, register the new one — rather than
something achievable by editing a single line.
This is the same move that made a sidecar schema safe in 0004: the objection to a convention is that people forget it, and a build that refuses is not relying on memory.
Migrations are content-addressed, not versioned
A retired entry naming a replacement is the migration. On reading stored data, any identifier matching a retired entry is rewritten to its replacement; chains are applied in order until nothing matches.
Nothing needs to record which schema version data was written under, because applying a rename to data that does not contain the old identifier is a no-op. Migration is idempotent and safe to run on every read, which removes an entire category of “what version is this?” bookkeeping that would otherwise have to survive years of dormancy.
Orphans are kept and surfaced
An answer whose identifier matches no active question and no migration is never deleted. It is retained in storage and in exports, and surfaced somewhere the reader can find and copy it.
This content is irreplaceable personal writing that exists nowhere else. Silently discarding it because a schema changed would be the worst thing this application could do, and it is a failure the reader could neither predict nor detect.
Consequences
C1. Renaming an identifier is deliberately awkward. That is the point: the cost is paid by whoever is editing the schema, who can see it, rather than by a reader whose answers vanished, who cannot.
C2. Migration code is permanent. The retired entries accumulate forever and none can be deleted, because there is no floor on how old arriving data might be. Expect the registry to become the longest file in the schema, and treat that as working as intended.
C3. The orphan surface is real UI to design and build, not a log line — per 0008 it also needs to survive export, so orphans belong in the envelope alongside live answers.
C4. The assistant contract inherits instance identity. A reply that fills in existing chapters must reference their instance identifiers, so the generated prompt has to carry them; a reply proposing new chapters cannot invent them, so the importer assigns them on acceptance. This lands on the field-level review surface 0007 · C3 already requires, but it is more work than “parse the JSON and merge”.
C5. Identifiers are visible to readers in exports and in assistant prompts. They should read as descriptions rather than as internal keys, because a reader will see them at exactly the moment something has gone wrong and they are trying to understand their own file.
C6. crypto.randomUUID() requires a secure context. The site is HTTPS, so this holds —
but it rules out opening the built dist/ over file:// and expecting the workbook to
function, which is worth knowing before someone tries it as a way to use the app offline.