LIFE COMPASS

0015 — Assistant output is self-describing blocks, one per question group

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:

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:

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:

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.