|
| 1 | +# Form deltas in the UI IR |
| 2 | + |
| 3 | +Plan of record for the work Gluck specified on 2026-08-13. Written by aria |
| 4 | +b2b0c543 (session 4) for the role holder who takes it; I am not that holder, |
| 5 | +so this is a plan and not a claim about built code. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 0. What this is |
| 10 | + |
| 11 | +A reader of a transcript cannot see WHY a turn did what it did, because the |
| 12 | +state the model was shown is invisible: the bound board's keys, a studied |
| 13 | +form's transitions, the moment a role was recast. The provider sees all of |
| 14 | +it as system-reminders; the client sees none of it. |
| 15 | + |
| 16 | +So: **every UI IR node gains an optional `formDeltas` property**, the client |
| 17 | +draws it dimly, and two of those deltas get sentences of their own. |
| 18 | + |
| 19 | +## 1. The shape |
| 20 | + |
| 21 | +On `livedoc.Node` and on the turn's inquiry (`aria.Turn`), optional and |
| 22 | +omitted when empty: |
| 23 | + |
| 24 | +```go |
| 25 | +// FormDeltas is the form state a reader would have needed to understand |
| 26 | +// this node: what changed, on which form, and what kind of form it was. |
| 27 | +// Keyed "<formid>.<path>" so a client can group by form id without |
| 28 | +// parsing a nested shape, and sort stably. |
| 29 | +FormDeltas map[string]FormDelta `json:"formDeltas,omitempty"` |
| 30 | + |
| 31 | +type FormDelta struct { |
| 32 | + Value json.RawMessage `json:"value,omitempty"` // as seen HERE; absent on removal |
| 33 | + Kind FormKind `json:"kind"` // bound | studied | role |
| 34 | + Event FormEvent `json:"event,omitempty"` // set | removed | deleted |
| 35 | + Form string `json:"form"` // the id, unsplit, for grouping |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +`Kind` is `bound`, `studied`, or `role` — a form carrying `target-aria` is a |
| 40 | +role, and that is decided server-side so three clients do not each invent the |
| 41 | +predicate. `Event` distinguishes a key REMOVED from the whole form DELETED |
| 42 | +(`system.libretto.alive: false` on the copy), because a reader needs to know |
| 43 | +the difference between "the brief was cleared" and "the brief is gone". |
| 44 | + |
| 45 | +**Tolerate it on every node type.** Gluck asked whether a fork at a thinking |
| 46 | +node would need it; the answer is to give it to all of them rather than |
| 47 | +maintain a list of which nodes may carry state. It is `omitempty`; a node |
| 48 | +without deltas costs one absent field. |
| 49 | + |
| 50 | +## 2. Where it is assembled — NOT from the provider cache |
| 51 | + |
| 52 | +**The hub assembles this from the store, by the same cursor arithmetic the |
| 53 | +projection does, and never from the provider's translated bytes.** This is |
| 54 | +the load-bearing constraint and the reason to write it down: |
| 55 | + |
| 56 | +- the provider cache holds RENDERED bytes for one dialect, per LT, keyed by a |
| 57 | + fingerprint. Deriving UI state from it makes the UI a function of which |
| 58 | + provider last spoke, and of whether a record happened to be cached. |
| 59 | +- it is also the layer that has twice made a wrong rendering permanent. |
| 60 | + |
| 61 | +The mechanism to copy (not to call) is `internal/provider/projection.go`: |
| 62 | + |
| 63 | +1. each IR record carries cursors — `libretto:<sourceid>` for observed forms, |
| 64 | + the node's own form channel for the bound board; |
| 65 | +2. consecutive records bracket a window; |
| 66 | +3. the patches in that window come from `Libretto(source).PatchesBetween` for |
| 67 | + a studied form, and from the board's own `FormPatchesBetween` for the |
| 68 | + bound one. |
| 69 | + |
| 70 | +Three rules inherited from the projection, each of which was a bug first: |
| 71 | + |
| 72 | +- **read a libretto through `Libretto(source)`**, never as a node. A second |
| 73 | + `Form` over that channel replays once and never hears the fold again. |
| 74 | +- **strip `system.libretto.*` except `alive`** (`store.HiddenLibrettoKey`). |
| 75 | + `at` moves on every fold; `refs` moves when ANOTHER aria studies the same |
| 76 | + form. Neither is this reader's business. |
| 77 | +- **a window may only close on a record that can carry it.** For the model |
| 78 | + that meant `RoleInput`; for the UI IR the analogous question is which node |
| 79 | + a window belongs to. Pick the rule deliberately and write it in the code: |
| 80 | + the natural one is that a window closes on the node projected from the |
| 81 | + record that carries the stamp, which for a tool round is the tool node. |
| 82 | + |
| 83 | +Legacy `study:` stamps (source versions, pre-migration) are IGNORED, exactly |
| 84 | +as the translator ignores them. An old transcript shows no form deltas. |
| 85 | + |
| 86 | +## 3. The two sentences |
| 87 | + |
| 88 | +Both are derived from ordinary deltas; neither needs a new channel. |
| 89 | + |
| 90 | +| when | the TUI says | |
| 91 | +|---|---| |
| 92 | +| `aria_id` changes on the BOUND form | *This figaro has been forked from `<id>`* | |
| 93 | +| `target-aria` changes on a form of kind `role` | *Role `<formid>` recast to figaro `<aria id>`* | |
| 94 | + |
| 95 | +The first is available today without inference: a fork's birth patch stamps |
| 96 | +`system.forked_from` beside the new `aria_id`, so the sentence can name the |
| 97 | +parent from the delta itself rather than reconstructing it. |
| 98 | + |
| 99 | +## 4. The client |
| 100 | + |
| 101 | +Dimmer than the selection UI, slightly opaque, Kanagawa. Concretely: |
| 102 | + |
| 103 | +- one line per form, grouped by form id, keys joined — not one line per key, |
| 104 | + or a board write of six keys becomes six lines of furniture. |
| 105 | +- rendered BELOW the node it belongs to, indented, in the theme's muted |
| 106 | + foreground; the selection UI keeps its brighter treatment, so the eye |
| 107 | + ranks selection above state and state above nothing. |
| 108 | +- the two sentences above render as prose, not as a key/value pair. |
| 109 | +- collapsed by default if a form delta exceeds a couple of lines: studied |
| 110 | + values are mirrored WHOLE and are not truncated anywhere in the pipeline. |
| 111 | + |
| 112 | +Global theming: take colours from the theme table rather than literals, and |
| 113 | +add a token (`stateDim` or similar) rather than reusing the comment colour by |
| 114 | +coincidence. One name, one meaning. |
| 115 | + |
| 116 | +## 5. Build order |
| 117 | + |
| 118 | +1. **The types** (`livedoc.Node`, `aria.Turn`, `FormDelta`): additive, |
| 119 | + `omitempty`, nothing reads them yet. Wire-compatible with old clients. |
| 120 | +2. **The hub-side assembly**, behind a function with its own test: given a |
| 121 | + record range and a store, produce the deltas. This is where the cursor |
| 122 | + arithmetic lives and where the three inherited rules are enforced. |
| 123 | +3. **The projector** (`internal/uiir`) attaches them to nodes. |
| 124 | +4. **The TUI** draws them. |
| 125 | +5. **The two sentences**, last, because they are a rendering of (1)-(4) and |
| 126 | + not a mechanism. |
| 127 | + |
| 128 | +Each step is independently testable, and 1-2 are safe to land alone. |
| 129 | + |
| 130 | +## 6. What will bite |
| 131 | + |
| 132 | +- **Cost per turn.** This adds a store read per stamped record on a path that |
| 133 | + runs for every rendered turn. The projection pays it already and has a |
| 134 | + per-LT cache; the UI IR has no such cache. Measure before shipping: the |
| 135 | + `DaemonDay` and `ListingCost` probes exist for exactly this. |
| 136 | +- **A form deleted mid-transcript.** The copy outlives it, so the delta still |
| 137 | + renders; the source read would fail. Another reason to read the libretto. |
| 138 | +- **Ephemeral arias** have no store and no librettos. The field is simply |
| 139 | + absent; do not synthesize it from the provider path to fill the gap. |
| 140 | +- **Retranslation must not change it.** These deltas are derived from durable |
| 141 | + stamps, so two renders of the same transcript must agree. That is a test: |
| 142 | + render twice, assert equality, and make it fail if anyone reaches for the |
| 143 | + provider cache to speed it up. |
| 144 | + |
| 145 | +--- |
| 146 | + |
| 147 | +## 7. The fork-on-a-form bug, confirmed (same session, filed here for the same hand) |
| 148 | + |
| 149 | +`fig fork @form` births a **figaro**, not a forked form: verified live, the |
| 150 | +child is `kind=conversation` with `aria_id` and `system.forked_from` on its |
| 151 | +board. So `fork` and `bind` on a form are SYNONYMS and one of the two names |
| 152 | +is a lie. |
| 153 | + |
| 154 | +Cause, one line: `XwalStore.ForkWith` hard-codes |
| 155 | +`forkWithKind(..., kindConversation)`, while `forkWithKind`'s own comment |
| 156 | +says the caller chooses the child's species. The fork verb has no way to say. |
| 157 | + |
| 158 | +**The form→form operation already exists and is already documented**: |
| 159 | +`CreateForm(parent, patch)` — *"Parent \"\" forks the null root; a form id |
| 160 | +duplicates that form's state into a fresh @id"* (`rpc.FormCreateRequest`). |
| 161 | +`createFormAndReport` even takes a `parent`. **Every CLI caller passes ""**, |
| 162 | +so nothing reaches it. `fig form new` while attended to a form mints a |
| 163 | +SIBLING off the null root and inherits nothing — verified. |
| 164 | + |
| 165 | +So the fix is wiring, not mechanism: `fig fork` on a node of kind `form` |
| 166 | +routes to `CreateForm(parent)`. Two real questions with it: |
| 167 | + |
| 168 | +1. a form's timeline is one ceremonial record, so `<id>:<turn>` addresses |
| 169 | + nothing — a form fork is always at the head, and the verb should say so |
| 170 | + rather than silently ignoring a coordinate. |
| 171 | +2. `CreateForm` REQUIRES a patch (*"a fork that transforms nothing is a fork |
| 172 | + nobody can name"*) and `fig fork` takes none. Either fork-a-form demands |
| 173 | + `-S`, or that rule is relaxed for this path. That is a ruling, not an |
| 174 | + implementation detail. |
| 175 | + |
| 176 | +Recorded and NOT to be built (Gluck, explicitly): `cast` on a form with no |
| 177 | +argument minting a figaro, and a role fork meaning fork-both-and-recast. |
0 commit comments