@termwright/protocol 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gorce-ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,651 @@
1
+ # @termwright/protocol
2
+
3
+ The termwright semantic wire protocol: message shapes, roles, limits, framing,
4
+ the render-commit marker, and snapshot validation.
5
+
6
+ This package is the normative source of truth for the protocol (see
7
+ `CONTRACTS.md`). It depends on `zod` and Node builtins only — never on React,
8
+ Ink, MCP, PTY, or the driver — so adapters and drivers can both import it
9
+ without dragging in each other's runtime.
10
+
11
+ Everything here **fails closed**: untrusted input is rejected with a typed
12
+ `ProtocolViolation` or a structured `{ ok: false, code, detail }` result, never
13
+ partially accepted.
14
+
15
+ ## Versioned geometry contract
16
+
17
+ `termwright/1` remains a strict compatibility protocol: `bounds` and
18
+ `occlusion` keep their historical meaning and are never silently upgraded.
19
+ `termwright/2` requires `qualified-observations` and snapshot `v: 2`. Each node
20
+ then reports independent `displayed`, `intendedRect` and `visibleRect`
21
+ observations; the snapshot reports its coordinate space and an explicit
22
+ pointer hit-grid observation. A known grid is accepted only after negotiating
23
+ `pointer-hit-grid`. Its regions are canonical, non-overlapping row-major runs
24
+ with positive width and `height: 1`, permitting unambiguous lookup and linear
25
+ validation.
26
+
27
+ The driver accepts either major and echoes it in `hello-ack`; snapshot majors
28
+ must match the handshake. V2 is full-snapshot-only, so v1 delta semantics are
29
+ never projected onto qualified facts.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pnpm add @termwright/protocol
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```ts
40
+ import {
41
+ DEFAULT_LIMITS,
42
+ createFrameDecoder,
43
+ encodeFrame,
44
+ encodeMarker,
45
+ parseAdapterMessage,
46
+ verifyMarkerPayload,
47
+ } from '@termwright/protocol';
48
+
49
+ // Driver side: decode length-prefixed frames off the socket.
50
+ const decoder = createFrameDecoder(DEFAULT_LIMITS.maxFrameBytes);
51
+
52
+ socket.on('data', (chunk: Uint8Array) => {
53
+ for (const frame of decoder.push(chunk)) {
54
+ const result = parseAdapterMessage(frame, DEFAULT_LIMITS);
55
+ if (!result.ok) {
56
+ // 'bad-version' | 'malformed' | 'limit-exceeded' — close the session.
57
+ return closeWith(result.code, result.detail);
58
+ }
59
+ if (result.message.type === 'snapshot') {
60
+ // Already validated and deep-frozen; safe to retain.
61
+ publish(result.message.snapshot);
62
+ }
63
+ }
64
+ });
65
+
66
+ // Adapter side: announce a committed render on stdout, then push the tree.
67
+ process.stdout.write(encodeMarker(token, sessionId, revision));
68
+ socket.write(encodeFrame({ type: 'revision-commit', revision }, DEFAULT_LIMITS.maxFrameBytes));
69
+
70
+ // VT layer: verify an OSC payload before trusting it (null on any mismatch).
71
+ const marker = verifyMarkerPayload(payload, token, sessionId);
72
+ ```
73
+
74
+ ## Surface
75
+
76
+ | Module | Provides |
77
+ |---|---|
78
+ | `env` | Env var names, `PROTOCOL_VERSION`, `PROTOCOL_ID` |
79
+ | `roles` | Closed `SEMANTIC_ROLES` / `SEMANTIC_ACTIONS` sets |
80
+ | `limits` | `DEFAULT_LIMITS`, `ABSOLUTE_LIMITS`, `ProtocolLimits` |
81
+ | `tree` | `SemanticSnapshot`, `SemanticNode`, `Rect`, portable `SemanticState`, application `SemanticExtendedState` |
82
+ | `node-keys` | closed semantic-node key set shared by cross-language validators |
83
+ | `probe` | Probe IR, `ProbeInfo`, identity/capability/provenance vocabularies, bounds resolution and validation |
84
+ | `logs` | bounded structured application-log records and validation |
85
+ | `messages` | Message interfaces plus `parseAdapterMessage` / `parseDriverMessage` |
86
+ | `framing` | `createFrameDecoder`, `encodeFrame`, `projectDto` |
87
+ | `marker` | `encodeMarker`, `verifyMarkerPayload` |
88
+ | `validate` | `validateSnapshot` |
89
+ | `delta` | `TreeDelta`, `validateTreeDelta`, `applyTreeDelta` |
90
+ | `accesskit` | `toAccessKitTreeUpdate`, `accessKitNodeId`, role table |
91
+ | `errors` | `ProtocolViolation`, `ProtocolViolationCode` |
92
+
93
+ `SemanticNode.state` is a closed, cross-framework vocabulary. Application
94
+ facts that are meaningful but not portable belong under `SemanticNode.extended`
95
+ as bounded JSON data; keeping the namespaces separate prevents a framework or
96
+ annotation from silently inventing a portable state flag.
97
+
98
+ ## Integrating the marker with a VT parser
99
+
100
+ `encodeMarker` emits a private OSC sequence terminated by BEL:
101
+
102
+ ```
103
+ ESC ] 8487 ; twm;{rev};{mac} BEL
104
+ ```
105
+
106
+ A VT parser hands an OSC handler everything after the number and its
107
+ separator, which is exactly what `verifyMarkerPayload` takes — verified against
108
+ `@xterm/headless`:
109
+
110
+ ```ts
111
+ import { MARKER_OSC_CODE, verifyMarkerPayload } from '@termwright/protocol';
112
+
113
+ term.parser.registerOscHandler(MARKER_OSC_CODE, (data) => {
114
+ const marker = verifyMarkerPayload(data, token, sessionId);
115
+ if (marker !== null) commit(marker.revision);
116
+ return true; // consumed: keeps the sequence out of the visible grid
117
+ });
118
+ ```
119
+
120
+ A trailing BEL or ST is tolerated, because a caller scanning raw output with a
121
+ regex keeps the terminator that a parser would have consumed.
122
+
123
+ ### Why OSC 8487
124
+
125
+ **Why OSC and not DCS.** ConPTY rewrites the stream it forwards. A passthrough
126
+ probe run in CI across the three platforms showed it dropping DCS, APC and
127
+ OSC 8, while passing private OSC with either terminator, and OSC 133. A DCS
128
+ marker could not reach the driver on Windows at all.
129
+
130
+ One encoding is used everywhere rather than negotiated per platform: two paths
131
+ double the surface that has to stay correct, and the path used least is the one
132
+ that rots unnoticed. BEL is emitted rather than ST because it is the terminator
133
+ ConPTY was observed to forward most reliably.
134
+
135
+ **Why this number.** OSC numbers have no registry, only convention, so 8487 is
136
+ chosen to sit clear of everything in use — xterm's allocations (0–14, 46, 50,
137
+ 52, 104, 110–119), OSC 8 hyperlinks, 9 and 1337 (iTerm2), 99 and 30001 (kitty),
138
+ 133 (FinalTerm shell integration), 633 (VS Code), 697 (ConEmu), 777–779
139
+ (urxvt/VTE). It is the ASCII codes of `T` and `W`, for termwright.
140
+
141
+ The `twm;` tag after the number is kept as a self-identifying guard: if anything
142
+ ever does claim 8487, a marker still says what it is rather than being mistaken
143
+ for that feature's payload.
144
+
145
+ The token is likewise **opaque**: whatever lands in `TERMWRIGHT_TOKEN` is what
146
+ both sides pass to the HMAC as the key. Never decode it to bytes first. Use
147
+ `generateToken()` so every client mints it the same way.
148
+
149
+ ## Guarantees worth knowing
150
+
151
+ - **Log record ordering.** `LogRecord.seq` is **strictly increasing** within a
152
+ session. A gap upward means records were dropped at the source (rate limit,
153
+ queue overflow) and is expected under load; a duplicate or a decrease means
154
+ the sender is broken, and the receiver rejects that record with a diagnostic.
155
+ Keeping those two distinguishable is the whole point of the counter. This is
156
+ a rule between records, so `validateLogRecord` cannot check it — it validates
157
+ one record's shape, and the driver, which is the only party that sees the
158
+ whole session, enforces the ordering.
159
+
160
+ - **Framing.** 4-byte big-endian length prefix + UTF-8 JSON. The declared
161
+ length is checked against the ceiling *before* any body is read, so a
162
+ four-byte header claiming 4 GB costs four bytes. Partial frames are buffered
163
+ (never emitted); a violation poisons the decoder permanently rather than
164
+ resynchronising on an attacker-chosen offset.
165
+ - **Projection.** Every decoded value passes through `projectDto`, which walks
166
+ the graph with `Object.getOwnPropertyDescriptor` and rejects accessors,
167
+ proxies, symbol keys, exotic prototypes, reserved keys (`__proto__`), sparse
168
+ arrays, aliases and cycles, non-finite numbers, and unpaired surrogates. A
169
+ getter on hostile input is **detected without being invoked**. The result is
170
+ a deep-frozen plain copy sharing no references with the input.
171
+ - **Marker.** `\x1bPtwm;{revision};{mac}\x1b\\`, where the MAC is
172
+ base64url(HMAC-SHA256(token, `${sessionId}:${revision}`)) truncated to 16
173
+ bytes. Comparison is constant-time, revisions must be canonical decimal (`01`
174
+ is not `1`), and the MAC binds session and revision so it cannot be replayed
175
+ across either. `verifyMarkerPayload` is total: hostile input yields `null`.
176
+ - **Validation.** `validateSnapshot` enforces the §8.2 invariants: unique ids,
177
+ parents that exist, acyclic parent chains, depth/count/byte ceilings,
178
+ UTF-8 byte bounds on strings, safe-integer rects that intersect the viewport
179
+ unless `state.hidden`, a positive revision, and a closed role/action set.
180
+ Unknown properties are rejected, not ignored. Checks run cheapest-first, so
181
+ a snapshot over the byte ceiling is rejected before any per-node work.
182
+
183
+ `bounds` is optional per node, and a snapshot carrying **no bounds at all** is
184
+ valid. Class-B/C frameworks publish role+name nodes without trustworthy
185
+ coordinates, and even a class-A adapter drops bounds wholesale when it cannot
186
+ observe its own offset (Ink does this when the tree contains `<Static>`).
187
+ Consumers must treat a bounds-free snapshot as a normal state, not a fault, and
188
+ fall back to their non-geometric path.
189
+
190
+ Two invariants are stricter than the prose spec strictly requires, and are
191
+ called out here because adapters must satisfy them: every node without a
192
+ `parentId` must appear in `rootIds`, and `labelledBy`/`describedBy` must
193
+ reference nodes present in the same snapshot.
194
+
195
+ ## Tree deltas
196
+
197
+ With `subscribe: 'diffs'` an adapter sends `tree-delta` instead of a full
198
+ snapshot after each commit. A delta is bound to an **exact** base revision:
199
+
200
+ ```ts
201
+ import { applyTreeDelta, validateTreeDelta } from '@termwright/protocol';
202
+
203
+ const checked = validateTreeDelta(body, limits); // shape only
204
+ if (!checked.ok) return closeWith('malformed', checked.detail);
205
+
206
+ const composed = applyTreeDelta(held, checked.delta, limits);
207
+ if (!composed.ok) {
208
+ // Never patch around a mismatch — ask for the whole tree instead.
209
+ if (composed.code === 'revision') return requestFullTree();
210
+ return closeWith('malformed', composed.detail);
211
+ }
212
+ ```
213
+
214
+ **Composition semantics** (normative — every adapter and client must agree):
215
+
216
+ - `changed` upserts by id. A node already present is **replaced wholesale**,
217
+ never field-merged: merging would need a third state meaning "unset this
218
+ optional field", which the wire cannot express.
219
+ - `removed` removes each id **together with its subtree**. Cascade is what
220
+ keeps deltas small — dropping a dialog is one id, not one per descendant —
221
+ and it is the only rule that cannot leave orphans behind.
222
+ - `rootIds`, when present, replaces the root list. When absent the base roots
223
+ carry over minus anything removed, so **introducing a new root requires
224
+ sending `rootIds`**; otherwise the parentless node is missing from the root
225
+ list and validation rejects it.
226
+ - Removals are applied **before** upserts, so one delta can rescue a node out
227
+ of a subtree it also removes.
228
+ - **Retraction is wholesale replacement.** A recognizer that loses confidence
229
+ in a fact sends the full node *without* that field; there is no separate
230
+ "unset" operation, and none is needed, because a replacement node's silence
231
+ about a field is already the signal. Partial node patches would buy back the
232
+ bytes but reintroduce the third state ("leave this alone") that wholesale
233
+ replacement exists to avoid, so they stay a future option contingent on
234
+ measured `px` cost.
235
+ - **A producer that dropped facts sends a full snapshot, not a delta.** Under
236
+ backpressure a probe may sample, coalesce or discard; a delta built on top of
237
+ facts it never saw describes a tree that never existed. `get-tree` resync is
238
+ the same mechanism with a new trigger, and it is the producer's obligation:
239
+ the receiver cannot detect the difference, because a delta missing a change
240
+ is indistinguishable from a delta whose producer had nothing to say.
241
+ - `cursor`, when present, replaces the cursor; absent means **unchanged**.
242
+ Without it a diffs-only session could never move the cursor, which in a TUI
243
+ moves on nearly every keystroke — the mode would be useless for exactly the
244
+ interactive applications it exists to make cheap.
245
+
246
+ A delta can set the cursor but **cannot clear it**, and those differ:
247
+ `{ visible: false }` means there is a cursor and it is hidden, while an
248
+ absent `cursor` on a snapshot means there is no cursor information at all.
249
+ So a producer whose tree loses its cursor entirely **must send a full
250
+ snapshot**, exactly as it must for a resize. Emitting a delta there leaves
251
+ the receiver holding a cursor the application stopped reporting — stale
252
+ state that looks live.
253
+
254
+ **The validation split matters.** `validateTreeDelta` checks only what is
255
+ knowable without the base: bounded sizes, well-formed nodes, unique ids, a
256
+ revision that moves forward. Parent existence, acyclicity, depth and whether
257
+ bounds intersect the viewport are properties of the *composed* tree — a delta
258
+ carries no viewport at all — so `applyTreeDelta` checks them by running the
259
+ result through `validateSnapshot`. A delta is never trusted to produce a valid
260
+ tree, only to describe one.
261
+
262
+ **Resynchronisation.** A base-revision mismatch, or a removal of a node the
263
+ receiver does not hold, means the producer's view and ours have diverged. Both
264
+ return a failure telling the caller to request a full snapshot via `get-tree`.
265
+ A speculative patch would produce a tree that looks fine and is wrong, and
266
+ every assertion downstream would inherit that error silently.
267
+
268
+ A delta cannot change the viewport or the session id; those are inherited from
269
+ the base snapshot, and changing them requires a full one.
270
+
271
+ ## AccessKit export (bridge-ready)
272
+
273
+ `toAccessKitTreeUpdate` converts a `SemanticSnapshot` into an AccessKit
274
+ `TreeUpdate` in its serde JSON shape. It is a pure transformation — this
275
+ package takes no dependency on AccessKit — so the output is data a bridge can
276
+ hand to a real adapter.
277
+
278
+ ```ts
279
+ import { toAccessKitTreeUpdate } from '@termwright/protocol';
280
+
281
+ const { update, cellBounds } = toAccessKitTreeUpdate(snapshot, {
282
+ toolkitName: 'ink',
283
+ toolkitVersion: '7.1.1',
284
+ });
285
+ ```
286
+
287
+ ### Why there is no native bridge in 1.0
288
+
289
+ AccessKit's platform adapters attach a tree to a **native window**: an `NSView`
290
+ on macOS, an `HWND` on Windows, a toplevel on AT-SPI. A terminal application
291
+ has none of those. The emulator owns the window; the application under test is
292
+ a child process writing bytes to a pseudo-terminal. There is nothing for an
293
+ adapter to attach to, and no path for an assistive technology to route a
294
+ request back to us.
295
+
296
+ The geometry gap is the same problem from the other side. Our `bounds` are
297
+ **terminal cells** — row 3, column 12 — while AccessKit's `Rect` is in pixels
298
+ relative to the window origin. Converting needs the cell size and window
299
+ position, which live in the emulator, not in the process being tested. Guessing
300
+ a cell size would produce coordinates that look authoritative and point nowhere,
301
+ which is worse than having none.
302
+
303
+ So this is the half of the problem that can be solved correctly without a
304
+ window. `bounds` is emitted **only** when the caller passes `cellSize`, which
305
+ an embedder that owns the window (a GUI emulator embedding termwright) can do
306
+ honestly. Otherwise cell rects are returned separately as `cellBounds`, because
307
+ AccessKit's `Node` has no extension point for foreign coordinate systems and
308
+ smuggling cells into a pixel field would silently corrupt every consumer.
309
+
310
+ ### Mapping notes
311
+
312
+ - **Focus is tree-level.** AccessKit puts `focus` on the `TreeUpdate`, not on a
313
+ node, so the node carrying `state.focused` becomes the update's focus.
314
+ - **Children are explicit.** Our tree is flat and joined by `parentId`;
315
+ AccessKit nodes carry a `children` array, derived here in snapshot order.
316
+ - **Ids are hashed.** AccessKit's `NodeId` is a `u64`, but JSON numbers are
317
+ doubles, so `accessKitNodeId` takes SHA-256 of the id truncated to **53
318
+ bits** — every id stays exactly representable, and a collision (about 1.4e-9
319
+ at the 5 000-node ceiling) throws rather than merging two nodes.
320
+ - **`select` is dropped.** AccessKit has no selection action; mapping it onto
321
+ `click` would claim a behaviour the adapter never described. `toggle` does
322
+ map to `click`, which is how AccessKit expresses toggling.
323
+ - A multiline `textbox` becomes `multilineTextInput`.
324
+
325
+ ### Schema provenance
326
+
327
+ Verified against `accesskit` 0.24.1 (docs.rs, August 2026):
328
+ `TreeUpdate { nodes, tree, tree_id, focus }`, `Tree { root, toolkit_name,
329
+ toolkit_version }`, `NodeId(u64)`, `Rect { x0, y0, x1, y1 }`, `TreeId(Uuid)`
330
+ with the nil UUID reserved for the root tree, and
331
+ `#[serde(rename_all = "camelCase")]` on `Role`, `Action` and `Node`.
332
+
333
+ One spelling could not be confirmed from the published docs: the serde
334
+ representation of the `Toggled` enum. This export emits `"true" | "false" |
335
+ "mixed"` for consistency with the crate's other public enums. Anyone building a
336
+ real bridge should check that against the adapter they link, and it is a
337
+ one-line change if it turns out to be `"True" | "False" | "Mixed"`.
338
+
339
+ ## Adapter semantics conventions
340
+
341
+ The vocabulary is already shared: roles, states and actions are closed sets the
342
+ protocol enforces. The **conventions** were not. Where a name comes from, what
343
+ falls back to what, whether an empty value is published — each adapter decided
344
+ for itself, so two conformant adapters could describe the same UI differently
345
+ and a test written against one would fail against another for no reason its
346
+ author could see.
347
+
348
+ This section is normative for every adapter in every language. An adapter that
349
+ cannot follow a rule because its framework does not expose the data must say so
350
+ in its own README under a `## Deviations` heading (rule 6) — silence is not an
351
+ option, because a silent difference is exactly what costs a test author an
352
+ afternoon.
353
+
354
+ ### 1. Role — three levels, in order
355
+
356
+ 1. explicit author annotation;
357
+ 2. the framework's widget-type map;
358
+ 3. `generic`.
359
+
360
+ Stop at the first that produces a role in `SEMANTIC_ROLES`. An adapter may
361
+ resolve level 2 from whatever its framework offers (a class map, an
362
+ accessibility property, a convention prop), and may consult more than one
363
+ source there, but it must not invent a fourth *precedence* level above the
364
+ author's annotation: an explicit annotation always wins.
365
+
366
+ ### 2. Name — ordered sources
367
+
368
+ 1. explicit author annotation (including a deliberate empty string);
369
+ 2. the widget's own label, title or placeholder property;
370
+ 3. **for name-from-content roles only**: the concatenated text of descendants;
371
+ 4. the widget's identifier.
372
+
373
+ Step 3 is the one that has diverged most, so it is spelled out. The
374
+ name-from-content roles are exactly:
375
+
376
+ `button`, `listitem`, `menuitem`, `tab`, `checkbox`, `radio`, `cell`, `row`,
377
+ `heading`
378
+
379
+ **Containers are never named from their content.** A `region`, `dialog`,
380
+ `list`, `table` or `application` with no label of its own has an empty
381
+ name — it does not inherit the text of everything inside it. Naming containers
382
+ from content is what makes `getByRole('region', { name: 'Approve' })` match the
383
+ dialog *containing* the Approve button, so every ancestor of a label becomes a
384
+ plausible match for it and locators stop being selective.
385
+
386
+ Descendant text is collapsed on whitespace and bounded by
387
+ `limits.maxStringBytes`.
388
+
389
+ ### 3. testId — native identifier and annotation, both
390
+
391
+ An adapter must accept **both**:
392
+
393
+ - the framework's native identifier where one exists (a Textual DOM `id`, an
394
+ OpenTUI `id`), and
395
+ - an explicit author annotation, which wins over the native one.
396
+
397
+ Framework-generated identifiers that are not author-chosen (OpenTUI's
398
+ `renderable-<n>`) must be filtered out: a test id that changes when an unrelated
399
+ widget is added is worse than none, because it fails only later and looks
400
+ flaky rather than wrong.
401
+
402
+ ### 4. States — mapped, never guessed
403
+
404
+ `disabled`, `focused`, `selected`, `checked`, `expanded`, `modal`, `hidden`,
405
+ `readonly` are published **only** when read from a native framework flag or
406
+ supplied by the author. An adapter must not infer a state from appearance,
407
+ position or role.
408
+
409
+ Omitting a state means "this framework does not report it", which a test can
410
+ handle. Guessing means the tree asserts something the application never said,
411
+ and a passing test then proves nothing.
412
+
413
+ An adapter that drops hidden nodes from the tree entirely (rather than
414
+ publishing them with `hidden: true`) must say so under `## Deviations`; both are
415
+ defensible, but they are not the same tree.
416
+
417
+ ### 5. `value` versus `name`
418
+
419
+ `value` carries what the widget *contains*; `name` carries what it is *called*.
420
+ Publish `value` whenever the widget has one, **including the empty string** — an
421
+ empty textbox has `value: ''`, not an absent value.
422
+
423
+ The distinction is load-bearing: `''` means "the field is empty" and absent
424
+ means "this is not a value-bearing widget". Collapsing them makes
425
+ `toHaveValue('')` unassertable, and a wire format that drops empty strings
426
+ (Go's `omitempty` and friends) silently converts the first into the second.
427
+
428
+ **Which roles derive a value.** Automatic derivation is gated to
429
+ `textbox` and `progressbar`. An explicit author annotation bypasses the gate on
430
+ any role — the author knows something the widget map does not — but an adapter
431
+ must not go looking for a `.value` property on roles outside the set.
432
+
433
+ `scrollbar` is deliberately excluded: its position is `state.scrollOffset` and
434
+ `state.scrollExtent`, which are numbers with defined meaning, whereas a
435
+ stringified scroll position in `value` would be a second encoding of the same
436
+ fact that no matcher knows how to read.
437
+
438
+ **A boolean is never a value.** A widget whose `.value` is `true`/`false` is
439
+ reporting a *state*, not contents: it maps to `state.checked`, and `value` stays
440
+ absent. This is a real divergence found while converging two adapters, not a
441
+ hypothetical — publishing `value: "true"` makes a checkbox look like a textbox
442
+ containing the word "true" to every role-blind matcher.
443
+
444
+ ### 6. Deviations must be declared
445
+
446
+ Per-adapter differences are permitted **only** where the framework does not
447
+ expose the data, and each one must be listed in that adapter's README under a
448
+ `## Deviations` heading, saying what the rule is, what the adapter does
449
+ instead, and why the framework forces it.
450
+
451
+ An undeclared deviation is a bug, not a difference.
452
+
453
+ Rules 1–5 bind whatever publishes a semantic tree, so a package that publishes
454
+ none — the Rust crate is the protocol plus a logs bridge — has nothing to
455
+ declare and needs no such heading. The requirement follows the adapter, not the
456
+ package.
457
+
458
+ Entry formatting is deliberately unconstrained: adapters use prose, bullets and
459
+ a table, and conformance parses all three. The rule governs adapters, not
460
+ markdown, and making authors rewrite prose to suit a parser would be the tail
461
+ wagging the dog.
462
+
463
+ ### Bounds are visible geometry, and occlusion is a separate fact
464
+
465
+ The IR keeps `intendedRect` (where an object asked to draw) and `visibleRect`
466
+ (what survived the clip) apart, because they are different facts. A
467
+ `SemanticNode` publishes **one** rectangle, so a normalizer collapses them, and
468
+ the collapse is guaranteed rather than incidental:
469
+
470
+ `bounds` is always the best known **visible** geometry — the framework's own
471
+ clip intersection where it computes one, `intendedRect ∩ clip` where a clip is
472
+ known, and the intended rectangle only as a last resort. A consumer never has
473
+ to ask which of the two it is holding.
474
+
475
+ Publishing both rectangles was the alternative and was rejected: it moves "which
476
+ of these did you mean" onto every consumer of the tree instead of answering it
477
+ once. `resolveNodeBounds` implements the rule, so the five client
478
+ implementations share one collapse rather than five.
479
+
480
+ What `bounds` cannot say is whether something was painted on top of it. Legacy
481
+ v1 `occlusion: 'known'` says only that the probe reported paint order — it does
482
+ not name the topmost input recipient. **Absent means `'unknown'`**.
483
+
484
+ A consumer performing pointer actions must require a qualified hit test naming
485
+ the recipient, rather than click and hope. The input lands somewhere real, and
486
+ if it lands on another widget the result is attributed to the intended target
487
+ — a silent false green, which is worse than a refusal. Paint order alone never
488
+ lifts that requirement.
489
+
490
+ ### Scrolled away is not the same as never displayed
491
+
492
+ `state.offscreen` says the node exists in the layout but every one of its cells
493
+ falls outside the visible area — it is scrolled out, and scrolling can bring it
494
+ back. It is named for the claim a test author makes, not for the mechanism:
495
+ clipping is *how* it happens, off screen is *what it means*.
496
+
497
+ Three states that used to collapse into two:
498
+
499
+ | Situation | `bounds` | `state` |
500
+ |---|---|---|
501
+ | Visible | the visible rectangle | — |
502
+ | Scrolled out of view | zero-area rectangle at its anchor | `hidden: true, offscreen: true` |
503
+ | Not displayed | zero-area rectangle, or absent if unknown | `hidden: true` |
504
+ | Producer does not know the geometry | absent | — |
505
+
506
+ The last row is why the field exists. Without it an adapter had to choose
507
+ between "no geometry" and "scrolled away", so `bounds: undefined` carried both
508
+ meanings and a consumer reading a tree generically could not tell them apart.
509
+ `offscreen` gives the scrolled case its own word and returns absent bounds to
510
+ its single meaning.
511
+
512
+ `offscreen: true` implies `hidden: true` and validation refuses the pair
513
+ without it — every cell outside the visible area and the node still visible
514
+ cannot both be true. **Absent means "not claiming"**, not "on screen": a
515
+ producer that cannot observe clipping omits the field, which is why this is a
516
+ positive assertion rather than another tri-state.
517
+
518
+ Textual is the worked example and the reason this is expressible at all: it
519
+ computes `clip ∩ region`, so its probe reports a zero-area rectangle for a
520
+ scrolled-out widget and no rectangle at all for `display=False`. Normalizers
521
+ get there from `resolveNodeBounds`, whose `clippedAway` maps to exactly this
522
+ pair.
523
+
524
+ ### Merge precedence
525
+
526
+ Facts about a node arrive from several sources at once, and the tree publishes
527
+ one answer. The order is:
528
+
529
+ **annotation > recognizer > framework mapping > render inference > heuristic**
530
+
531
+ with one exception that matters more than the order itself: **physical facts
532
+ are never casually overridden by an annotation.** Bounds, focus, visibility and
533
+ cells describe what the terminal actually did. An author may name a widget, give
534
+ it a role or a test id — those are claims about meaning. An author may not
535
+ declare where something is on screen, because a test that trusts an annotated
536
+ rectangle over a measured one stops testing the application and starts testing
537
+ the annotation.
538
+
539
+ Each node records where its facts came from in `p`, with per-field exceptions in
540
+ `px`, drawn from a closed set: `annotation`, `recognizer`, `framework`,
541
+ `correlation`, `heuristic`. One source per node covers the overwhelming
542
+ majority; the exception map means a mixed node pays only for the fields that
543
+ actually differ.
544
+
545
+ Provenance is not decoration. A fact with a weak source is not the same as an
546
+ absent fact, and neither is the same as a fact known to be false — three states
547
+ that collapse into one the moment a tree stops saying where its facts came
548
+ from.
549
+
550
+ ### Where the current differences live
551
+
552
+ This section carried a snapshot of per-adapter gaps when the rules were first
553
+ written down. Every entry in it has since been fixed or declared, so the
554
+ snapshot is deleted rather than left to rot: a stale list in a normative
555
+ document is worse than no list, because it is read as current. That applies to
556
+ counts and claims here too — this paragraph deliberately names no totals.
557
+
558
+ The live source of truth is the compatibility registry plus each
559
+ tree-publishing probe/client's limitations. `@termwright/ink` and
560
+ `@termwright/opentui` contribute optional author intent but do not publish a
561
+ tree themselves. Keeping one generated registry prevents two copies of a fact
562
+ from drifting.
563
+
564
+ ## Protocol evolution
565
+
566
+ The protocol grows without a version bump only in ways an already published
567
+ client can survive. Anything else is a breaking change.
568
+
569
+ ### Direction decides strictness
570
+
571
+ The two directions are read differently, and the difference is about **who is
572
+ speaking**, not about the message:
573
+
574
+ | Direction | Reader | Unknown fields |
575
+ |---|---|---|
576
+ | adapter → driver (`parseAdapterMessage`) | strict | rejected as `malformed` |
577
+ | driver → adapter (`parseDriverMessage`) | tolerant | ignored, and passed through |
578
+
579
+ Adapter traffic crosses the hostile-input boundary: it comes from a process
580
+ under test that may be broken or malicious, so an unknown field is a signal,
581
+ not an extension. The driver is the trusted party and behaviour there is
582
+ governed by negotiated capabilities, so a newer driver may add an optional
583
+ field without invalidating every adapter already published.
584
+
585
+ Tolerant does not mean lax. Known fields stay strictly type-checked, closed
586
+ sets stay closed, and unknown fields are *carried through* rather than
587
+ stripped, so a reader that does understand them still can.
588
+
589
+ **Additive — readers must tolerate these:**
590
+
591
+ - **New fields on any driver → adapter message**, including nested objects
592
+ (`marker`, `logs`). `hello-ack.logs` is the worked example: **absent means
593
+ the feature is off**, so an older driver that never sends it keeps working,
594
+ and an adapter must not use a feature it was not explicitly granted.
595
+ - **New keys in `limits`.** Lenient in *both* directions — capacity is
596
+ negotiated, so a driver learning a new ceiling must not invalidate adapters
597
+ in the wild.
598
+ - **New capability strings.** The driver filters the adapter's advertised
599
+ capabilities down to the ones it knows, so an adapter may advertise a
600
+ capability a given driver has never heard of.
601
+ - **A new closed-set value that is gated behind a capability.**
602
+ `subscribe: 'diffs'` is the worked example. Growing a closed set is normally
603
+ breaking, and it still would be here — except the driver only ever selects
604
+ `diffs` for an adapter that announced `tree-diffs` first. An adapter that has
605
+ never heard of the value cannot be sent it, so the gate, not the set, is what
606
+ makes this safe. Extending a closed set **without** such a gate stays
607
+ breaking.
608
+
609
+ **Breaking — needs a coordinated release:**
610
+
611
+ - A new or renamed **required** field on any message.
612
+ - A new member of a **closed set** a reader must accept: message `type`,
613
+ `error.code`, roles, actions, log levels, `subscribe`. These stay strict in
614
+ both directions, precisely so unknown values fail loudly instead of
615
+ acquiring behaviour by accident.
616
+ - Any new field on an **adapter → driver** message. That direction is strict,
617
+ so adding one breaks every driver that has not been updated.
618
+ - Changing the meaning, units or clock of an existing field.
619
+ - **Changing an encoding.** The render marker moved from a private DCS
620
+ sequence to `OSC 8487 … BEL` because ConPTY drops DCS, so the old encoding
621
+ could not work on Windows at all. Every producer and every receiver had to
622
+ change together; `MARKER_DCS_PREFIX`/`MARKER_DCS_FINAL` were replaced by
623
+ `MARKER_OSC_CODE`/`MARKER_OSC_PREFIX` with **no aliases**, because an alias
624
+ would have left two encodings alive and the second one untested. This was
625
+ done pre-publication, as a single generation of producers — the only point at
626
+ which a change of this shape is cheap. Tightening
627
+ `LogRecord.seq` from non-decreasing to strictly increasing is an example:
628
+ nothing about the shape changed, but a sender that repeated a number was
629
+ previously conforming and now is not.
630
+
631
+ The asymmetry is deliberate: *capacity* is negotiated and therefore extensible,
632
+ while *vocabulary* is closed and therefore fixed. When in doubt, ask whether a
633
+ reader that ignores the new thing still behaves correctly. If yes it is
634
+ additive; if it would silently do the wrong thing, it is breaking.
635
+
636
+ Cross-language clients (`clients/`) assert against generated vectors in
637
+ `clients/test-vectors/`. An additive change still requires regenerating those
638
+ vectors, because they pin exact constants.
639
+
640
+ ## Development
641
+
642
+ ```sh
643
+ pnpm --filter @termwright/protocol build # tsup, ESM + d.ts
644
+ pnpm --filter @termwright/protocol typecheck # tsc --noEmit
645
+ pnpm --filter @termwright/protocol test # vitest
646
+ pnpm --filter @termwright/protocol test:hostile # same suites, 128 MB heap cap
647
+ ```
648
+
649
+ `test:hostile` pins the worker's old-space to 128 MB via `execArgv`, so
650
+ resource-exhaustion cases fail closed instead of passing by virtue of a large
651
+ default heap.