Skip to content

Commit 68e69c1

Browse files
author
gluck
committed
plan: form deltas in the UI IR, and the fork-on-a-form bug confirmed
Gluck's specification, written down before anybody builds it, plus the bug found while confirming his suspicion about fork. The load-bearing constraint is his: assemble the deltas in the HUB from the store's own cursor arithmetic, never from the provider cache. A UI derived from translated bytes is a function of which dialect last spoke and of whether a record happened to be cached -- and that cache is the layer that has twice made a wrong rendering permanent. The plan carries the three rules the projection learned the hard way, each of which was a bug first. The fork bug is confirmed live and is narrower than it looks: forking a form gives a figaro because ForkWith hard-codes the child's species, while the form-to-form operation already exists, is already documented in FormCreateRequest, and is simply unreachable -- every CLI caller passes an empty parent. Wiring, not mechanism, plus two rulings the next hand needs.
1 parent ec3ab03 commit 68e69c1

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

‎plans/form-deltas-in-ui-ir.md‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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

Comments
 (0)