0013 — Instance identity for slots the build renders
- Status: Accepted
- Date: 2026-08-02
Context
0011 settled how a repeated
group is stored: an ordered array of instances, each with an identifier “generated by
crypto.randomUUID() when the reader adds it”. Order lives in the array, so
reordering, inserting and deleting are all safe and an orphan is traceable to a specific
chapter rather than to “slot 3”.
That sentence assumes a form the reader builds. The site does not have one. The build
renders a fixed number of slots from the schema’s min, nobody can add or remove one
yet, and every slot of a group therefore carries the same data-field: all five of Day
1’s chapters render data-field="day1.chapters.title".
The scale is why this cannot wait for the add-another control. 334 of the 447 blanks sit inside repeats, and 264 of them collide on a key another blank already uses — under a flat identifier-keyed store, chapter five’s title would save over chapter one’s. The storage layer (#24) shipped deliberately narrowed to single-valued fields because of it.
There is no reader act to hang an identifier on, and there is no second chance: 0011 · C2 records that migration code is permanent, and a format is only free to change before anyone has answers.
Options
O1. Position is the identity. day1.chapters.3.title. Every blank gets a unique key
today and the binding is trivial. Rejected. The first delete or reorder shifts every
answer below it onto a neighbour’s key — the precise failure 0011 froze identifiers to
prevent, discovered on data a reader already owns.
O2. Mint identifiers when the reader adds an instance, as 0011 says. Rejected as written, not in substance: there is nothing to hang it on until the control exists, and the control is not what unblocks storage.
O3. Mint on first write, materialising the whole slot set. Chosen. The first time any field of a group is written, the client mints an identifier for every slot the page is showing and stores the order. From then on the group has instances, and the add-another control — when it arrives — appends to a list that already exists.
Decision
A slot is a position on the page; an instance is a thing with an identity. The build renders slots and the client materialises instances.
The markup carries the slot. Every rendered instance is one element with
data-instance="<slot index>", counting from zero — a <li> for a row or line repeat, a
wrapping <div class="q-instance"> for a section repeat, which previously had no element
of its own. data-field is unchanged and still holds the frozen identifier from 0011, so
a blank’s DOM address is the pair (data-instance on the nearest ancestor, data-field).
Zero-based because it indexes the stored order array. The visible numbering beside it (“Value 1”) and the heading anchor stay one-based, because those are for a reader.
Materialising is all-or-nothing per group. On the first write to any field of a group, identifiers are minted for every slot then rendered and the order is stored. A half-materialised group would have to decide where a later instance belongs in the array, and the answer would be its slot index — which is the positional identity O1 was rejected for, arrived at sideways.
Two key shapes, told apart by the schema. A bare group identifier holds the order; a longer key holds an answer:
day1.chapters -> ["5f1c…","9a34…","c701…","4b19…","e8a2…"]
day1.chapters.5f1c….title -> "The garage-band years"
Reading such a key back means finding where the group identifier ends. Q6 below is what makes that possible; nothing here relies on it yet, because nothing here reads a key back.
checkSchema does now refuse two questions that produce the same identifier — a repeat
day1.chapters alongside a group day1 with a field chapters. That is a collision
today rather than a property of the future format: the identifier is the storage key, so
it is two questions writing over each other, and nothing else catches it.
What is built
This landed in two pull requests, which is why the record sat Proposed through the first of them.
The markup half came first: the slot markers, the q-instance wrapper, a check that
two questions cannot produce one identifier, checkSchema’s refusal of an identifier
containing an empty segment, and tests at two grains — every repeat in the schema checked
for its slot numbering, each slot marked exactly once counting from zero, while
containment, that every blank sits inside the element carrying its slot, is asserted
against a fixture for each repeat shape, including a multi-slot instance and the row
holding a single field.
The client half followed with the DOM binding: the key encoding (src/client/keys.ts),
minting, materialisation, and order-to-slot reconciliation (src/client/fields.ts).
Holding the encoding back until something consumed it was the right call and is worth
recording as a practice. A first attempt wrote the encoder alone, and review found that
nearly every defect in it — two functions disagreeing about a valid identifier, and a
data-field that carries the full identifier where the encoder wanted a bare segment —
existed because nothing consumed it. A format that cannot be changed later should not be
frozen by a pull request containing nothing that exercises it.
Still not built: the decoder. One was written for this half and removed again, for the same reason and by the same argument. Nothing on the page reads a key back — the binding derives every address from the markup — so its only exercise was its own test, and its correctness rests on Q6, which the build does not enforce. It belongs with 0011’s rename-on-read (Q4), which is the first thing that will actually need it.
Questions the binding forced
Each of these was raised by the binding. Q1 and Q3 are answered below because the binding could not ship without them; the rest are still open, and 0011’s registry has to change before Q6 can be.
Q1. Atomicity. Answered. Store grew one operation: claim(guard, entries) reads
the guard and writes every entry inside a single readwrite transaction, returning false
if the guard is already set. Reading in one transaction and writing in the next leaves a
gap, and that gap is exactly where a second tab lands — both would read “absent” and both
would write. The loser does not retry with its own identifiers; it re-reads the order and
adopts the winner’s, because whichever order landed second would otherwise strand the
other tab’s answers under identifiers nothing references.
Q2. min in both directions. Still open. Growing is no longer silent; shrinking
still is. Shrinking leaves stored answers with no slot to show them in, and 0011’s orphan
surface will not catch them because their identifiers are still active. Growing leaves a
rendered slot with no instance. Both are reachable by an ordinary worksheet edit against
readers who have already answered, and neither has an answer here.
What the binding has is a floor under the growing half. A stored order with fewer instances than the page has slots is adopted for the slots it covers — those still show their answers and still save — while the slots past its end refuse and say so. Discarding those keystrokes silently was the first behaviour; refusing the whole group was the second, and that hid the reader’s existing answers behind a banner claiming they were untouched.
Shrinking is untouched and still silent: an order LONGER than the slot count is accepted without comment, and the answers under its extra instances simply never appear. Neither half is answered here; one of them just stopped costing the reader words.
Q3. Corrupt or partial orders. Answered. readOrder returns three things rather
than two — absent, unreadable, and an order — and only absent may materialise, so a
corrupt order is never minted over. Anything unusable makes the whole order
unreadable rather than being filtered out: dropping entries shortens the list, every later
instance moves up a slot, and the next write persists the shortened list, permanently
orphaning the answers beneath what was dropped. A duplicate identifier counts as
unreadable too, because two slots resolving to one identifier is the collision this whole
scheme exists to prevent. An unreadable group keeps its stored bytes untouched, refuses
further writes, and tells the reader.
Q4. Migration reach. 0011 rewrites stored identifiers matching a retired entry.
day1.chapters.<uuid>.title splices the instance into the middle of
day1.chapters.title, so exact matching cannot reach any of the 334 blanks inside
repeats. Either the rewrite becomes prefix-and-suffix aware, or 0011’s mechanism has to
say it does not cover repeats.
Q5. Orphan surface. 0011 · C3 puts orphans in the export envelope alongside live answers. An answer whose instance is absent from the order is, to a flat string map, indistinguishable from a live one — finding it needs the decoder Q4 also needs. The same gap swallows a retired group: once it leaves the schema nothing records that its value was an instance list, so 0011’s orphan surface would present JSON to a reader as their own prose.
Q6. What makes a key readable back. Splitting day1.chapters.<uuid>.title into its
parts means knowing where the group identifier ends, which requires that no identifier is
a dotted prefix of one belonging to a different question, and that no field or item id
contains a dot. A question identifier is dotted by construction, so a dot-free rule can
only be asked of the pieces joined onto it — the field and item ids — where a stray dot
would move the boundary a decoder finds. Both hold across today’s 254 identifiers and
neither is enforced. Enforcing the first properly needs to see retired entries —
0011 · C2 keeps them forever and answers written under them survive — and a registry entry records an
identifier’s own lifecycle (its id, status, retirement date, replacement, and a note),
not which question produced it, so it cannot tell a group legitimately prefixing
its own fields (141 such pairs today, by design) from a real collision. Freezing this
means deciding what the registry stores, which is a change to 0011 rather than to this
record.
Consequences
C1. Single-valued questions are untouched. Their key stays the frozen identifier, and
the store’s existing contract already covers them — which is why the storage slice could
ship narrowed rather than blocked. Their blanks have no data-instance ancestor at all,
so a binding reading closest("[data-instance]") gets null, and null means single-valued
rather than a bug.
C2. The store’s values stop being uniformly prose once the encoding lands: exactly one key per repeat group will hold JSON. While a group is in the schema nothing needs a type tag, because the schema says which identifiers are groups — but an export (#25) has to know it too, and a retired group has no schema entry at all, which is the second half of Q5.
C3. Order becomes data rather than position. Once materialised, the array decides which slot a stored answer appears in, so the binding has to read the order before it reads any answer.
C4. min acquires a second job — the number of instances a first-time reader
materialises — alongside “how many slots to print”. #24 already noted it doing two jobs;
Q2 is where that gets resolved rather than noted again.
C5. This record changes 0011’s storage shape rather than keeping it: 0011 stores each instance’s values inside one array per group, and this stores the order under the group identifier with every answer under its own key. #24 requires saving at field granularity so an interrupted dictation resumes without re-speaking anything, and a single JSON value per group rewrites every chapter on every keystroke. Recorded on 0011 as C8.
C6. This is still not the add-another control. It makes one possible without a migration, which is the point of doing it before answers exist rather than after.