@jarenjs/app 0.34.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.
@@ -0,0 +1,804 @@
1
+ # The Jaren App Format
2
+
3
+ **Version 0.1 — Specification**
4
+
5
+ Module: `@jarenjs/app`. This document is the contract for the Jaren
6
+ **app document** — a complete interactive application as one JSON value
7
+ — and for the loop that runs it. It builds directly on three published
8
+ contracts: [QUERY-FORMAT](../../json/docs/QUERY-FORMAT.md) (action and
9
+ `when` documents), [JSLT-FORMAT](../../json/docs/JSLT-FORMAT.md) (the
10
+ view stylesheet) and [VIEW-FORMAT](../../view/docs/VIEW-FORMAT.md) (the
11
+ vnode output vocabulary and renderer behavior).
12
+
13
+ ## 1. Introduction
14
+
15
+ ### 1.1 The loop
16
+
17
+ ```
18
+ event → binding (§4) → action document (§3) → transition (§3.2)
19
+ → invariant check (§6) → next state → subscriptions refresh (§5.3)
20
+ → batched re-render (view stylesheet → vnodes → keyed DOM patch)
21
+ ```
22
+
23
+ Everything the loop executes is compiled once, when the app document is
24
+ loaded. JavaScript participates only at **named, registered
25
+ boundaries**: effect handlers, subscription handlers, event-field
26
+ extractors (§5.4), registered widgets (§5.5), the `compileTypeTest`
27
+ hook and the `validateState` hook. Between the boundaries, everything
28
+ is data.
29
+
30
+ ### 1.2 Conformance and normative language
31
+
32
+ The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**,
33
+ **MAY**, and **OPTIONAL** are to be interpreted as described in
34
+ RFC 2119. A **producer** emits app documents and MUST emit documents
35
+ valid per §2–§5. A **runtime** (the reference implementation is
36
+ `createApp`) MUST reject invalid documents with the compile errors of
37
+ §10, MUST raise the runtime errors of §10 under the conditions
38
+ specified there, and MUST serialize dispatches per the transaction
39
+ model of §8.
40
+
41
+ ## 2. The app document
42
+
43
+ ```json
44
+ {
45
+ "$app": "0.1",
46
+ "state": { },
47
+ "view": [ ],
48
+ "actions": { },
49
+ "subs": [ ]
50
+ }
51
+ ```
52
+
53
+ - **`$app`** (OPTIONAL) — the format version. Absent implies `"0.1"`.
54
+ - **`state`** (OPTIONAL) — the initial state, any JSON value. Absent
55
+ means the state starts `undefined`; a document SHOULD provide it.
56
+ The state MUST be treated as immutable by every boundary function.
57
+ - **`view`** (REQUIRED) — a JSLT stylesheet document (bare rule array
58
+ or envelope form), compiled per JSLT-FORMAT. Each render evaluates it
59
+ against the current state; its output MUST be a single text or
60
+ element vnode.
61
+ - **`actions`** (OPTIONAL) — an object mapping action names to action
62
+ documents (§3).
63
+ - **`subs`** (OPTIONAL) — an array of subscription entries (§5.2).
64
+
65
+ Unknown top-level members MUST be ignored (forward compatibility).
66
+
67
+ ## 3. Actions
68
+
69
+ ### 3.1 The action document
70
+
71
+ An action is a Jaren JSON Query document evaluated with:
72
+
73
+ - **`$`** — the current state (the whole document; cross-cutting
74
+ transitions are the point, exactly as in `x-form` rules);
75
+ - **`$event`** — when the dispatch originated from a DOM event, a
76
+ serializable slice of it (below); otherwise `null`;
77
+ - **`$payload`** — the dispatch payload (§4); `null` when absent.
78
+
79
+ These three names are the whole ambient vocabulary. The runtime takes
80
+ the query's **first** result (`query.first` semantics).
81
+
82
+ For a DOM dispatch, `$event` is the default slice
83
+ `{ "type", "value", "checked", "key" }` **plus** one member per field
84
+ name the binding requested (§4's `event` member). Each requested name
85
+ is resolved in precedence order:
86
+
87
+ 1. a host extractor registered under that name
88
+ (`options.eventFields`, §5.4) — the host wins, so a host can also
89
+ *override* a built-in;
90
+ 2. the built-in allow-list below;
91
+ 3. neither — the member is bound `null` and `JA2009` is reported,
92
+ **without dropping the dispatch**: a typo in one field name MUST NOT
93
+ swallow the user's event (contrast §4's `JA2001`, where the whole
94
+ binding is unusable).
95
+
96
+ The built-in allow-list — every entry is a JSON primitive by
97
+ construction, `undefined` coerced to `null`:
98
+
99
+ - read from the event: `shiftKey`, `ctrlKey`, `altKey`, `metaKey`,
100
+ `button`, `buttons`, `clientX`, `clientY`, `offsetX`, `offsetY`,
101
+ `pageX`, `pageY`, `screenX`, `screenY`, `movementX`, `movementY`,
102
+ `deltaX`, `deltaY`, `deltaMode`, `code`, `repeat`, `location`,
103
+ `isComposing`, `detail`, `pointerId`, `pointerType`, `pressure`,
104
+ `isPrimary`;
105
+ - read from `event.target`: `selectionStart`, `selectionEnd`.
106
+
107
+ `target` itself, `files`, touch lists, and every other
108
+ host-object-valued field are deliberately excluded: host objects never
109
+ enter `$event` — it MUST survive `JSON.stringify`, the same invariant
110
+ as state. When a host needs data only such an object can provide (file
111
+ selections, say), it registers an extractor that maps the object to a
112
+ JSON value at the boundary (§5.4).
113
+
114
+ ### 3.2 The transition object
115
+
116
+ An action's result MUST be nothing (empty sequence, `null` — a no-op)
117
+ or a **transition object** with any of:
118
+
119
+ - **`state`** — the next state, whole. Replaces the state.
120
+ - **`patch`** — an RFC 6902 JSON Patch, applied copy-on-write to the
121
+ state (after `state`, when both are present). Because the action is a
122
+ query document, op members like `value` and `path` are themselves
123
+ query expressions — computed at dispatch time from `$`, `$event` and
124
+ `$payload`.
125
+ - **`effects`** — an array of effect invocations (§5.1), run after the
126
+ state settles, in order, regardless of whether the state changed.
127
+
128
+ Order of application: `state`, then `patch`, then the invariant check
129
+ (§6), then `effects`, then (if the state reference changed)
130
+ subscription refresh and render scheduling. A failing `patch` aborts
131
+ the whole transition (`JA2004`): the copy-on-write engine guarantees
132
+ the state was never touched.
133
+
134
+ Change detection is **reference identity**: a transition whose result
135
+ is `===` the current state changes nothing, schedules nothing. The
136
+ copy-on-write patch engine and JSLT's structural sharing make reference
137
+ inequality mean real change, end to end — this is the same contract
138
+ VIEW-FORMAT §5.1 builds its fast path on.
139
+
140
+ One exception keeps controlled inputs correct: a transaction whose
141
+ **source is a DOM event** always schedules a **settlement** render, even
142
+ when it changes nothing (a no-op, a rejected or failed action, an
143
+ effects-only outcome). A user keystroke can move a controlled input off
144
+ authoritative state, and without a render the control would keep the
145
+ user's value; the settlement render lets VIEW-FORMAT §3 reassert it. It
146
+ is cheap — unchanged state re-projects to a reference-equal vnode the
147
+ patcher skips whole, leaving only the controlled reconciliation. A
148
+ programmatic `dispatch` is not a DOM event and does not force it.
149
+
150
+ A patch-only transition additionally yields its **changed paths**: the
151
+ runtime applies the patch with the engine's `changes` option and hands
152
+ the resulting JSON Pointers (invalidation-sound semantics, see the
153
+ `@jarenjs/json/patch` documentation) to state subscribers —
154
+ `listener(state, changes)` — with `changes = null` for whole-state
155
+ transitions, meaning "treat everything as changed". Dirty-path-pruned
156
+ re-rendering builds on this feed (roadmap); today the runtime itself
157
+ still re-runs the full view stylesheet per frame.
158
+
159
+ ## 4. Event bindings
160
+
161
+ A vnode `on` binding (opaque to the view layer) is interpreted by this
162
+ format as either:
163
+
164
+ - a **string** — an action name; `$payload` is `null`; or
165
+ - an object **`{ "action": name, "with"?: payload, "event"?:
166
+ [fieldName, ...], "preventDefault"?: bool, "stopPropagation"?:
167
+ bool }`** — `$payload` is the `with` value, verbatim, and `event`,
168
+ when present, MUST be an array of strings naming the extra `$event`
169
+ fields to resolve (§3.1). The string binding form carries none of
170
+ the optional members.
171
+
172
+ The two **native controls** default to `false` and are allowed only on
173
+ the object form. When declared `true` **and the named action is
174
+ registered**, `event.preventDefault()` / `event.stopPropagation()` run
175
+ **synchronously in the native event callback**, before the action is
176
+ queued (§8) and before the browser can perform its default or bubble
177
+ behavior — a checkbox, link or button nested inside a clickable row is
178
+ expressible declaratively. Only a registered action owns the native
179
+ behavior: an unknown action name suppresses nothing (the dispatch
180
+ still queues and reports `JA2001`). A registered action that later
181
+ fails keeps its already-applied controls — whether the action succeeds
182
+ or fails cannot retroactively change an already-performed native
183
+ control. A widget's `emit(binding,
184
+ nativeEvent)` follows the identical path. Headless dispatch with an
185
+ event object lacking the methods is a documented no-op.
186
+
187
+ `$event` extraction runs synchronously at dispatch time too: the
188
+ native event is reduced to plain JSON before the transaction enters
189
+ the queue, so a recycled event object can never corrupt a queued
190
+ dispatch. Requesting a **default member** (`type`/`value`/`checked`/
191
+ `key`) never overwrites it — it is already bound; a registered
192
+ extractor still wins, the host chose to redefine it. A requested
193
+ `__proto__`/`constructor` member binds as an ordinary own data
194
+ property and never mutates a prototype. Registered extractors are
195
+ trusted host code and MUST return JSON.
196
+
197
+ Payloads are built at *render* time by the view stylesheet: a rule
198
+ body may embed `"$path"`, the matched value, or anything in scope
199
+ inside the binding. Combined with §3's `$event`, this replaces both of
200
+ hyperapp's payload-creator forms with data. Extraction requests are
201
+ render-time data the same way: a JSLT rule builds them, so they are
202
+ serializable, schema-checkable and replayable.
203
+
204
+ ```json
205
+ ["tr", { "on": { "click": {
206
+ "action": "selectRow",
207
+ "with": { "id": "$.id" },
208
+ "event": ["shiftKey", "ctrlKey", "metaKey"]
209
+ } } }, "…"]
210
+ ```
211
+
212
+ The action then branches on `$event.shiftKey` to extend a selection
213
+ range instead of replacing it.
214
+
215
+ Anything else dispatched as a binding — including an object whose
216
+ `event` member is present but not an array of strings — is a producer
217
+ error (`JA2001` at runtime): the binding is unusable and the dispatch
218
+ is dropped.
219
+
220
+ ## 5. The boundaries
221
+
222
+ ### 5.1 Effects
223
+
224
+ An effect invocation is `{ "run": name, "with"?: props }`. The runtime
225
+ resolves `name` in the registered effect handlers
226
+ (`options.effects[name]`) and calls `handler(props ?? null, dispatch)`.
227
+ Effects are fire-and-forget from the loop's perspective; asynchronous
228
+ completion re-enters through `dispatch`. An unregistered name is
229
+ `JA2006`; a throwing handler is `JA2007`; neither aborts the loop or
230
+ the remaining effects. For asynchronous work, the shipped task
231
+ convention — request identity, stale-response rejection, cancellation,
232
+ and the `createTaskEffect` helper — is documented in
233
+ [TASKS.md](TASKS.md).
234
+
235
+ ### 5.2 The derivation boundary
236
+
237
+ `options.viewModel` (OPTIONAL) maps the state to the view stylesheet's
238
+ input document before every render; default identity. It MUST be pure
239
+ (state in, document out, no dispatching) and is where JS-computed
240
+ derivations — `buildFormViewModel` from `@jarenjs/forms`, aggregations
241
+ the query language cannot express, memoized joins — enter the render
242
+ path without entering the state. This is the app-level generalization
243
+ of forms' layer-2 rule evaluation, kept at a boundary for the same
244
+ reason: derivations are recomputed, never dispatched.
245
+
246
+ ### 5.3 Subscriptions
247
+
248
+ A subscription entry is `{ "run": name, "with"?: props, "when"?:
249
+ query, "withQuery"?: query, "key"?: query, "for"?: query }`. After
250
+ boot and after every state change, the runtime evaluates each entry's
251
+ `when` by **effective boolean value** against the current state (no
252
+ externals) and reconciles:
253
+
254
+ - newly live → `cleanup = handler(props, dispatch)`;
255
+ - newly dead → `cleanup()` if the handler returned one.
256
+
257
+ An absent `when` means always live while the app runs; `stop()` kills
258
+ all. Error policy, the mirror image of forms' `visible` rule: a
259
+ broken `when` fails **closed** — the subscription stops and the error
260
+ is reported — because a broken rule must never keep side effects
261
+ alive. An unregistered `run` name is `JA2008`, reported each time the
262
+ entry would start.
263
+
264
+ **Static versus dynamic props.** `with` is verbatim data — never
265
+ evaluated, and a static entry never restarts (the original contract,
266
+ preserved exactly). `withQuery` makes the entry DYNAMIC: a query
267
+ compiled like `when` and evaluated against the state per
268
+ reconciliation; its result (empty → `null`) is the handler's props. The
269
+ two are mutually exclusive (`JA0008`), because a member that is
270
+ sometimes data and sometimes executable is how a document becomes
271
+ accidentally executable.
272
+
273
+ **The restart rule.** A live dynamic subscription restarts — stop,
274
+ then start with the new props, within one reconciliation — exactly
275
+ when its **key** changes. The key derives from the resolved props BY
276
+ VALUE (`stableStringify`; structurally equal props share a key
277
+ regardless of member order), or from the explicit `key` query when
278
+ recomputing a deep key per transaction is worth opting out of
279
+ (`key` without `withQuery` or `for` is `JA0008`). A cyclic resolved
280
+ value cannot key and fails closed (`JA2016`) rather than hanging. An
281
+ unchanged key performs zero handler calls. A throwing cleanup is
282
+ isolated (`JA2012`) and never prevents the restart's start half; a
283
+ throwing start leaves the slot stopped (`JA2013`) until the next key
284
+ change retries it. This is the same "key plus supersede policy" shape
285
+ `createTaskEffect` (§9) models for effects — a reader who knows one
286
+ knows the other.
287
+
288
+ **Fan-out.** `for` names a query whose result is the entry's ITEM set
289
+ (one array value fans out over its elements; a single item over
290
+ itself; empty over none), and the runtime maintains **one instance per
291
+ item key** — `stableStringify(item)`, or the `key` query with `$item`
292
+ bound. `withQuery` (also with `$item`) shapes per-instance props;
293
+ without it the props ARE the item; `with` beside `for` is `JA0008`.
294
+ Reconciliation is deterministic: removed instances stop in their
295
+ previous order, then added — and changed, by resolved props value —
296
+ instances start in the document order of the item sequence; duplicate
297
+ keys collapse to the first occurrence. A resolution failure fails the
298
+ whole declaration closed (`JA2016`, naming the member). Exceeding
299
+ `maxSubInstances` (a `createApp` option, default 256) is `JA2017`, and
300
+ the previous instance set is KEPT — the bound is printed, never
301
+ silent.
302
+
303
+ **Scope.** Dynamic members are compiled closed-world: the only
304
+ external a document may reference is `$item`, and only under `for` —
305
+ any other free variable is `JA0008` at compile time. Reconciliation
306
+ runs when the state changed, which is precisely correct for
307
+ state-derived keys and props; a key derived from anything outside the
308
+ state is NOT supported, and no member sees the DOM, the clock or the
309
+ host.
310
+
311
+ ### 5.4 Event-field extractors
312
+
313
+ `options.eventFields` (OPTIONAL) is a `Record<string, (nativeEvent:
314
+ any) => any>` of named JavaScript extractors — the same philosophy as
315
+ effects and subscriptions: JavaScript enters only at named, registered
316
+ boundaries. When a binding requests a field (§4), an extractor
317
+ registered under that name takes precedence over the built-in
318
+ allow-list (§3.1). The extractor receives the native event and MUST
319
+ return a JSON value; `undefined` is coerced to `null`. An extractor
320
+ that throws surfaces as the dispatching action's `JA2002`.
321
+
322
+ This is the escape hatch for everything the allow-list deliberately
323
+ cannot serialize. The worked example: a `"fileTokens"` extractor that
324
+ stows `event.target.files` in a host-side registry and returns opaque
325
+ string tokens, so a form can dispatch a file selection without a
326
+ `File` object ever entering `$event` or the state:
327
+
328
+ ```javascript
329
+ // Token identity is MONOTONIC (or a UUID): a registry key is never
330
+ // reused, so a stale token can never rebind to a different File.
331
+ let nextFileToken = 1;
332
+ const fileRegistry = new Map();
333
+ createApp(doc, {
334
+ node,
335
+ eventFields: {
336
+ fileTokens: (event) =>
337
+ Array.from(event.target?.files ?? [], (file) => {
338
+ const token = `file:${nextFileToken++}`;
339
+ fileRegistry.set(token, file);
340
+ return token;
341
+ }),
342
+ },
343
+ });
344
+ ```
345
+
346
+ A binding `{ "action": "pickFiles", "event": ["fileTokens"] }` then
347
+ binds `$event.fileTokens` to `["file:1", ...]` — JSON all the way —
348
+ and an upload effect later redeems the tokens at the boundary.
349
+
350
+ The registry the host builds around those tokens needs an explicit
351
+ lifecycle, because the tokens in state outlive the objects they name:
352
+
353
+ - **identity** — monotonic or UUID, never derived from the registry's
354
+ current size; a token is never rebound to a different `File`;
355
+ - **per-attempt identity** — each upload attempt gets its own request
356
+ id; a failed or canceled attempt may retain the same session-owned
357
+ `File` for an explicit retry;
358
+ - **consume** — a successfully committed file is removed from the
359
+ registry (and its object URLs revoked);
360
+ - **discard/revoke** — canceling the selection, closing the owning
361
+ route/session, or `app.destroy()` revokes every remaining token;
362
+ - **expiry** — a bounded retention policy, so an abandoned selection
363
+ cannot hold file handles forever; a redeemed-but-expired token MUST
364
+ fail safely (a structured error), never select another file.
365
+
366
+ ### 5.5 Widgets
367
+
368
+ `options.widgets` (OPTIONAL) is a `Record<string, WidgetDef>` of
369
+ registered widget definitions for `jaren-widget` vnodes — JavaScript
370
+ enters only at named, registered boundaries, the same sentence shape
371
+ as effects and subs. The mechanism lives entirely in `@jarenjs/view`
372
+ (the vocabulary, lifecycle and reconciliation semantics are
373
+ VIEW-FORMAT §7); `createApp` only forwards the registry to the
374
+ renderer it creates — the dependency arrow stays one-way. A widget's
375
+ `emit` delivers ordinary §4 bindings, so widget events dispatch
376
+ through the same `handleBinding` path as any DOM event — including
377
+ §4's `event` extraction member.
378
+
379
+ ## 6. Invariants
380
+
381
+ When `options.validateState` is present, every candidate next state
382
+ that differs (by reference) from the current state is passed to it. A
383
+ rejection — `false`, or an object with `valid: false` — blocks the
384
+ transition entirely (**fail closed**, matching `x-form.assert`): the
385
+ state does not change, no effects run, and `JA2005` is reported with
386
+ the hook's `errors` in `detail`.
387
+
388
+ The intended hook is a compiled `@jarenjs/validate` schema — including
389
+ `$query` cross-field assertions, so generated or replayed transitions
390
+ are checked for internal consistency, not just shape. The app package
391
+ never imports the validator; like forms, the application holds the key
392
+ (`compileTypeTest` for schema operators inside documents,
393
+ `validateState` for the authoritative check).
394
+
395
+ ## 7. Serialization, replay, SSR (non-normative)
396
+
397
+ Because state, view, actions and subs are one JSON value and every
398
+ dispatch is `(name, payload)` — also JSON — the following are
399
+ compositions, not features: state snapshots, action logs, time-travel
400
+ (replay the log through a fresh `createApp` — with the LIVE effect and
401
+ subscription handlers stubbed or replaced, since replaying a log
402
+ re-runs its transitions but must not re-fire real side effects;
403
+ replaying recorded effect *outcomes* is an open follow-up, not a
404
+ shipped capability), server rendering
405
+ (`renderToString(app.getVnode())`), and remote mutation (ship a
406
+ transition's `patch` over the wire). Runtimes SHOULD keep it that way:
407
+ any extension that puts a function in the document breaks the format's
408
+ core property.
409
+
410
+ ## 8. The transaction model
411
+
412
+ ### 8.1 One FIFO queue
413
+
414
+ Every dispatch is one **transaction** on one FIFO queue; only the
415
+ queue drain evaluates and applies transitions. A dispatch made from an
416
+ effect handler, a state listener, a transaction observer, a
417
+ subscription handler or callback, a widget `emit`, or any lifecycle
418
+ hook **queues behind the current transaction** — dispatches never
419
+ nest. `setState` (§8.5) is a transaction on the same queue, so nothing
420
+ that changes state runs outside it. Within a transaction the order is
421
+ fixed:
422
+
423
+ 1. action evaluation and state commit;
424
+ 2. effect invocation;
425
+ 3. listener notification (registration order — every listener observes
426
+ every transaction in the same order, with the state produced by
427
+ exactly that transaction and its changed paths);
428
+ 4. subscription reconciliation;
429
+ 5. render scheduling;
430
+ 6. observer notification (§8.3).
431
+
432
+ All six complete before the next transaction begins. Errors from
433
+ isolated sites — listeners (`JA2011`), observers (`JA2011`), cleanups
434
+ (`JA2012`) — are reported through `onError` and never corrupt the
435
+ queue: whatever the sink throws surfaces to the outermost dispatch
436
+ caller only after the drain fully completed. A runaway
437
+ action→effect→action loop is diagnosed as `JA2010` after
438
+ `options.maxTurns` transactions (default 1000) instead of hanging;
439
+ `maxTurns` MUST be a positive finite integer — `createApp` rejects
440
+ anything else (a guard that silently coerces is no guard).
441
+
442
+ `validateState` is host code and may itself fail: a throwing validator
443
+ is isolated as `JA2015` — the transaction fails (its observer record
444
+ carries the code), the original cause is preserved, and the queue
445
+ keeps draining; parked errors from the default rethrowing sink surface
446
+ only after the drain.
447
+
448
+ `validateState` receives a second argument, the transaction context
449
+ `{ previous, action, payload, changes }` — `changes` is the patch
450
+ engine's changed-pointer list, or `null` meaning *unknown: validate
451
+ fully*. Selective validation keyed on `changes` is sound only when the
452
+ hook falls back to a full check for `null`.
453
+
454
+ ### 8.2 Boot, stop, destroy
455
+
456
+ **Boot is a transaction.** Compiling the documents, creating the
457
+ renderer, validating the initial state, starting the initial
458
+ subscriptions, painting the first frame and draining the dispatches
459
+ those handlers queued either all succeed, or every already-acquired
460
+ resource — subscriptions, effect handlers, the renderer (the container
461
+ ends empty, scheduled work becomes a no-op) — is disposed and
462
+ `createApp` throws `JA0007` with the original failure as `cause`.
463
+ `onError` observes individual boot failures first: a sink that
464
+ swallows a subscription-start failure (`JA2013`), the initial-state
465
+ check (`JA2005`/`JA2015` with the boot context `{ previous: null,
466
+ action: null, payload: null, changes: null }`) or an error inside
467
+ queued boot work recovers it and boots the rest; renderer-construction
468
+ and first-frame failures are always fatal; the default rethrowing sink
469
+ aborts boot on any of them. No failure path leaves a live
470
+ subscription, a populated container or an unreturned app handle
471
+ behind. Boot is SCHEDULER-INDEPENDENT: every frame the boot
472
+ transaction produces commits inside the boot window (never on a later
473
+ microtask), so the deferred first `afterRender` always acts on the
474
+ final boot DOM — a boot subscription that changes state and queues a
475
+ focus intent settles identically under the synchronous and the
476
+ default scheduler. Effect-handler identities are snapshotted inside
477
+ the boot rollback BEFORE any resource is acquired: disposal never
478
+ re-enumerates a host registry at destroy time, and an unenumerable
479
+ registry rejects the boot (`JA0007`) while nothing is owned yet.
480
+
481
+ **Subscription startup is resource acquisition.** A slot commits live
482
+ only after its handler returned; a throwing handler leaves the slot
483
+ stopped (`JA2013`). Cleanups run exactly once, a throwing cleanup is
484
+ isolated (`JA2012`) and never skips its siblings. Because dispatches
485
+ queue, a condition flipped by a starting handler is observed by the
486
+ next transaction's reconciliation, which disposes the just-started
487
+ resource through the ordinary stop path — rapid `false`/`true`
488
+ condition changes coalesce per transaction.
489
+
490
+ **`stop()` halts; `destroy()` ends.** `stop()` is one-way and
491
+ nonterminal — there is no resume: it clears the queue, disposes live
492
+ subscriptions and listeners, and permanently ignores further
493
+ dispatches; the renderer and effect handlers stay untouched.
494
+ `destroy()` is terminal and idempotent: `stop()` plus observer
495
+ removal, effect-handler `dispose()` (each handler identity exactly
496
+ once), and renderer destruction — widgets unmount exactly once, the
497
+ container is left empty, and scheduled render flushes become exact
498
+ no-ops.
499
+
500
+ ### 8.3 Transaction observers and diagnostics
501
+
502
+ `app.observe(fn)` delivers one bounded JSON record per settled
503
+ transaction: `{ seq, action, source, status, changedPaths,
504
+ scheduledEffects, durationMs, errorCode }` — `source` is
505
+ `'dispatch'`/`'binding'`/`'effect'`/`'subscription'`, `status` is
506
+ `'applied'`/`'noop'`/`'rejected'`/`'failed'`. Payload/event values are
507
+ included **only** when the app was created with `capturePayloads:
508
+ true` — diagnostics must not leak data by default. A throwing observer
509
+ is isolated (`JA2011`). `createTransactionLog({ limit, redact })`
510
+ packages the bounded ring buffer with a redaction hook and a versioned
511
+ `export()` envelope; the log lives in host memory, never in state.
512
+
513
+ ### 8.4 The post-render focus/measurement queue
514
+
515
+ DOM nodes never enter state; focus, selection and measurement bridge
516
+ through JSON intents naming a `data-ref` attribute token.
517
+ `createFocusEffect({ container })` returns an effect handler whose
518
+ intents `{ ref, op?: "focus"|"select"|"measure", done?, id? }` queue
519
+ during the transaction and flush after the **next committed frame** —
520
+ wire its `flush` as `options.afterRender`. `afterRender` is a
521
+ **committed-live-frame boundary**: it runs exactly once per settled,
522
+ nonterminal committed frame — after the DOM patch *and* after widget
523
+ mounts, so a target born in the same transition is already connected;
524
+ when a widget hook error was parked during the frame, `afterRender`
525
+ runs BEFORE that error is delivered (the frame committed; error
526
+ delivery cannot starve its callback); and it never runs for a pass
527
+ that ended in terminal teardown (there is no live frame to act on).
528
+ Boot is ATOMIC for the callback: frames committed during the boot
529
+ transaction defer, and one `afterRender` fires only after the entire
530
+ boot — queued boot drain included — succeeded. A failed boot never
531
+ fires it, so no post-render side effect can escape a boot that rolled
532
+ back; a deferred callback's own failure follows boot policy (a
533
+ swallowing sink recovers it; an escaping failure rolls back as
534
+ `JA0007`). A missing target is a diagnosable
535
+ `JA2014`, never a silent no-op; sibling intents still resolve.
536
+ `measure` dispatches `done` with `{ id, ref, rect }`, the JSON-reduced
537
+ bounding rect. `app.destroy()` cancels pending intents through the
538
+ handler's `dispose()`. A headless app never flushes — the queue is a
539
+ documented no-op there.
540
+
541
+ ### 8.5 `setState` is a transaction
542
+
543
+ `app.setState(next)` replaces the whole state from OUTSIDE the action
544
+ loop — SSR hydration, or a studio hot-swapping an edited `state` block
545
+ into a running app without a reboot. It runs no reducer and no effects,
546
+ notifies listeners with `null` changed-paths, refreshes `when`-gated
547
+ subscriptions and schedules one render.
548
+
549
+ It is public, so it is a contract, and it holds every guarantee §8.1
550
+ gives a dispatch:
551
+
552
+ - it **takes its turn** in the FIFO queue, so a replacement requested
553
+ from inside a listener, effect or subscription handler queues behind
554
+ the transaction that is running rather than interleaving with it;
555
+ - `validateState` **decides before the commit**, with the context
556
+ `{ previous, action: null, payload: null, changes: null }` — `action`
557
+ is `null` because no reducer ran, and `changes` is `null` because a
558
+ replacement changes everything. A rejected replacement is `JA2005` and
559
+ the state stands;
560
+ - every listener observes the SAME state, in registration order;
561
+ - the turn guard counts it, so a `setState` loop is `JA2010` rather than
562
+ a hang;
563
+ - observers see one record, `source: 'setState'`, `changedPaths: null`;
564
+ - a parked sink failure settles **at the `setState` caller**, not out of
565
+ the next unrelated dispatch.
566
+
567
+ A reference-identical replacement is a `noop`; a call after `stop()` or
568
+ `destroy()` does nothing.
569
+
570
+ ### 8.6 Subscription ownership
571
+
572
+ A subscription slot is OWNED from before its handler runs, not from
573
+ after it returns. A handler is host code and can reach the app surface,
574
+ so it can re-enter reconciliation; a slot that only became live on the
575
+ way out looked startable to that re-entry, started again, and each
576
+ return overwrote the single cleanup slot — leaking every acquisition but
577
+ the last, past `destroy()`. The claim-first rule means one live slot is
578
+ one start and one release, whatever the handler does.
579
+
580
+ The `for` fan-out bound (`maxSubInstances`) stops the WORK, not just the
581
+ retention: enumeration halts the moment the bound is crossed and reports
582
+ `JA2017` with how many items were left. Bounding only what is kept still
583
+ ran every key and props expression of a runaway query first, so the
584
+ limit cost memory and CPU proportional to the mistake it existed to
585
+ contain.
586
+
587
+ ### 8.7 Accessible component contracts (non-normative)
588
+
589
+ The format's accessibility position: **the widget escape hatch is not
590
+ an accessibility escape hatch**, and the primitives above exist so the
591
+ accessible patterns are expressible as data.
592
+
593
+ - **Dialogs**: opening moves focus into the dialog through a §8.4
594
+ intent (`data-ref` on its first control); closing restores it to the
595
+ opener the same way; the dialog element carries `role="dialog"`,
596
+ `aria-modal` and a label. The executable skeleton lives in the
597
+ focus-queue test suite and is the pattern to copy.
598
+ - **Tabs**: a tablist/tab/tabpanel triple is ordinary vnode data —
599
+ `role`/`aria-selected`/`aria-controls` are props like any other, and
600
+ arrow-key movement is a `keydown` binding requesting `key` (§3.1).
601
+ - **Rows with nested controls**: `stopPropagation` on the nested
602
+ binding (§4) keeps a checkbox or link inside a clickable row from
603
+ triggering the row action; `preventDefault` expresses suppressed
604
+ native behavior declaratively.
605
+ - **Widgets** own the complete keyboard, focus and announcement
606
+ behavior of their subtree — the renderer guarantees only mount/
607
+ update/unmount-exactly-once (VIEW-FORMAT §7); everything inside
608
+ is the widget contract's responsibility.
609
+ - These contracts are tested headlessly in this repository, and the
610
+ **lifecycle** half is additionally exercised against a real
611
+ Chromium/Firefox/WebKit matrix on every push (see
612
+ [the website's browser suite](../../website/README.md#browser-tests)):
613
+ boot with landmark semantics, client-side navigation mount/unmount,
614
+ keyboard activation of the router, and rapid route churn, each
615
+ asserting zero page errors under real engine scheduling. What that
616
+ matrix does **not** yet cover — and this document does not claim — is
617
+ the **accessibility** half: dialog focus traps and focus restoration
618
+ under an actual screen reader, AT semantics, and
619
+ `prefers-reduced-motion`. Those remain open and are tracked in the
620
+ roadmap.
621
+
622
+ ## 9. Tasks and host concurrency
623
+
624
+ ### 9.1 The convention
625
+
626
+ The async-task convention (state-side ids + guard-first completion
627
+ actions) is specified in [TASKS.md](./TASKS.md); its host half is
628
+ `createTaskEffect(run, options)`.
629
+
630
+ ### 9.2 Concurrency modes and controls
631
+
632
+ `createTaskEffect` takes a per-slot concurrency `mode`:
633
+
634
+ | Mode | A new start while the slot is busy… |
635
+ |---|---|
636
+ | `"switch"` (default) | aborts the in-flight predecessor; newest wins |
637
+ | `"exhaust"` | is ignored entirely — the double-click-safe commit mode |
638
+ | `"concat"` | queues and runs strictly after — deliberately ordered commands |
639
+ | `"parallel"` | runs concurrently; the consumer owns the merge rule |
640
+
641
+ Whatever the mode, correctness stays visible in JSON state — the task
642
+ slot's monotonic `id` and the completion action's guard remain the
643
+ authority on which response may land. The handler exposes
644
+ `cancel(slot)` (abort in-flight, discard queued), `cancelAll()`, and
645
+ `dispose()` (terminal: nothing dispatches afterwards; called
646
+ automatically by `app.destroy()`). `run` is invoked through a uniform
647
+ promise boundary: a synchronous throw and a non-promise return settle
648
+ through the same path as a rejection/resolution.
649
+
650
+ ## 10. Errors
651
+
652
+ ### 10.1 The host-failure normalization policy
653
+
654
+ JavaScript's `throw` accepts any value — `null`, `undefined`, strings,
655
+ numbers, arbitrary objects — and every host extension point (effects,
656
+ subscriptions, validators, event extractors, listeners, observers,
657
+ widget hooks, error sinks) may produce any of them. One shared policy
658
+ covers them all:
659
+
660
+ - every caught value is treated as `unknown`; no boundary ever reads
661
+ `.message` off a raw caught value;
662
+ - an `Error` instance passes through BY IDENTITY wherever a contract
663
+ promises the original as `cause`;
664
+ - a non-Error value is wrapped in a `HostValueError` whose message
665
+ describes the value **without invoking user coercion** (a hostile
666
+ `toString` is never called) and which retains the original value as
667
+ an OWN `cause` property — set even for `undefined`, so
668
+ `Object.hasOwn(err, 'cause')` distinguishes "threw undefined" from
669
+ "no cause";
670
+ - parked failures use presence records, never the thrown value itself
671
+ as the absence sentinel — a thrown `null` still counts, still
672
+ surfaces, and still surfaces BY IDENTITY to the outermost caller;
673
+ - event extraction reports tagged outcomes: a thrown `undefined` is a
674
+ failure (`JA2002`), structurally distinct from an unknown field
675
+ (`JA2009`);
676
+ - direct and deferred teardown behave identically, all sibling
677
+ listeners, cleanups, queued transactions and renderer teardown
678
+ complete before the first sink failure surfaces, and terminal
679
+ idempotence survives any cleanup failure;
680
+ - the policy is TOTAL: no operation performed while handling a caught
681
+ value may itself escape. Classification (`instanceof Error`) and
682
+ every diagnostic property read (`message`, `name`) run inside
683
+ nonthrowing accessors, and value description uses only untrappable
684
+ conversions — a revoked `Proxy`, a throwing trap, a hostile
685
+ `message` accessor or a booby-trapped `toString`/`Symbol.toPrimitive`
686
+ degrades the *description*, never the guarantee. An `Error` whose
687
+ diagnostics are hostile still passes as `cause` by identity, with a
688
+ safe projected message beside it; a value whose classification
689
+ throws wraps like any non-Error;
690
+ - dual failures stay observable in their own frame: when both
691
+ `afterRender` and a widget hook fail in one frame, a collecting sink
692
+ receives the `afterRender` failure first and the parked hook failure
693
+ second — in that frame, never a later one. Under the default
694
+ rethrowing sink, ALL values the sink threw during one drain cross
695
+ the caller boundary together: one value by identity, several as one
696
+ `AggregateError` over the originals in report order. The collection
697
+ is FRAMEWORK-OWNED — appending performs no reflection or coercion on
698
+ a sink-thrown value, and a host-created `AggregateError` (the
699
+ framework's own envelope message included) stays ONE element by
700
+ identity, never flattened;
701
+ - capability ACQUISITION is part of the boundary: reading an optional
702
+ host method — an effect handler's `dispose`, a widget definition's
703
+ `update`/`unmount`, a registry member — executes host code when the
704
+ host used an accessor or proxy, so the read shares the isolation
705
+ boundary and the failure policy of the invocation. A hostile
706
+ `dispose` lookup is a cleanup failure (`JA2012`) after which later
707
+ disposers and renderer teardown still run; a hostile `unmount`
708
+ lookup is collected like a throwing unmount, every sibling still
709
+ unmounts and the container empties; a hostile `update` lookup
710
+ poisons the widget exactly like a throwing update, the frame
711
+ settles, and the next render replaces it; hostile effect/
712
+ subscription registry reads report `JA2007`/`JA2013` and the loop
713
+ drains on;
714
+ - one `app.destroy()` delivers ONE `JA2012`: a single cleanup failure
715
+ is its cause by identity; several aggregate in occurrence order
716
+ (subscription cleanups, then effect disposal, then the renderer
717
+ walk — whose own frame envelope arrives as one element). This holds
718
+ for an IN-HOOK destroy too: when a widget hook calls
719
+ `app.destroy()` and the renderer teardown defers to the end of the
720
+ active pass, the collector stays open and delivers once, after that
721
+ teardown completes. Outside `destroy()` — `stop()`, per-transaction
722
+ reconciliation — each cleanup failure reports its own `JA2012`, as
723
+ before;
724
+ - task settlement is total: abort classification reads the rejection
725
+ value's `name` through a safe accessor (the signal never suppresses
726
+ — a superseded task's non-abort failure still dispatches, the
727
+ state-side id guard is the authority), every non-abort rejection
728
+ produces exactly one `{ id, error }` settlement dispatch, and a
729
+ settlement dispatch that itself throws is re-raised on its own
730
+ microtask so the host's global error handling observes it — never a
731
+ framework-originated unhandled promise rejection.
732
+
733
+ Compile (`AppCompileError`, thrown by `createApp`; `docPath` is a JSON
734
+ Pointer into the app document):
735
+
736
+ | Code | Condition |
737
+ |---|---|
738
+ | `JA0001` | the app document is not an object |
739
+ | `JA0002` | `view` missing or failed to compile |
740
+ | `JA0003` | `actions` is not an object |
741
+ | `JA0004` | an action document failed to compile |
742
+ | `JA0005` | `subs` is not an array |
743
+ | `JA0006` | a subscription entry is malformed / its `when` failed to compile |
744
+ | `JA0007` | boot failed after compilation (renderer construction, initial-state check, initial subscriptions, first frame or queued boot work); everything acquired was rolled back (§8.2) |
745
+ | `JA0008` | a subscription dynamic member (`withQuery`/`key`/`for`) failed to compile, or the members combine invalidly (§5.3) |
746
+
747
+ Runtime (`AppRuntimeError`, routed through `options.onError`, which
748
+ defaults to rethrowing):
749
+
750
+ | Code | Condition |
751
+ |---|---|
752
+ | `JA2001` | unknown action name, or unusable binding |
753
+ | `JA2002` | an action or `when` document threw while evaluating, or a registered event-field extractor threw (the member is bound `null`; the dispatch is NOT dropped) |
754
+ | `JA2003` | a transition is not an object / `effects` not an array |
755
+ | `JA2004` | a `patch` failed to apply (transition aborted) |
756
+ | `JA2005` | `validateState` rejected the next state (transition blocked) |
757
+ | `JA2006` | unregistered effect name |
758
+ | `JA2007` | an effect handler threw |
759
+ | `JA2008` | unregistered subscription name |
760
+ | `JA2009` | a binding requested an unknown event field (the member is bound `null`; the dispatch is NOT dropped) |
761
+ | `JA2010` | the dispatch loop exceeded `maxTurns` in one drain; the queue was abandoned (§8.1) |
762
+ | `JA2011` | a state listener or transaction observer threw (isolated) |
763
+ | `JA2012` | a cleanup threw while stopping/reconciling/destroying (isolated; a widget `unmount` failing during renderer teardown — deferred teardown after an in-hook `destroy()` included — reports here with the first host cause preserved, after every sibling cleaned up) |
764
+ | `JA2013` | a subscription handler threw while starting; the slot stays stopped |
765
+ | `JA2014` | a post-render intent named a `data-ref` with no rendered target (§8.4) |
766
+ | `JA2015` | the `validateState` hook itself threw (the transaction failed; the queue keeps draining) |
767
+ | `JA2016` | a subscription dynamic query (`withQuery`/`key`/`for`) threw while evaluating — cyclic resolved values included; the subscription failed closed (§5.3) |
768
+ | `JA2017` | a subscription fan-out resolved more instances than `maxSubInstances`; the previous instance set was kept (§5.3) |
769
+
770
+ Wrapped causes are preserved on `error.cause`; compile errors from
771
+ embedded documents keep their own codes (`JQ...`, `JT...`) there —
772
+ with their `docPath`s pointing inside the embedded document, the
773
+ feedback shape a repair loop needs.
774
+
775
+ ## 11. Open items (roadmap, non-normative)
776
+
777
+ - ~~The app-document meta-schema~~ — **shipped**:
778
+ [`schemas/jaren-app.schema.json`](../schemas/jaren-app.schema.json)
779
+ (draft 2020-12, with a mechanically derived draft-07 twin) composes
780
+ the published query and JSLT grammars by `$ref`; register those
781
+ artifacts alongside it. A constrained decoder held to it cannot emit
782
+ a structurally invalid application — and the test suite validates
783
+ the production website's own app document against it.
784
+ - ~~The standard forms stylesheet~~ — **shipped**: `createFormView` /
785
+ `createFormActions` render any `@jarenjs/forms` model through one
786
+ rule set. What remains of the idea is the other half — generalizing
787
+ `x-form`'s derivation vocabulary (`computed`/`visible`) to app-level
788
+ derived state, and letting a stylesheet replace the view-model
789
+ composition itself, which needs a language primitive this format does
790
+ not have (see ROADMAP, `@jarenjs/forms`).
791
+ - ~~Dirty-path-pruned re-rendering~~ — largely **shipped** through the
792
+ other side: the JSLT `memo` option makes unchanged subtrees return
793
+ reference-equal vnodes the renderer skips in O(1); prepass-level
794
+ pruning of match evaluation remains open (see ROADMAP).
795
+ - ~~Unifying the write path~~ — **shipped**: `@jarenjs/json/write`
796
+ `parents: 'create'` + undefined-deletes is now exactly forms'
797
+ `setValueAtPointer`, on the shared copy-on-write kernel.
798
+ - **Async action documents** — the effect→dispatch convention for
799
+ asynchronous work is now documented and helper-backed
800
+ ([TASKS.md](TASKS.md): task-slot identity, stale-response rejection,
801
+ `createTaskEffect`); what remains open is a first-class *awaiting
802
+ disposition* — an action that suspends on an effect's settlement and
803
+ transitions with its result, instead of completing through a second
804
+ dispatched action.