0015 — Assistant output is self-describing blocks, one per question group
- Status: Proposed
- Date: 2026-08-07
Context
0007 makes the clipboard the only channel between this application and an assistant. A prompt goes out, an interview happens somewhere else, and structured output comes back. 0007 · C4 requires that output to carry a version, so that a prompt generated by an older build produces something the importer either recognises or rejects cleanly rather than half-parses. Nothing recorded what the output looks like.
Two interface decisions taken while #15 was broken into phases narrow what this has to specify. The bridge is off by default and enabled from a page of its own, the way #25 gave backup a page rather than a control under each worksheet. Once on, each question grows a copy button and a paste button.
The second of those is not symmetric with the first. Writing the clipboard is free; reading it needs a permission that Android Chrome prompts for and Safari gates behind its own UI. So the agent page also carries a plain box to paste into, and that box — not the per-question button — is the general mechanism. Anything the reader has in hand must be droppable into it and land in the right place, or a browser that declines the permission loses the feature rather than a shortcut.
That is the requirement that shapes everything below: output has to say what it is, with no surrounding context to help.
Options
O1. Reuse the export envelope from 0009.
Rejected. It already carries a format, a version and a payload keyed by identifier, so the
temptation is obvious. But restore replaces the whole store rather than merging into it
(0009 · C7) and must never partially import (0009 · C4), while assistant output is a partial
merge that must never overwrite silently (0007 · C3). Two opposite semantics behind one
format string is a trap laid for whoever reads the code next, and the refusal machinery is
cheap to have twice.
O2. One document that names its scope — a group, a day, or a whole compass. Rejected. It sounds like the general answer and costs the most: three scope kinds to define, rules for how a day’s scope relates to the groups inside it, and — since a day’s reply plainly contains several things — a list shape at the top level that a later version would have to grow into.
O3. Self-describing blocks, one per question group. Chosen. Everything O2 wanted comes from the smallest unit plus the fact that text can contain more than one block.
Decision
A block answers exactly one question group, and names which. A day’s reply is several blocks in one paste; a compass-level synthesis is more of them. There is no list shape in the format because the pasted text is the list, and no day or compass scope because neither is a thing the store holds — the store holds groups.
That is what makes “an entire day collected and returned in one exchange” an ordinary use of version 1 rather than a future addition. It is worth being careful about the neighbouring argument: 0009 · C6 warns against growing a format with optional fields for consumers that do not exist yet, and its closing line — “format machinery with no consumer gets frozen before anything has tested what it is for” — is a fair objection to any of this being decided before something exercises it. The answer is not that C6 permits it. The answer is that O2 is rejected on its own costs, and choosing the smaller unit is what avoids the machinery rather than an instance of adding it. Everything below that could wait for a consumer is deferred explicitly rather than specified now.
Blocks are found by their content, not by their fence. The importer scans pasted text for
fenced code blocks, parses each as JSON, and keeps the ones whose format matches. Requiring
a particular info string would be requiring an assistant to be reliable about something they
are demonstrably not — asked for ```life-compass they will write ```json — and the
scan is what lets several blocks, and the prose between them, arrive together.
Amended 2026-08-09, after the first device test. “Not by their fence” was implemented as
“any info string will do”, and that was only the smaller half. A fence is Markdown source:
copying a rendered chat message gives you the JSON without the backticks, because the
backticks were never on screen. The ordinary way a reader copies a reply therefore produced
“there is nothing from an assistant in that” on the first real attempt. The importer now scans
for balanced {…} regions and lets the fences be whatever they are — respecting strings, so a
brace inside a dictated answer does not throw the count off, and finding a fenced block for
free because the backticks sit outside the braces. A reply that runs out mid-answer is refused
as cut off rather than partly accepted.
One consequence follows, and is the reason this is an amendment rather than a footnote: an object carrying the format is now found wherever it sits, including inside an assistant’s prose explaining the shape. C8a’s example group is what makes that safe — it names a group the schema does not contain, so a restated example cannot import. It is ignored — always, since C8b — and a paste holding nothing else then refuses for the reason that is actually true of it: every block in it named the example. An assistant that illustrates with a REAL group instead would be importable, and the confirmation surface is what stands between that and the reader’s answers.
Anything that is not a matching block is ignored, and a paste with no matching block at all
is refused. Assistants quote, illustrate and explain; a fenced block that will not parse, or
parses without this format, is ordinary noise rather than an error to report. What must not
happen is silence: a paste that yields nothing says so, rather than appearing to succeed.
A block, in full. This list is authoritative:
format— the string"life-compass/agent-answers". Distinct from"life-compass/answers", which is a backup and means “replace everything”.version— a number,1in this record. It is the BLOCK’s shape and moves only when this list changes; a schema edit, a new worksheet or a retired question are ordinary content and do not touch it. Same rule 0009 gives the envelope.group— the question’s identifier, e.g.day1.chapters. Declared in TypeScript rather than derived from the prose around it (0004) and frozen once used (0011), which is what makes it safe to put in a document that leaves the application and comes back later.- The answers, in exactly one of three shapes, chosen by the question’s kind:
answer— a string, for asingle.fields— identifier segment to string, for agroupor asentence. A sentence’s segments are its gap names, which are already its field identifiers.instances— an ordered array, for arepeat. Each entry is{ fields }, optionally carrying anid(see below).
Every value is a string. Anything else — a number, null, a nested object, an array where a
string belongs — is refused rather than coerced, as import.ts already does for the envelope
and for the reason its comment gives: sampling one value proves nothing about the rest.
A block carrying more than one of answer, fields and instances, or none, is refused.
Keys this list does not name are ignored, which is what makes a later version cheap to add.
An example, complete — and deliberately more than one block, because that is the ordinary shape rather than the advanced one:
{
"format": "life-compass/agent-answers",
"version": 1,
"group": "day1.chapters",
"instances": [
{ "id": "5f1c8e2a-…", "fields": { "title": "The garage-band years", "learned": "…" } },
{ "fields": { "title": "Leaving home", "defined_by": "…", "learned": "…" } }
]
}
{
"format": "life-compass/agent-answers",
"version": 1,
"group": "day1.patterns",
"answer": "The work that lands is the work with a person on the other end of it."
}
Amended 2026-08-10, with #82. The example shows two blocks because a reply routinely is two. A reader works through a worksheet in numbered items, not in questions — day 4 asks five things and renders fourteen answer controls — so a prompt covers one numbered item and asks for one block per question in it. Nothing in the format moves: this is the several-block paste C1 already describes, arriving one numbered item at a time rather than a whole day at once. What had to move is the outbound half, which asked for exactly one block and showed exactly one example. The two halves of this record are only ever safe when they are written from one reading of it, and the ceiling above is what happens when they are not.
What the format leaving unchanged does not cover is the blast radius, and this record
should not pretend otherwise. A block that is MALFORMED refuses the whole paste — readBlocks
returns on the first bad block, and C5 warns that all-or-nothing is not free on this side — so
one bad block now costs a numbered item’s whole interview rather than one question’s. That is
the price of collecting more per exchange, and it rises again the day a whole day is collected
at once. It is accepted rather than solved: partial acceptance of a malformed block would mean
telling a reader some of their reply landed and leaving them to work out which, which is the
trade import.ts already settled the other way.
The one block that is set aside rather than read or refused is C8a’s worked example, and that is not an exception to the paragraph above: nothing about it can be imported, so nothing about it can partly land. What C8b adds is that being set aside is now reported when the block was carrying the reader’s words rather than the example’s.
The block does not restate the question’s kind. questions.json already says it, and two
copies of one fact can disagree — the mistake 0009 · C6 records, and the one 0013 records in
keys.ts where readOrder and answerKey disagreed about what a usable identifier was. The
importer looks the group up and knows which shape to expect; a block whose shape does not
match its group’s kind is refused.
That lookup needs the schema on the client, which it does not have today. import.ts
says so as settled fact — “the client has no copy of the question set” — and identifies
instance orders by shape for that reason. It cannot be fixed by fetching: 0007 · C1 keeps
connect-src 'none', which forbids the page requesting /questions.json at all. So the
schema reaches the client as a module in the client tier
(0012), loaded as script rather than
fetched as data. Recorded here because both halves of the bridge depend on it and neither
issue currently says so; the JSON stays for anything outside the page.
Omitting a field means “leave it alone”. An empty value is refused. An assistant that has
nothing for a field leaves it out. The distinction matters more here than it looks: store.ts
deletes a key when its value is "" — “an empty field is an absent answer, not an empty one”
— so permitting an empty string would hand an assistant a delete primitive through a format
that says below it has none. import.ts refuses empty payload values in the envelope for the
same reason.
Instance identity travels in the prompt and comes back echoed — but a supplied identifier is never adopted as a key. An assistant cannot invent the identifiers 0013 mints, and position must not become identity: that is the failure 0011 was written to prevent and 0013 rejected as O1. So the prompt carries the identifiers of the instances that exist, and the block returns them per instance — as a reference, not as a proposal:
- An
idnaming an instance already in this group’s stored order is an answer to that instance. - Any other entry — an unknown
id, anidbelonging to another group, a malformed one, or noidat all — is a new instance, minted here withnewInstanceId(). What the block supplied is never written.
Minting rather than adopting is what makes the rest of this safe. Nothing arriving from
outside the application can become a storage key, so a hostile, duplicated or reused
identifier is not a case to validate — it is a case that cannot arise. keys.ts is left as
the only path into the identifier format, which is what 0013 asked for.
Carrying identifiers outward costs nothing against 0007 · 2, which defaults prior answers to off. An instance identifier is structure, not content — a UUID reveals nothing about what somebody wrote. What it does reveal is how many instances exist, which is disclosure of a kind however small, and 0007 · 1 requires the preview to show it rather than hide it.
New instances are bounded by the number of slots the page renders, and exceeding it is
refused. This is the rule most easily got wrong. min is the count the build prints;
max is what a reader may add once an add-another control exists, and 0013 · C6 records
that it does not. 0013 · Q2 is explicit about the consequence of an order longer than the slot
count: it “is accepted without comment, and the answers under its extra instances simply never
appear”. Day 1’s chapters are min 5 / max 8, so bounding at max would let an assistant asked
for “5–8 chapters” return eight and store three chapters of dictated words that nothing on the
page will ever show.
Superseded 2026-08-09 — see the amendment below. The ceiling was the rendered slot count
rather than max, standing in for Q2 until #74 answered it.
Amended 2026-08-09, when #74’s first half landed. The ceiling is now max. The sheet
renders every instance the range allows and reveals the ones a stored order names, so eight
chapters have somewhere to go and the reason for the smaller bound is gone. The prompt asks
for the range rather than a fixed count, because how many falls inside it was always the
reader’s decision and answering it for them was the workaround, not the design.
What has NOT changed is that excess is refused rather than truncated. Past max there is
still nothing to display, and truncating silently is still the loss this bound exists to
prevent. The prompt generator asks for the worksheet’s range, so the refusal is a backstop
rather than an ordinary outcome.
Fewer instances than the page renders is allowed and already has a defined behaviour: 0013 · Q2 adopts a short order for the slots it covers, and the slots past its end refuse writes and say so.
A block never reorders what exists. The order is the reader’s. New instances append, in the order the block gives them, after every existing instance. Where a paste holds several blocks, they are processed in the order they appear in the text.
Refusing a version. version must be a number, an integer, at least 1, and no greater
than this build’s. readEnvelope refuses all four cases and its comment says why the last two
travel together: “refusing the future without refusing the impossible would let it through”. A
block from an older version is a problem for the day a second version exists, and that day
arrives with blocks already in the wild to migrate from.
Nothing records which question registry a block was generated against. 0009 · C6 removed exactly that field from the export envelope and explained why: a digest of a set cannot answer a question about an individual key. 0011’s registry answers it per key and permanently — that is the registry described in 0011’s Decision, kept complete by 0011 · C2’s rule that retired entries accumulate forever.
A block pasted at a question it does not answer is routed to the group it names. The general paste box has no “current question” at all, so routing has to exist for that path regardless; making the per-question button behave differently would be a second code path for one behaviour, which is what #68 sets out to avoid. The reviewing surface for a routed block appears at the group it belongs to, and the reader is told that is where it went — routing somewhere silently is the part that would surprise.
Deliberately not in the contract:
- Prose around the block. Assistants explain themselves. That is ordinary, and the scan discards it rather than treating it as a defect.
- Checklists. They are readiness ticks the reader works through, not questions an
assistant answers on their behalf. A block naming a
checklistgroup is refused, and the consequence belongs upstream: those groups get no copy button either (C8). - Anything that deletes. There is no way to express “remove this answer”, which is what the refusal of empty values above is enforcing rather than merely asserting.
Rejected while writing this: short per-exchange handles (a1, a2) mapped back to
identifiers by a table the application holds. It would keep 0007 · 1’s preview readable, and
position would still never be the key because the app owns the mapping. Rejected because the
table is state that has to survive the round trip — through a browser restart, an eviction, or
a reply pasted a week later — and inventing a second thing to persist to make the first one
prettier is a poor trade. Revisit if the preview turns out to be genuinely unreadable on a
phone.
Consequences
C1. A whole day comes back in one paste at version 1, and so does a compass-level synthesis. Neither needs a format change, which is the point of choosing the unit small.
C2. The general paste box accepts anything, so the per-question paste button is strictly additive. A browser that never grants clipboard read costs its reader a tap, not a feature — which matters because 0007’s argument is about a choice being real, and a feature available only where a permission is granted is a choice somebody else made.
C3. Prompts carry instance identifiers even when no answers travel, so the reader sees UUIDs in the preview 0007 · 1 requires. Better that than a preview which is not what is sent.
C4. The schema has to reach the client as a module before either half of the bridge can validate anything. That is a prerequisite for #67 and #68, not a detail of them.
C4a. Added 2026-08-08. Emitting it straight into dist does not work, and the first
attempt did exactly that: a declaration file satisfied tsc while the only real JavaScript
lived in the output, so nothing that runs from source — every test — could resolve the import
at all. Nothing imported it yet, so nothing noticed. It has been removed again rather than
replaced, because a module the client cannot use is worth less than nothing while it is also
36 kB of precache; it returns with its first consumer, generated somewhere Node, the
typechecker and the browser all read one file.
The lesson that did ship is not about where the file goes. It is that a generated file is
only safe where something asserts its absence is a failure. Hooks cannot be that — node --run, a direct tsc, npm ci --ignore-scripts and a symlinked checkout all skip them —
so checkSpecifiers in build/client.ts refuses to emit a client module graph containing an
import nothing satisfies. Before it, deleting the generated file produced a successful build,
a shipped site whose client was dead, and a precache manifest that omitted the missing file so
cache.addAll succeeded and the deploy smoke test read one fewer URL. Every gate passed.
C5. Reading a block cannot write, structurally, as in import.ts — the function that
reads one has no store to reach. Applying several blocks across several groups is a different
question: the store’s primitives are a single-key write, a per-group claim and a
destructive replaceAll reserved to restore, so “never partially imports” is not free here
the way 0009 · C4 made it free for the envelope. #68 has to establish it rather than inherit
it, and this record does not pretend otherwise.
C6. The refusals this needs, as a list to build against: no matching block found; every
block naming the example group, so that none of them could be read (added 2026-08-10, C8b); a shape
that does not match its group’s kind, or more than one shape present; a group not in the
schema; a checklist group; a version that is absent, not a number, not an integer, below 1, or
newer than this build; a value that is not a string; an empty value; a field identifier the
question does not define; more instances than the page renders. Each says what was wrong and
that nothing on the device changed, as the existing Refusal union does.
C7. Two formats now exist that both carry answers, versioned separately on purpose, because the day one needs to move is not the day the other does. Whoever changes one must decide about the other deliberately.
C8. The prompt generator inherits three constraints from this record: it asks for the rendered slot count rather than the worksheet’s range; it offers no copy control on checklist groups; and it must not embed an example block in this format, because a reader who pastes the prompt back — a plausible mis-tap moments after copying it — would otherwise be handing the importer a correctly-named block that is not a reply.
None of the three still reads as written. The first was reversed by the ceiling amendment above when #74 landed — the prompt asks for the worksheet’s range. The second is narrowed by C8c, which keeps it for a checklist standing alone and lifts it for an item containing one. The third is relaxed by C8a. Left in place because the consequences that amend them argue against what it says, and a list edited to agree with them would hide that anything was ever decided differently.
C8a. Added 2026-08-08. C8’s third constraint — no example block in the contract format
— is relaxed to say how rather than forbid. Assistants follow an example far more reliably
than a description of JSON in prose, and a prompt that produces malformed replies fails the
reader more often than a mis-tap does. So the prompt carries a complete example whose group
is a name the schema does not contain. It teaches the shape, and a reader who pastes the
prompt back gets a loud refusal — a group not in the schema is already a refusal kind (C6) —
rather than an example silently imported as their answers. The safeguard C8 asked for is kept;
the cost to output quality is not.
C8b. Added 2026-08-10. A prompt covering a numbered item carries one worked example per question in it, and every one of them names C8a’s example group. The real identifier is stated in the prose above each example, which keeps C8’s safeguard: nothing in the prompt’s own structure is a block naming a question the schema holds.
Two consequences follow, and the second is why this is a consequence rather than a footnote.
Which refusal a mis-tap gets. readBlocks ignores an example-group block, so a prompt pasted
back is nothing but example blocks and nothing survives the ignoring. That used to
report “there is nothing from an assistant in that”, which is half true — there plainly is,
it just names the placeholder throughout. There is now a refusal for it that says so, and it
has to serve two pastes the text cannot tell apart: the prompt itself, and a reply that
substituted no groups at all — and it is now the answer for both, however many questions the
prompt covered. Ignoring the example only when something real sat beside it split that in two:
a single-question prompt pasted back reached the block reader and refused as unknown-group,
naming the placeholder and suggesting “the identifier may have been altered” — advice about a
fault that is not the reader’s — while a several-question one was skipped down to nothing and
refused as “there is nothing from an assistant in that”, which is half true of a document that
plainly is from one.
A reply that substitutes SOME of them. This is the one that had to be designed rather than noted. An assistant that replaces three groups of four leaves an answer wearing the placeholder, and ignoring it silently imported three questions and dropped one while telling the reader their reply had landed — the loss 0008 calls the worst available to this application, arriving through the machinery that exists to make a verbose reply usable. Refusing the paste instead would take the three good answers away with it. So the ignoring is counted — but only where the block carried the reader’s words. An echo of the example carries the example’s own placeholder text and nothing else, which is what tells the two apart; counting those as well would put a warning about lost answers over every thorough reply, and a warning that fires on the ordinary case is one nobody reads on the day it is true. The count then follows the reading to each surface that can be the last thing the reader is told — the confirmation, a refused plan, the nothing-to-change notice, and the line that confirms the save. The paths it does not reach are the ones where the reply is still in the box and nothing was written: a store that would not open, a save that failed, and a paste refused before any of it was read. C1’s “a whole day in one paste” carries this cost at every scale, and it is paid where the reader can see it.
None of this is what makes the mis-tap safe. That is worth stating plainly because this
record implied otherwise. The importer finds blocks by balanced braces, not by fences (the
2026-08-09 amendment above), and prompt.ts was still neutralising a reader’s stored answers
for backticks alone — so an answer carrying a one-line contract object needed no fence to
become a real candidate, and a prompt built from it imported into whatever group that object
named. Fixed with this slice: braces in a reader’s own words are rounded to parentheses on the
way into a prompt, the same trade the backtick rule already made. The lesson is the one the
amendment itself records — when the scanner changed, everything that had been written to defeat
the old scanner needed re-reading, and only one half of it was.
C8c. Added 2026-08-10. Checklists are dropped from a numbered item’s prompt rather than refusing it. C8’s second constraint — no copy control on a checklist group — still holds for a checklist on its own; what it did not anticipate is a control covering an ITEM that contains one, where refusing would cost the reader every question beside it.
No numbered item in the workbook currently holds a checklist beside an answerable question:
the two that hold one hold nothing else. So this rule is written for a page that does not exist
yet, which 0009 · C6 warns against — and it is admitted here rather than dressed as a live
constraint. What justifies it anyway is that the alternative is not “no rule” but “refuse”, and
the day a ## heading gains a question beside its readiness ticks, refusing would silently
cost the reader that question. An item holding nothing but a checklist is still refused by
name, which is what tells the control not to exist there at all.
C9. A group with no answers stored still has a usable prompt: no identifiers travel, and every instance comes back new. The first exchange with an assistant is the common case, not the edge one, and it must not be the awkward path.