The wire format
Status: stable (see STABILITY.md → "Wire format"). Version: wire format v1 – profile core@1.0, language rev 0.2.0 (see §15 for the version/profile + forward-compatibility contract, and §1.1 for the 0.2.0 revision summary).
This document is the permanent, language-neutral specification of the Fuaran UI tree's JSON wire format. It is the authority; the F# encoder (Fuaran.UI.OpStream.Abstractions.CanonicalJson) and decoder (Fuaran.UI.Ops.JsonDecode) are one conformant host – the reference – of this contract. The other conformant codec hosts (TypeScript, Python, Go, Rust) and any third-party host implement the same contract from this doc + the conformance corpus – without reading F# source. The §11.0 roster is the authoritative list of hosts and their roles (codec host vs native render projection).
The executable conformance suite is the fixture corpus in this repository, indexed by manifest.json – the authoritative enumeration of every fixture family and count. A decoder/encoder pair built from this document alone must pass every fixture assertion the manifest enumerates.
1. Scope and shape
The wire format serialises two top-level artefacts:
| Artefact | F# type | Encode | Decode | Round-trip target |
|---|---|---|---|---|
| Node (a UI tree) | Node<'Msg> | encodeNode | decodeNode | Result<Node<obj>, DecodeError> |
| TreeOp (a tree edit) | TreeOp<'Msg> | encodeOp | decodeOp | Result<TreeOp<obj>, DecodeError> |
Both are produced as a single JSON document (RFC 8259) with no leading/trailing whitespace. The decode side is storage-shape erased: it always yields Node<obj> / TreeOp<obj> because the wire form carries no typed-'Msg information (every 'Msg payload encodes as the "<closure>" sentinel – see §4). Typed callers re-attach a real 'Msg downstream via their own moduleMsgDecoder.
The fundamental conformance property is byte-stable round-trip:
encode(decode(encode(x))) == encode(x) for every value x
The corpus stores encode(x) for each round-trip fixture; a conformant host asserts encode(decode(inputFile)) == inputFile byte-for-byte.
1.1 The 0.2.0 revision (pre-publish coordinated rev – clean break)
The 0.2.0 language rev is a single coordinated change to the canonical bytes, taken while the language is pre-launch so no legacy aliases for the retired names ship (retired vocabulary is a hard decode error, not a deprecation). Sanctioned by §15.4's pre-1.0 posture; every corpus fixture was regenerated on the new bytes in the same change-set. The six strands:
- Rename law – scalar
value, collectionsource. A scalar displayed value is namedvalue(Metric/LabelValueRow/Fact);sourceis reserved for collection feeds (Sparkline/DataGrid/Chart/Map/Select,Binding.Format.source,Binding.Transform.source). The oldMetric.source/LabelValueRow.sourceare not accepted (clean break); the web-priordataalias remains (§3.6). - Filters unification. The parallel
FilterKindDU (TextFilter/ChoiceFilter/RangeFilter/SegmentedFilter) is retired; a filter chip's control is an ordinaryFormFieldKind, and a newFormFieldKind.Rangeabsorbs the dual-thumb range control. A chip control with novaluefield decodes to the auto bindingFilter(<chip name>)– the declarative floor for filters (§3.3). Binding.Filter.defaultValue. The Filter binding gains an optional slot-typeddefaultValue(mirror ofState.defaultValue) – the value the resolver yields before the filter is first written (§3.3).- Bare-string
TextSource.Literalis canonical. The bare JSON string is the encoder's Literal form; the{"$type":"Literal"}envelope moves to the lenient-accept side (§16). - Sentinel omission. The three no-information closure sentinels are off the wire:
Binding.Query.accessor,Binding.Selection.accessor,Action.Dispatch.msg(§4). - Behavioural omitted-when-default. Five behavioural flags join the §3.6 omit-when-default
discipline on BOTH boundaries:
DataGrid.editable(false),Progress.indeterminate(false),Tabs.orientation(Horizontal),Toast.dismissable(true – the one omit-when-TRUE),Callout.dismissable(false); segmentedorientationomission becomes encoder-symmetric.
0.2.1 addendum – the symmetric form-field auto-bind. Strand 2's omission rule extends to its
fixpoint: a Form field whose value slot is absent decodes to
{"$type":"State","key":<the field's own id>,"defaultValue":<the slot's typed placeholder>} and
the encoder symmetrically omits a value that is exactly that auto-binding – so ONE rule covers
the whole control vocabulary: every control may omit value; a filter chip auto-binds
$filters.<name>, a form field auto-binds $state.<field id>. The typed placeholders (empty
string / 0 / false / null-choice / {min 0, max 0} / ISO-empty date) are pinned by the
reference implementations and the form-declarative-minimal fixture; input carrying the explicit
auto-shape normalises to the omitted form (lenient-596-form-explicit-auto-state). Eval-driven:
the first 0.2.0 cohort's largest failure class (38/122 first-time parse fails, every provider) was
exactly this omission – the intent was unambiguous, so the language legalised it.
2. JSON syntax conventions (the canonical encoder rules)
These twelve rules make the encoding deterministic: two structurally-equal inputs (modulo closures) produce byte-for-byte identical output across .NET, Fable, process restarts, and machines. This is load-bearing for hash-chain integrity and for the AI pre-emit wire-shape gate. A conformant encoder MUST follow all twelve; a conformant decoder MUST accept the output and (per rule 2) any key ordering.
-
UTF-8 source, ASCII for control chars only. Structural punctuation is ASCII. Non-ASCII characters inside strings pass through as their literal UTF-8 sequence – no
\uXXXXescaping for non-control characters (escaping would inflate output and confuse the wire-shape gate). -
Object keys are sorted alphabetically by Ordinal comparison on encode (
StringComparer.Ordinal– not culture-aware, not case-insensitive). Empty objects render{}. Decoders MUST accept any key order – the structural shape is what matters, not the byte order. (Encoder enforces order; decoder tolerates any order. Field lookup is by name.) -
Lists / arrays preserve source order. A list is an ordered structure (sibling order matters for layout). Empty arrays render
[]. -
None/ null fields are EXCLUDED from object output. Anoptionthat isNonedoes not render as"key":null– the key is omitted entirely.Some xrenders the unwrappedx. This keeps emissions minimal. Corollary for decoders: an absent optional key meansNone; never synthesisenull.There is NO exception (Phase 677).
nulldoes not appear anywhere in a canonical Fuaran emission. It is not a value in this model: absence is structural, expressed by a missing key. Until 0.2.x the obj-erasedBinding.Staticseam andBinding.State.defaultValuecarved themselves out and emitted"value":null/"defaultValue":null; they now omit the key, and the carve-out is gone. That exception was not free — it leftFuaran.Core.Wire.JValunable to parse two of this corpus's own fixtures, and produced a host bridge that silently turned a null into an empty string.Decoders still ACCEPT
nullat those two positions as a §16 lenient shorthand for absence (models emit null naturally, and the intent is unambiguous), normalising it to the omitted form. Accepting is not emitting:encode(decode(x))never reproduces a null. Everywhere else — the rule 12 structured-payload positions —nullremains a hard decode error. -
Numbers.
- Integers render as decimal with no leading zeroes, no decimal point, no exponent (
42,-7,0). - Floats render in the canonical layout of .NET
Double.ToString("R", InvariantCulture)– the shortest digit sequence that round-trips (parse(toString(x)) == x), laid out as follows. This layout is mandatory for every finite double across the whole range, not implementation-defined outside int53 (Phase 117):- The shortest round-trip significant digits (both .NET
"R"and the JSNumber.prototype.toString()produce the same digit sequence since .NET Core 3.0 / V8 – they differ only in layout, which this rule pins). - Let
ebe the base-10 exponent of the leading significant digit (x = d₀.d₁d₂… × 10ᵉ). Use fixed-point notation iff-4 ≤ e ≤ 16; otherwise scientific notation. - Scientific notation is
<mantissa>E<sign><exp>: an uppercaseE; an always-present exponent sign (+/-); the exponent zero-padded to at least two digits (1E+21,1E-07,1.2345678901234568E+17,5E-324). The mantissa has no decimal point when it is a single digit (1E+21), otherwise a single leading digit then.then the rest (1.602E-19). - This differs from the JS native
String(x)form (lowercasee, no sign-padding, a wider fixed-point threshold), so the TypeScript host normalisesString(x)into this layout (see@fuaran-ui/opsencode.tsformatFiniteDouble); the F# host emits it natively viaToString("R"). The two are byte-identical over the full finite-double range – exercised by themetric-float-*corpus fixtures (1e21, 1e-7, a 17-significant-digit value, an integer > 2^53) and the cross-host property fuzzer.
- The shortest round-trip significant digits (both .NET
- Negative zero collapses to positive zero (
0, never-0). - Special values
NaN,+∞,-∞render as the quoted strings"NaN","Infinity","-Infinity"(RFC 8259 forbids them as bare numbers). See §7 for the decode side.
- Integers render as decimal with no leading zeroes, no decimal point, no exponent (
-
Strings are quoted with these escapes, and only these:
"→\"\→\\- control chars
U+0000–U+001F→\uXXXX(lower-case hex, four digits) - everything else passes through literally – including
/, which is not escaped (RFC 8259 permits\/but does not require it; the canonical form is un-escaped).
-
Booleans render
true/false. -
DU cases render as an object with a
"$type"discriminator (sorted by rule 2 it lands before any lower-case data key) whose value is the case's short name ("Static","Query","EditNode"– never fully-qualified), followed by the case's payload fields. Example:Binding.Static 42.0→{"$type":"Static","value":42}. See §3 for the full discriminator map. -
Tuples render as positional arrays (
(1, "x")→[1,"x"]). -
Closures / function values / unobservable runtime payloads render as the sentinel string
"<closure>". See §4. -
obj-typed values (the remaining erased seams: untypedBinding.Staticstatics, aPropValue.Nativeop value) are best-effort: if the runtime type matches a recognised JSON primitive (string, bool,int,int64,float,float32,DateTimeOffset,DateTime), encode that.DateTimeOffset/DateTimeencode as Unix seconds (int64). Anything else renders the sentinel"<opaque>". No reflection over arbitrary CLR objects. The slot-typedStaticpayloads the language enumerates (options / values / series / markers / row feeds) bypass this rule with typed encodings – see §5 for the table and the residual-opaque boundary. Rule 11 still governs inside a row, at the individual cell. -
Structured JSON payload positions –
Customprops,Action.Notify/SetState/AiToolpayloads,I18nargs, and a wire-formUpdatePropvalue – carry a structured JSON value (JValon the F# host) and round-trip faithfully at any nesting depth within the §21 resource limits: objects re-encode with Ordinal-sorted keys, numbers under rule 5, no"<opaque>"collapse. (This rule read "at any nesting depth" unqualified until §21 landed, which made unboundedness normative and put the format's totality guarantee out of reach – see §21.3.) A JSONnullanywhere inside such a position is rejected at decode (WRONG_TYPE, message naming the rule) – the wire model has no null (rule 4): omit the field instead.
2.1 Reserved $-prefixed keys
Object keys beginning with $ are reserved for this specification and its certified extensions; a host MUST NOT mint one. The spec uses $-keys for the two structural roles a canonical wire needs: the DU discriminator $type (§3) and the versioning-envelope keys $profile / $payload / $requiredProfile (§15). Two properties follow from the reservation:
- Canonical sort position. Because rule 2 sorts keys by Ordinal and
$(U+0024) precedes every letter and digit, a$-key always sorts before any lower-case data key on the same object – so a reserved key is deterministically first, never interleaved with a host's data fields. - Forward tolerance. An unknown
$-key on an otherwise-known kind is treated exactly as any unknown key under rule 2 – ignored on decode, not an error – so a newer spec revision may reserve a further$-key without breaking an older decoder. (This is distinct from an unknown kind, which §15.3's transport-onlyUnknownpreserves; a$-key is object-local metadata, not a kind.)
Host-specific opaque data does not ride a $-key: it rides the sanctioned wire-omitted / extension slots (Node.ExtraAttributes, §9; the theme manifest's $extensions pocket) – those are lower-case, host-filled, and outside the reserved namespace by construction.
3. "$type" discriminator dispatch
Every DU position on the wire is a JSON object carrying a "$type" string + that case's payload fields. A decoder reads $type, dispatches to the per-case parser, and surfaces UNKNOWN_DU_CASE (see §6) for unrecognised discriminators, with an ExpectedShape hint enumerating the valid cases.
3.1 Node envelope
A Node has exactly two required keys – id and kind. state, style, and accessibility are optional and omitted when empty / all-default / None. A fully-default node is just { "id": …, "kind": … }.
{ "id": "<non-empty string>",
"kind": <NodeKind>,
"state": <StateBehaviour>, // optional — omitted when empty
"style": <SemanticStyle>, // optional — omitted when all-default
"accessibility": <Accessibility> // optional — omitted when None
}
state(StateBehaviour) is an object with optional keysonLoading(Node),onEmpty(Node),onError(always the"<closure>"sentinel when present – theErrorPayload -> Nodecallback is unobservable). Omitted entirely from the node when all three areNone(the common case); a decoder restores the emptyStateBehaviouron absence.style(SemanticStyle) is{ "emphasis": <Emphasis>, "tone": <ToneVariant>, "weight": <StyleWeight> }, each a bare enum string (§3.5), plus the Phase 147role/voice. Omitted entirely when all fields are the default (emphasis="Normal",tone="Default",weight="Standard",role/voicedefault); a decoder restores the default on absence. Each ofemphasis/tone/weightis individually omitted-when-default on both boundaries (§3.6, Phase 460), matchingrole/voice: an absent field restores its identity default on decode, and the encoder omits a field at its identity default even when the object is emitted for the other fields.accessibilitycarries optional keyslabel(Binding<string>),labelledBy(NodeId string),describedBy(NodeId string),role(ARIA role string),liveRegion("polite"/"assertive"/"off"),hidden(Binding<bool>). Omitted entirely whenNone.
3.2 NodeKind discriminators (kind.$type)
The kind object's $type is the node's primitive discriminator directly – the wire is flat, with no behavioural-category envelope and no spec wrapper. A node carrying a label/value row is {"$type":"LabelValueRow","emphasis":…,"label":…,"value":…} – the spec's fields hoisted directly under $type, exactly as Custom/ErrorBoundary and every nested DU carry their fields. The four behavioural categories – Layout / Display / Input / Visualisation – are a host-side classification recovered on decode (each primitive belongs to exactly one category), not a level of wire nesting.
The kind.$type is one of – and only one of – the following primitives or structural cases. Anything else is WRONG_NODE_KIND (a dedicated code distinct from UNKNOWN_DU_CASE, because the AI-emission eval surface pattern-matches specifically on "AI emitted something other than a valid node kind"):
| Recovered category | kind.$type ∈ | Payload (hoisted under $type) |
|---|---|---|
| Layout | Box,SplitPanel,Tabs,Stepper,SummaryList,Disclosure,Modal,ScrollArea | the spec's fields (incl. a children array) |
| Display | Heading,Markdown,Metric,Badge,Sparkline,Callout,Progress,Skeleton,LabelValueRow,Fact,Link,Image,List,Toast,CodeBlock,Math,Drawing | the spec's fields |
| Input | Form,Button,FileUpload,Select | the spec's fields |
| Input | Filters | { "items": [ … ] } |
| Visualisation | DataGrid,Chart,Map | the spec's fields |
| (structural) | Custom | { "moduleId", "componentId", "props", "contentHash"?, "exposedNodeIds"? } |
| (structural) | ErrorBoundary | { "child": <Node>, "fallback": <Node> } |
| (structural) | FragmentDecl | { "name": <string>, "body": <Node>, "holes"?: [ <HoleDecl> ], "effect"?: <EffectClass> } |
| (structural) | FragmentRef | { "name": <string>, "args"?: { <holeName>: <FragmentArg> } } |
Every kind.$type is globally unique. The former Grid collision (a Layout grid and a Visualisation data-grid both once named Grid) is fully resolved: the CSS-grid container is now a Box with layout: {"$type":"Grid",…} (Phase 390 – see below), and the data-bound grid is DataGrid (payload GridSpec). This global uniqueness is what lets the wire be flat – a single discriminator unambiguously selects both the primitive and its category.
A primitive's spec fields are emitted directly under $type, with no spec wrapper (e.g. Markdown → {"$type":"Markdown","text":…}; Box → {"$type":"Box","children":[…],"layout":…,"role":…}). Filters carries an "items" array. The corpus is the exhaustive reference for each spec's field set – read nodes/<id>.json for the canonical shape of each.
The Box container (Phase 390 / 459)
The four container near-synonyms (Stack / GridLayout / Dashboard / Card) are unified into a single Box kind, whose layout names how children arrange and whose role names what the container means (driving the HTML element, ARIA landmark, and fuaran-* chrome). BoxSpec carries children (required), layout (required), role (required), and an optional heading (emitted only when Some – the Card heading):
{"$type":"Box","children":[…],"heading":<TextSource?>,"layout":{…},"role":"Group"|"Card"|"Dashboard"|"Separator"}
layout is a discriminated object:
{"$type":"Flex","direction":"Vertical"|"Horizontal","gap":<int?>,"wrap":<bool>}–gapomitted whenNone. (direction+wraprequired.){"$type":"Grid","cols":<int>,"gap":<int?>,"templateColumns":<string?>}–gap/templateColumnsomitted whenNone. (colsrequired; aSome templateColumnssupersedescols.){"$type":"Auto"}– responsive auto-tile (the retiredDashboard's renderer-owned behaviour; no author column count).
The four canonical corners (byte-exact): stack → {layout:{$type:Flex,direction,wrap},role:"Group"}; gridLayout → {layout:{$type:Grid,cols},role:"Group"}; dashboard → {layout:{$type:Auto},role:"Dashboard"}; card → {layout:{$type:Flex,Vertical,false},heading,role:"Card"}. See nodes/stack-1.json, nodes/glayout-1.json, nodes/dash-empty.json, nodes/card-1.json.
Retired container tags are rejected, as are Spacer / Divider. The four superseded container $type tags (Stack / GridLayout / Dashboard / Card) and the superseded Table tag are hard-retired (Phase 673): a bare "$type":"Stack" is a decode error, not an upgrade. They briefly decode-upgraded to Box / DataGrid for permalink and op-stream compatibility; that seam was removed once measurement showed nothing depended on it (no persisted artefact carried the tags, and across 6,561 eval runs no model emitted one without being taught it). This restores §1.1's stated 0.2.0 posture — retired vocabulary is a hard decode error, not a deprecation — which the upgrade seam had quietly contradicted. The two leaf display primitives Spacer and Divider were hard-retired (Phase 459) with no legacy seam: Spacer → the container gap; Divider → a childless Box with role:"Separator" (<hr>/role="separator"; DividerSpec.Orientation → the box's layout axis, DividerSpec.Label → the box's heading). A bare "$type":"Spacer" / "Divider" is rejected (UNKNOWN_DU_CASE), and the corpus carries no Spacer/Divider fixtures.
Vocabulary-completion primitives (Phases 287–293)
The Wave-43 "last-10%" primitives, canonical shapes pinned by the named fixtures:
Image(Display) –{"$type":"Image","alt":<TextSource>,"src":<Binding>,"variant":"Default"|"Avatar"|"Rounded"}.srcis aBinding<string>; the renderer routes it through the §19 URL-scheme floor – sanitisation is a render-time obligation, not a wire constraint, so a URL that fails the floor is still a valid wire document.altis mandatory. Seenodes/image-1.json.List(Display) –{"$type":"List","items":[<TextSource>,…],"ordered":<bool>}. Seenodes/list-1.json.Divider– retired (Phase 459) into a childlessBoxwithrole:"Separator"(see "TheBoxcontainer" above). A bare"$type":"Divider"is rejected (UNKNOWN_DU_CASE); there is nodivider-1.jsonfixture.Toast(Display) –{"$type":"Toast","dismissable"?:<bool>,"message":<TextSource>,"open":<Binding>,"tone"?:<ToneVariant>}. 0.2.0:dismissableis omitted-when-TRUE (a toast is dismissable unless said otherwise – the one inverted default in §3.6's table). Seenodes/toast-1.json.Modal(Layout) –{"$type":"Modal","children":[<Node>,…],"dismissable":<bool>,"heading"?:<TextSource>,"onDismiss"?:<Action>,"open":<Binding>}.onDismissis a wire-survivableAction(likeFormSpec.onSubmit– encoded as the action value, not a<closure>sentinel), OPTIONAL since Phase 426: omitted, a dismissable modal falls to the write-back default (dismiss writesfalseto a writableopenslot).headingomitted whenNone. Seenodes/modal-1.json.ScrollArea(Layout) –{"$type":"ScrollArea","children":[<Node>,…],"orientation":"Vertical"|"Horizontal"|"Both","maxHeight"?:<int>,"maxWidth"?:<int>}. The pixel bounds omit whenNone. Seenodes/scroll-1.json.CodeBlock(Display, Phase 290) –{"$type":"CodeBlock","code":<string>,"copyable":<bool>,"highlightLines":[<int>,…],"language":<string>,"lineNumbers":<bool>}. All five always present (highlightLinesis an int array, possibly empty). The parity-checked render is a deterministic<pre><code>(HTML-escaped, no markdown library) identical across all hosts + SSR; syntax highlighting is a client-only post-hydration enhancement that targets thelanguage-{x}class – explicitly OUTSIDE the cross-host / SSR↔CSR byte-diff. Seenodes/code-1.json.Math(Display, Phase 293) –{"$type":"Math","display":"Inline"|"Block","source":<string>}.sourceis the LaTeX string. The parity-checked render is a deterministic escaped-source fallback in a known container; KaTeX is a client-only post-hydration enhancement (targets.fuaran-math-source), OUTSIDE the byte-diff – the no-JS / SSR reader sees the source, the JS reader sees rendered math. Inline$…$math in prose is a separate client-only pass over rendered markdown (soft-coordinated with the deterministic GFM markdown renderer), same pattern. Mermaid is NOT a node – a host registers it via the existingCustomescape (heavy JS-only library, non-deterministic SVG); promote to a first-classDiagramnode only if demand warrants. Seenodes/math-1.json.
Drawing primitive (Phase 524)
Drawing (Display) – a bounded, typed vector-graphics primitive: the shared render target every
Chart lowers to and the reusable substrate for maps/diagrams. DrawingSpec hoists under $type:
{"$type":"Drawing","shapes":[<Shape>,…],"style":<DrawStyle>,"viewBox":<ViewBox>,"title"?:<TextSource>,"description"?:<TextSource>}.
title/description omit when None (the accessible name / long description the renderer emits as
role="img" + <title>/<desc>, Phase 525). See nodes/drawing-1.json (all shapes) and
nodes/drawing-empty.json (the degenerate empty drawing).
ViewBox–{"height":<number>,"minX":<number>,"minY":<number>,"width":<number>}, the user-space coordinate box (SVGviewBoxsemantics). All four required, plain numbers – aDrawingis a resolved geometric artefact (a chart lowers to concrete coordinates), so geometry is static; onlyDrawStylecarriesBindings.DrawStyle–{"fill"?:<Binding>,"opacity"?:<Binding>,"stroke"?:<Binding>,"strokeWidth"?:<Binding>}, every field OPTIONAL and omitted whenNone(an all-default style is{}).fill/strokeareBinding<string>(colour tokens/literals);strokeWidth/opacityareBinding<float>. Present on every shape (asstyle) and on the drawing root (asstyle).DrawPoint–{"x":<number>,"y":<number>}.Shape– a closed,$type-discriminated DU. There is noPathshape and no raw SVGdstring (the typed-surface guard – §5): a curve is a typed command list.{"$type":"Group","children":[<Shape>,…],"style":<DrawStyle>}(nests shapes under a shared style){"$type":"Rectangle","x","y","width","height","cornerRadius"?,"style"}(cornerRadiusomitted whenNone){"$type":"Line","x1","y1","x2","y2","style"}{"$type":"Polyline","points":[<DrawPoint>,…],"style"}·{"$type":"Polygon","points":[…],"style"}{"$type":"Curve","commands":[<CurveCommand>,…],"style"}{"$type":"Circle","cx","cy","r","style"}·{"$type":"Ellipse","cx","cy","rx","ry","style"}{"$type":"Label","x","y","text":<TextSource>,"style"}
CurveCommand– a closed, typed DU (thePath/d-string replacement):{"$type":"MoveTo","to":<DrawPoint>}·{"$type":"LineTo","to":<DrawPoint>}·{"$type":"CubicTo","control1":<DrawPoint>,"control2":<DrawPoint>,"to":<DrawPoint>}·{"$type":"QuadraticTo","control":<DrawPoint>,"to":<DrawPoint>}·{"$type":"Close"}.- Default-deny by shape. An unrecognised
ShapeorCurveCommand$typeisUNKNOWN_DU_CASE(a typed defect, not a pass-through) – seereject/reject-unknown-drawing-shape.jsonandreject/reject-unknown-drawing-curve-command.json.
Select multi-select (Phase 291). SelectSpec gains two OPTIONAL wire fields: "multiple":true (emitted only when multi-select – omitted when false, so every single-select fixture is byte-identical to the pre-multi-select wire) and "values":<Binding> (the multi-select value binding, a Binding<string list>, emitted only when present). The multi-select change handler is a closure → no separate wire key (the existing "onChange":"<closure>" covers it). Single-select carries value (a Binding<string option>); multi-select carries values instead. See nodes/multiselect-1.json (multi) vs the byte-unchanged nodes/select-1.json (single). A searchable Combobox/autocomplete is noted as a future Select variant, deferred.
Filter chips are FormFieldKind controls (0.2.0 filters-unification; superseding the Phase 423 FilterKind DU). A Filters item is {"kind":<FormFieldKind>,"label":<TextSource>,"name":<string>} – one control vocabulary for forms and filter strips; the retired FilterKind discriminators (TextFilter / ChoiceFilter / RangeFilter / SegmentedFilter) are a hard UNKNOWN_DU_CASE. Two chip-specific rules: (a) auto-binding – a chip control with no value key decodes to {"$type":"Filter","name":<the chip's own name>} (the item's declared name IS the store key), and the encoder symmetrically omits a value that is exactly that auto binding, so the canonical minimal chip is {"kind":{"$type":"Choice","options":…},"label":…,"name":"status"}; (b) the Phase 423 handler mechanics carry over unchanged – an omitted onChange writes $filters.<name> through the host's filter seam, a present "<closure>" wins. Since 0.2.1 the synthesis is symmetric: in a Form, an absent value auto-binds State(<field id>, <typed placeholder>) (see the §1.1 addendum) – the context decides the store, never whether omission is legal. See nodes/filters-1.json / nodes/filters-declarative.json / nodes/filters-segmented.json.
Control write-back default – optional event handlers over writable value bindings (Phase 426). Every value-carrying event handler on the covered controls is an OPTIONAL wire field, generalising the Phase 423 filter-chip onChange mechanics: the FormFieldKind handlers (onChange / onToggle), SelectSpec.onChange + onChangeMulti, TabsSpec.onSelect + onSelectTag, DisclosureSpec.onToggle, and ModalSpec.onDismiss (the one wire-survivable Action in the set – the rest are "<closure>" sentinels). A present handler encodes exactly as before (closure → sentinel; modal action → the action value) and wins at run time; an omitted handler – the shape an AI author emits, and the shape every decoded handler-free control takes – arms the write-back default: when the control's own value binding is directly {"$type":"State"} (→ the renderer's reactive StateStore) or {"$type":"Filter"} (→ the FilterStore, Phase 423), the renderer writes the typed change back to that slot – text/textarea/date → string, number/ranged → number, checkbox → bool, choice/segmented/select → the chosen option (a cleared choice clears the slot), multi-select → the value list (against values), tabs → the clicked index (against activeIndex; with a populated tag overlay, the clicked tag against activeTag), modal → false on dismiss (against open), disclosure → the new open bool (against open). Any other binding shape (Static / Query / Local / Format / …) means no write – the FUARAN069 inert-control check warns at validate time (Binding.Local is exempt: its Phase 62 commit pipeline carries the change). Every pre-426 fixture is byte-unchanged (Some handlers keep their sentinels; onSelectTag / onToggle / onChangeMulti were previously never encoded and only appear for closure-authored specs). Decoders restore Some placeholder from a present sentinel and None from an absent key; the State binding's defaultValue is now decoded through the typed static parser (previously discarded for a typed placeholder – a decoded field reads its own authored default). See nodes/form-declarative.json + nodes/controls-declarative.json (handler-free) and nodes/controls-closure.json (the new closure-authored sentinel keys) vs the byte-unchanged nodes/form-1.json / nodes/tabs-1.json / nodes/select-1.json / nodes/modal-1.json.
Toast vs Action.Notify – the decided split (Phase 289). Both ship; they are complementary, not redundant. Toast is the declarative, in-tree, SSR-rendered notification surface – a real node bound to an open Binding<bool> that hydrates cleanly and participates in the overlay render-fidelity contract (§ below / docs/SSR.md). Action.Notify (a wire-survivable Action that carries {channel, payload} with no rendered node) remains the imperative trigger a host maps to ephemeral chrome. Reach for Toast when the notification is model-driven and must survive SSR + replay; reach for Action.Notify for fire-and-forget host chrome. Adding Toast did not change Action.Notify.
Overlay + overflow render-fidelity contract (Phase 289). Modal / Toast / ScrollArea are render-fidelity-sensitive, so the renderers pin an explicit SSR↔CSR contract: overlays render inline (no React portal), positioned + z-indexed purely by CSS, and a closed overlay stays in the DOM behind the native [hidden] attribute (never an absent node). The server and client therefore emit byte-identical class + ARIA structure (role="dialog"+aria-modal for Modal; role="status"+aria-live="polite" for Toast; role="region"+tabindex="0" for ScrollArea), so React hydration finds the DOM it expects with no mismatch. Focus management is an additive client-only enhancement that does not alter the hydrated DOM. The contract is executable in the SSR-parity corpus (Phase 142). Full narrative: docs/SSR.md.
DataGrid static-table mode (staticRows, Phase 393)
DataGrid carries two surfaces under one discriminator: the ordinary data-bound grid, and a
static read-only table selected by the OPTIONAL staticRows field. This is where the retired
Table kind's surface went (§"The Box container" records the retirement; "$type":"Table" is a
hard decode error, not an upgrade) — one tabular kind now owns both the static and the data-bound
form, so a host implements one decoder and one renderer branch instead of two primitives.
{"$type":"DataGrid","columns":[],"source":{"$type":"Static","value":[]},
"staticRows":{"headers":[<TextSource>,…],"rows":[[<TextSource>,…],…]}}
- Shape.
staticRowsis an object with exactly two REQUIRED fields:headers, an array ofTextSource; androws, an array of rows, each row an array ofTextSourcecells. A missingheadersorrowsisMISSING_FIELD; a non-array in either position isTYPE_MISMATCH. Both arrays may be empty. The wire does not constrainrows[i]to the length ofheaders— a ragged matrix decodes, and cell/header alignment is a renderer concern. TextSourcecells. Headers and cells are fullTextSourcevalues, not bare strings by type — so the bare-stringLiteral(the canonical form since 0.2.0, §16 rule 1),Bound, andI18nall apply inside a static table. Localisation and binding substitution therefore reach table content; this is the reason the mode carriesTextSourcerather thanstring.- Optionality — omitted means data-bound.
staticRowsis emitted only when present (rule 4). Every ordinary data-bound grid omits it, so the canonical bytes of a bound grid are exactly what they were before the mode existed.nodes/grid-1.jsonpins the omitted form;nodes/table-1.jsonpins the present form. - Mode semantics.
staticRowspresent ⇒ the node is a static read-only table: a conformant renderer emits semantic<table>markup from the headers and cells, and ignoressourceandcolumnsentirely. The mode is non-interactive — no row-click surface, no editing, no cell kinds; a static table participates in no store write-back. - What
source/columnscarry canonically. They remain REQUIRED fields of the spec (§13), so a static-mode grid still emits both, carrying the degenerate values the encoder produces for a table that has no data feed and no column model:"columns":[]and"source"an emptyStaticrow feed,{"$type":"Static","value":[]}(§5 — an empty feed encodes[], nevernull). Before Phase 665 this position carried the"<opaque>"sentinel instead; a decoder still accepts that form here, under the same indefinite read-compat rule (§5). An emitter authoring a static table SHOULD write exactly those two values; seenodes/table-1.json, which is the byte-exact canonical corner for the whole mode. A decoder MUST NOT read meaning into either field whenstaticRowsis present.
Parameterised fragments (holes / effect / args)
A FragmentDecl/FragmentRef is an artifact-function: the decl declares typed holes, a ref applies it by binding args. These fields are additive – a zero-hole, pure-deterministic decl omits holes+effect and a zero-arg ref omits args, so a fixed-body fragment is byte-identical to the pre-parameterisation shape (the degenerate case).
holes– an ordered array ofHoleDecl, each$type-discriminated:{"$type":"Value","name":<string>,"space":<HoleValueSpace>,"default"?:<Scalar>}{"$type":"Slot","name":<string>,"kindConstraint"?:<string>}{"$type":"Repeat","name":<string>,"countSpace":<HoleValueSpace>}–countSpaceMUST be a boundedIntRange(totality).
HoleValueSpace–{"$type":"IntRange","min","max"}|{"$type":"FloatRange","min","max"}|{"$type":"StringLen","minLen","maxLen"}|{"$type":"Enum","choices":[…]}|{"$type":"AnyString"}.Scalar(a value default or value arg) – self-describing:{"$type":"Int","value":<int>}|{"$type":"Float","value":<number>}|{"$type":"Bool","value":<bool>}|{"$type":"Str","value":<string>}.effect–{"hostEffect": "Pure"|"ReadsHost"|"WritesHost", "determinism": "Deterministic"|"Clock"|"Random"|"Network"}. Omitted when pure-deterministic.args– an object keyed by hole name; each value is aFragmentArg: aScalarbranch (Int/Float/Bool/Str) for a value arg, or{"$type":"SlotArg","tree":<Node>}for a slot subtree.
See nodes/frag-decl-param.json + nodes/frag-ref-args.json for the canonical shapes; nodes/frag-decl-1.json + nodes/frag-ref-1.json remain the degenerate fixed-body fixtures.
3.3 Nested DU positions
$type-dispatched objects also appear at every nested DU: TextSource (Literal/Bound/I18n), Binding<'T> (Static/Query/Filter/Selection/State/Computed/I18n/Local/Format/Transform/Invoke), Action<'Msg> (Dispatch/Call/Notify/Navigate/SetState/AiTool/Chain/CommitLocal/WriteToClipboard/ReadFileBody/Invoke), CellFormat, CellValue, ColumnWidth, Format, LocaleSource, FormFieldKind, CellKindErased, LocalFlushTrigger. Each renders {"$type":"<CaseName>", …fields}, with two 0.2.0 exceptions: TextSource.Literal's canonical form is the bare JSON string (the {"$type":"Literal","text":…} envelope stays decode-accepted and normalises down, §16), and Action.Dispatch renders the bare {"$type":"Dispatch"} (no msg sentinel, §4). Field names and presence are pinned by the corpus.
Binding.Transform (Phase 282) is the declarative-compute case – a serialisable dataframe transform evaluated client-side as data: {"$type":"Transform","pipeline":<array>,"source":<object>}. source is a columnar data source (an embedded {schema, columns} table – column-oriented, a values array + a validity mask per column – or a {schema, ref} host-resolved named source); pipeline is an ordered array of $type-discriminated transform steps (filter / project / derive / groupBy / join / window / pivot / unpivot / sort / distinct / limit / union, each over a scalar ColExpr algebra). Both sub-trees are Fuaran.Core values serialised in this same canonical discipline (§2), so they splice in byte-stably; their detailed per-step shape is owned and conformance-certified by Fuaran.Core's own codec, and the schema (§13) describes them structurally (array / object) rather than re-deriving the full algebra – the same "don't constrain content the host doesn't decompose" posture as an opaque Static.value (§5). The case is constrained to the row-feed binding at a data-bearing node (DataGrid / Chart / Metric): the host evaluates the pipeline and the result rows resolve as the node's source, in the same row shape §5 defines for a literal feed. See nodes/grid-transform.json for the canonical shape.
Binding.Transform params (Phase 424). The Transform binding gains an OPTIONAL params field: "params":[{"from":<Binding>,"name":<string>},…], each entry binding a ColExpr.param name the pipeline references (a {"$type":"param","name":…} scalar expression, fuaran-core#77) to a scalar Binding source (Filter / State / Static / Selection). Omitted when empty, so a param-free Transform is byte-identical to the Phase 282 wire. The host resolves each param to a Cell, prunes any filter step whose params are unbound (an unset choice filter ⇒ no constraint – the one lenient UI rule), and evaluates the pipeline in that env – so a filter step comparing a col to a param scopes the rows by a live filter/state value, the declarative-data twin of Query.dependsOn. The filter→consumer edge is derived from the pipeline's params, never separately declared. See nodes/grid-transform-param.json (a filter param from a chip) vs the byte-unchanged nodes/grid-transform.json.
Binding.Query dependency edge (Phase 421). The Query binding gains an OPTIONAL dependsOn field: "dependsOn":["status","date-range"], a string array naming the filters that scope this host-computed consumer. Omitted when empty; the degenerate canonical Query is {"$type":"Query","name":…} (0.2.0 – the accessor sentinel is off the wire, §4). The tree owns the dependency edge (so the AI can author it, the validator sees it, the op-stream replays it – restoring symmetry with Binding.Selection); the host accessor closure still owns how it filters – no predicate language enters the tree (that is Transform.params, Phase 424, for declarative data). On a filter-store change, a renderer re-resolves every Query whose dependsOn names the changed filter. Note the paired decoded-accessor fix: a decoded Query accessor is now an identity projection (F# unbox, TS (raw) => raw), so a host-populated queryResults.<name> value flows through decoded trees (previously it was discarded). See nodes/query-dependson.json.
Action.Call result target (Phase 428). The Call action's onResult closure is OPTIONAL on the wire (present → the "<closure>" sentinel, byte-identical to before; the closure wins at run time), and the case gains an optional declarative result target: "into":{"$type":"State","key":…} (the response lands in the reactive $state.<key> slot – Binding.State readers re-render) or {"$type":"Query","name":…} (the response lands in the queryResults slot <name> – Binding.Query readers re-render, data-preserving per the Phase 421 identity accessor). Both omitted is a fire-and-forget command call (FUARAN073 warns). A failed / undecodable call never reaches the target – the host's Call implementation surfaces it (the default browser host warns) and the slot stays unwritten, so readers keep their onLoading surface. The endpoint set + the default-deny dispatch gate are unchanged – into adds no new capability, only a destination. Canonical shape: {"$type":"Call","endpoint":…,"into"?:…,"onResult"?:"<closure>"}. See nodes/call-into.json (closure / into-State / into-Query side by side).
Binding.Invoke / Action.Invoke (Phase 283) are the invocable-capability cases – the binding dispatches a host-registered compute capability for a value, the action for an effect: {"$type":"Invoke","args":[{"addr":<string>,"value":<string>}…],"capabilityId":<string>}. capabilityId references a capability the host registry enumerates (the compute analogue of node-introspection); args are scalar (addr, value) pairs the host validates against the capability's signature before dispatch (default-deny by shape). The body is never on the wire – only the typed declaration + this invocation. A Binding.Invoke's value is async (a Deferred) and renders through the existing StateBehaviour surface (onLoading until ready, onError on failure) – no new node concept, no Deferred wire DU. A non-deterministic invocation's realized value is journaled through the determinism-capture seam for exact replay.
FormFieldKind.Date (Phase 288) is the date/time field case: {"$type":"Date","onChange"?:"<closure>","value":<Binding>,"variant":"Date"|"Time"|"DateTime","min"?:<string>,"max"?:<string>,"step"?:<number>} (onChange optional per Phase 426). value is a Binding<string> carrying an ISO-8601 string (YYYY-MM-DD / HH:MM / YYYY-MM-DDTHH:MM per variant); min / max are ISO strings and step is in seconds – all three optional, omitted when None (rule 4), mirroring RangedNumber. See nodes/form-date.json.
Binding.Filter.defaultValue (0.2.0). The Filter binding gains an OPTIONAL defaultValue: {"$type":"Filter","defaultValue"?:<typed static>,"name":<string>}. It is the value the resolver yields – and the renderer seeds the filter store with – before the filter is first written (the pre-selected-filter gap: "default to the last 30 days"). The payload is typed via the slot's own static encoding (the same seam as State.defaultValue, Phase 429); omitted, behaviour is exactly pre-0.2.0 (NotResolved until written). A chip's auto binding (see the filters-unification note above) is Filter(name) with no default – a chip whose control carries an explicit value binding with a defaultValue keeps that value on the wire (the omission rule keys on the exact auto shape).
FormFieldKind.Range (0.2.0) is the dual-thumb numeric range control (absorbing the retired FilterKind.RangeFilter): {"$type":"Range","onChange"?:"<closure>","value":<Binding<float*float>>,"min"?:<number>,"max"?:<number>,"step"?:<number>}. A Static pair rides as the bare {"max":<number>,"min":<number>} object – no Static envelope (the Phase 423 range shape, kept as the canonical bytes); a decoder also accepts the [min,max] two-element array leniently (the §3.6 bare-array coercion) and the enveloped form. In a filter context the value may be omitted per the auto-binding rule. min/max/step bounds are omitted when absent (rule 4).
FormFieldKind.DateRange (0.7.0) is the single-control date range – Range's pair mechanics with Date's value conventions: {"$type":"DateRange","onChange"?:"<closure>","value":<Binding<string*string>>,"variant":"Date"|"Time"|"DateTime","min"?:<string>,"max"?:<string>,"step"?:<number>}. The pair is (from, to), each an ISO-8601 string in the variant's shape (YYYY-MM-DD / HH:MM / YYYY-MM-DDTHH:MM), and it is ordered: a literal pair whose from sorts after its to is a decode error (WRONG_TYPE at the value path, with a message naming the rule – see reject/reject-daterange-unordered.json). Same-variant ISO-8601 strings compare lexicographically in chronological order, so the check is an ordinal string compare – no date parsing, no locale, total for every variant. A bound pair is not checked; its ordering is a runtime concern.
A Static pair rides as the bare {"from":<iso>,"to":<iso>} object – no Static envelope, exactly the Range posture above; a decoder also accepts the [from,to] two-element array leniently (the §3.6 bare-array coercion, lenient/lenient-daterange-bare-array.json) and the enveloped form (lenient/lenient-daterange-static-envelope.json). variant is always emitted; min / max (ISO strings) and step (seconds) bound both ends and are omitted when absent (rule 4), mirroring RangedNumber. In a filter context the value may be omitted per the auto-binding rule, and the pair then binds one filter param, not two – the reason the case exists rather than two coordinated Date fields. See nodes/form-date-range.json (all three variants + bound combinations) and nodes/filters-date-range.json (the auto-bound chip).
Binding.Format (Phase 102) is the locale-aware formatted-value case: {"$type":"Format","format":<Format>,"locale":<LocaleSource>,"source":<Binding>}. source is always a numeric Binding<float>; the case produces a display string (constrained to Binding<string> use). Format is a $type-DU – Number (optional decimals integer), Currency (isoCode string), Percent (optional decimals integer), Date (dateStyle bare-enum), RelativeTime (unit bare-enum). LocaleSource is a $type-DU – Ambient (no fields; defers to the host locale) or Explicit (tag BCP-47 string). Number / Percent omit decimals when None (rule 4).
3.4 TreeOp discriminators (top-level $type)
For decodeOp, the document's own $type is the op kind: EditNode, UpdateProp, ReplaceBinding, UpdateStyle, UpdateState, InsertChild, RemoveNode, MoveNode, ReorderChildren, ReplaceRoot, Batch. See ops/*.json for each shape. ReplaceRoot carries "node": <Node> – the whole new tree; it is the only op that legally changes the root node id (a whole-app swap, vs. a Batch of remove/insert). Batch carries "ops": [ <TreeOp>, … ] (recursive).
Membership and order are separate ops (0.4.0). InsertChild and MoveNode change which children
a parent has, and both append; ReorderChildren states the order by naming ids. Placing a node
anywhere but last is Batch [InsertChild …, ReorderChildren …].
{"$type":"InsertChild","child":{…},"parentId":"grid"} // appends
{"$type":"MoveNode","newParentId":"grid","target":"card"} // appends under the new parent
These two ops previously carried an integer position / newPosition. The rule that removed it:
where a collection's members have identity, they are addressed by it. Every node has an id, every
other op addresses by one, and ReorderChildren already stated order that way — so the ordinal was
the one place the structural surface departed from the tree's own identity model. It also named
something the tree does not store: children are a list, so order is structural and no index exists in
the state. An index is therefore a projection over that list, meaningful only against one snapshot of
it and silently wrong after any preceding or concurrent edit, where a wrong id fails loudly.
This does not apply to contained data. Columns[i], Fields[i], TabHeaders[i], YFields[i]
and the like stay positional: those are bounded payload collections inside one node, not tree
structure, and their items have no identity to address. An ordinal is legitimate exactly where
identity is absent.
Migration window. A decoder currently ACCEPTS AND IGNORES a legacy position / newPosition on
these two ops, so a stored v1 emission still applies (as an append) while hosts adopt independently.
That tolerance is a migration mechanism, not a second dialect: nothing in this spec, the corpus, or
the prompt pack offers the field, and a conformant encoder must never write it. The window closes
when every host is positionless, after which the field is a decode error.
UpdateProp.path grammar – nested addressing (Phase 364)
UpdateProp carries "path" as a plain JSON string at codec level – the codec does not validate
the grammar (any string decodes; grammar violations surface at apply time as structured
ApplyErrors, never at decode time). The grammar every conformant apply engine implements:
path := segment ( "." segment )*
segment := field ( "[" index "]" )?
field := [A-Za-z_][A-Za-z0-9_]* ; a spec-record field name (PascalCase, the §4b vocabulary)
index := "0" | [1-9][0-9]* ; 0-based decimal, no sign, no leading zeros
Semantics: segments resolve left-to-right against the target node's spec record through per-kind
typed traversal (no reflection – each kind's dispatch table declares its nested legs). field[i]
indexes a list-typed field; an indexed segment may itself be the leaf when the list's elements are
scalars (YFields[1] sets the second y-field string). The applied value gets the same type-checking
a top-level path gets.
List addressing is positional-only ([i]) – decided. The sub-node lists this grammar exists for
(Columns, TabHeaders, Fields, YFields) carry no element identity, and the only
identity-bearing lists (Children-like lists of Node) are already addressable directly: a child
node is targeted by its own NodeId via any node-targeted op, so id-keyed list addressing would add
no reach. An id-keyed form (e.g. Children[#some-id]) is reserved syntax – a # in an index
position is PathInvalid today and MUST NOT be given another meaning by a host.
The v1 nested surface (typed-traversal legs; everything else that is grammatically valid but
untraversable surfaces PathNotSupportedYet with the kind's supported paths in the hint):
| Kind | Nested path | Leaf type (value shape) |
|---|---|---|
DataGrid | Columns[i].Label | string |
DataGrid | Columns[i].Field | string – Phase 425, the row property projected to the cell; optional, sibling of the value closure (closure wins when both present) |
DataGrid | RowKeyField | string – Phase 425, the row property for stable row identity; optional, sibling of the rowKey closure |
DataGrid | Columns[i].Format | CellFormat ($type object) |
DataGrid | Columns[i].Width | ColumnWidth ($type object) |
Chart | YFields[i] | string (indexed scalar leaf) |
Tabs | TabHeaders[i].Label | TextSource ($type object) |
Tabs | TabHeaders[i].Disabled | Binding<bool> ($type object; installs Some) |
Tabs | TabHeaders[i].Icon | IconSource (raw string; installs Some) |
Form | Fields[i].Label | TextSource ($type object) |
Form | Fields[i].Required | bool |
Form | Fields[i].Help | TextSource ($type object; installs Some) |
Closure-bearing sub-fields (Columns[i].Value, Columns[i].Kind, Fields[i].Kind, …) are not
addressable – same posture as every closure slot (§4). Children-list edits stay with the
structural ops (InsertChild / RemoveNode / MoveNode / ReorderChildren); Children[i]… paths
are deliberately not traversed.
Apply-time error mapping (codes per ERROR_CODES.md; every hint enumerates the alternatives at
the failing segment so an AI consumer recovers in one turn):
| Failure | Code | Hint carries |
|---|---|---|
malformed syntax (empty segment, bad index literal, missing ], reserved #) | PathInvalid | the grammar + the kind's nested-path patterns |
list segment without an index (Columns.Label) | PathInvalid | the indexed form (Columns[i].…) + valid index range |
| unknown root / leaf field at any segment | FieldNotFound | the available fields / sub-paths at that segment |
index outside 0 ≤ i < list.Length | PositionOutOfRange | the valid index range |
| grammatically valid but no typed-traversal leg | PathNotSupportedYet | the kind's supported top-level fields + nested patterns |
| value doesn't coerce to the leaf's type | KindMismatch | the expected-type detail |
Codec round-trip note. This grammar changes no wire shape (the path was always a string), so
the profile stays core@1.0 (§15). The op's value payload is a structured JSON position
(rule 12): an object-valued UpdateProp value (a $type object such as a CellFormat) decodes
structurally, applies correctly, and re-encodes byte-identically – object-valued nested ops
round-trip like every other fixture. (Historical note: pre-PropValue the F# encoder collapsed
object values to "<opaque>" on re-encode; that defect is closed, and object-valued round-trip
fixtures now pin the faithful behaviour.)
3.5 Bare-string enums
These DUs encode as a bare JSON string (not a $type object), matching the renderer's emission:
Orientation:"Vertical"/"Horizontal"BadgeVariant:"Neutral"/"Brand"/"Success"/"Warning"/"Critical"/"Info"ButtonVariant:"Primary"/"Secondary"/"Tertiary"/"Destructive"HeadingVariant:"Standard"/"Eyebrow"/"Caption"/"Lead"ToneVariant:"Default"/"Subdued"/"Brand"/"Success"/"Warning"/"Critical"/"Info"StyleWeight:"Compact"/"Standard"/"Spacious"Emphasis:"Quiet"/"Normal"/"Loud"ChartKind:"Line"/"Bar"/"Area"/"Pie"/"Scatter"/"Heatmap"AriaRole: the raw ARIA string ("button","link","dialog", …;AriaRole.Custom rawemitsraw)LiveRegionKind:"polite"/"assertive"/"off"HashStrictness(insideCustom.contentHash.strictness):"StrictReplay"/"AdvisoryWarning"DateStyle(insideFormat.Date.dateStyle):"Short"/"Medium"/"Long"/"Full"RelativeTimeUnit(insideFormat.RelativeTime.unit):"Second"/"Minute"/"Hour"/"Day"/"Week"/"Month"/"Year"FileReadEncoding(insideAction.ReadFileBody.encoding):"Text"/"Base64"/"DataUrl"
An unrecognised bare-enum string is UNKNOWN_DU_CASE at that path (e.g. tone: "Magenta").
3.6 Stylistic fields – omitted-when-default + lenient-ingest (Phase 460)
The stylistic slots on the spec decoders – format (CellFormat), tone (ToneVariant),
weight (StyleWeight), emphasis (Emphasis), and width (ColumnWidth) – are
omitted-when-default on the decode boundary: an absent field restores its identity default,
exactly as role/voice do inside SemanticStyle (Phase 147). This is the required-vs-omittable
seam of the Phase 426/430 declarative-floor doctrine, applied to style instead of behaviour: an
emission carrying only the semantic fields (label, value, kind) is a complete, valid tree.
Identity-default table (absent ⇒ this value; a present explicit-default value keeps decoding, read-compat):
| Field | Type | Identity default | Sites |
|---|---|---|---|
format | CellFormat | None | MetricSpec, ColumnErased, LabelValueRowSpec |
tone | ToneVariant | Default | MetricSpec, SemanticStyle, ToastSpec, CalloutSpec, ProgressSpec, FactSpec |
weight | StyleWeight | Standard | MetricSpec, SemanticStyle |
emphasis | Emphasis | Normal | MetricSpec, SemanticStyle, FactSpec |
width | ColumnWidth | Auto | ColumnErased |
default | ToneVariant | Default | CellKindErased.TonedPill – the tone for a value the map does not mention (Phase 750) |
editable | bool | false | GridSpec (DataGrid) – 0.2.0 |
indeterminate | bool | false | ProgressSpec – 0.2.0 |
dismissable | bool | false | CalloutSpec – 0.2.0 |
dismissable | bool | true | ToastSpec – 0.2.0; the one omit-when-TRUE (a toast is dismissable unless said otherwise) |
orientation | Orientation | Horizontal | TabsSpec, FormFieldKind.SegmentedChoice – 0.2.0 (encoder-symmetric) |
CellFormat's own per-case payloads (Currency.code, Date.format, SignificantDigits.digits)
stay required – only the parent field is omittable, never a DU payload. LabelValueRowSpec.emphasis
is a bool (behavioural, not the style DU) and stays required – out of this seam's scope.
Scope note (symmetric omit-when-default). The seam is symmetric on both boundaries: a conformant decoder restores the identity default when the field is absent, and a conformant encoder omits the field at its identity default – a default-styled tree round-trips minimal, and the corpus fixtures pin the omitted form as the canonical bytes. Input carrying an explicit identity default still decodes (read-compat) but re-encodes to the omitted form, so it is a lenient-accept normalisation case (§16), never a round-trip fixture.
SchemaGenmarks these fields optional.
Lenient-ingest aliases (decode-only; never encoded). A curated set of common synonyms decode to
the canonical case (a re-encode always normalises back to the canonical name, and SchemaGen stays
strict-canonical – aliases never appear in the schema or the conformance corpus):
| DU | Alias in | Canonical |
|---|---|---|
ToneVariant | Positive | Success |
ToneVariant | Danger, Negative | Critical |
ToneVariant | Neutral | Default |
Emphasis | Strong, Bold | Loud |
Emphasis | Subtle, Muted | Quiet |
StyleWeight is deliberately not aliased: Bold/Heavy is font-weight intent, but the language's
Compact | Standard | Spacious means layout density – any mapping would silently misread the
author. With weight omitted-when-default and the vocabulary documented, a model that doesn't know
the cases simply omits the field (the correct outcome); an unknown case still fails loudly with the
UNKNOWN_DU_CASE expected-case list.
2026-07-17 additions (same law; observed authoring data from the Kimi/GPT smokes):
| DU | Alias in | Canonical |
|---|---|---|
HeadingVariant | Default | Standard |
BadgeVariant | Default | Neutral |
BadgeVariant | Danger | Critical |
ButtonVariant | Danger | Destructive |
Orientation | Row, row | Horizontal |
Orientation | Column, column | Vertical |
HeadingVariant's other observed guesses (Title, Page, Section) stay rejects – their mapping
is ambiguous (Standard? Lead?), so an alias would guess the author's intent.
Lenient-ingest FIELD-NAME aliases (decode-only; 2026-07-17). The same law extended from enum
values to field names, driven by observed authoring data (a model emitting Navigate wrote the
destination under href – the dominant web name for the concept – twice, identically). An alias is
admitted only when the foreign name denotes the same concept at the same semantics; a name that
betrays a different concept is refused (Progress.value/percent vs fraction: the 0–100 prior
would silently mis-scale by 100×). The canonical name wins when both are present; re-encode always
normalises to the canonical name. Pinned cross-host by the lenient/lenient-alias-* fixtures:
| Site | Alias in | Canonical |
|---|---|---|
Action.Navigate | href, url, to | route |
Action.Call | url | endpoint |
Binding.Query | deps, dependencies | dependsOn |
Binding.State | initialValue, default | defaultValue |
MetricSpec / LabelValueRowSpec | data | value – 0.2.0 rename law (scalar=value, collection=source); the retired source name is a hard error, NOT an alias (pre-launch clean break) |
SparklineSpec / ChartSpec | data | source |
SelectSpec | options, data | source |
GridSpec (DataGrid) | data, rows | source |
MapSpec | data, markers | source |
Grid layout | columns | cols |
ColumnErased | type | kind |
ColumnErased | header, title | label |
| form field | name | id |
Box / Modal / Disclosure / SummaryList / Callout | title | heading |
CellKindErased.TonedPill | toneMap, tones | map – 2026-07-30 (Phase 750); map is the shortest honest name for a value→tone dictionary and the least descriptive |
(title is scoped: Chart.title and Drawing.title are real canonical fields and take no alias.)
The enum-value aliases above apply inside a TonedPill's map values and its default exactly
as they do at a tone field (Danger→Critical, Positive→Success, Neutral→Default) – the map
values are an ordinary ToneVariant position, and a host that read them through a second, private
tone reader would diverge. Pinned by lenient/lenient-tonedpill-tone-aliases.
Lenient-ingest SHAPE coercions (decode-only; 2026-07-17). The same admission law extended from
names to structure, driven by observed authoring data (two independent models emitted a choice
chip's options as a bare string array, omitting both the Binding envelope and the option
objects). A shape is coerced only when the foreign shape can denote exactly one canonical value:
| Site | Coerced shape | Canonical |
|---|---|---|
any Binding<'T> slot | bare JSON array | {"$type":"Static","value": <array>} – every Binding case is a $type-discriminated object, so an array can only mean Static (covers options: ["A","B"], the HTML select prior, and data: [1,2,3], the Chart.js prior) |
any Binding<'T> slot | bare JSON scalar (string / number / bool) | {"$type":"Static","value": <scalar>} – same unambiguity as the array rule (extended 2026-07-17 second wave on launch-eval evidence: fraction: 0.9, activeStep: 1) |
plain-value field (Progress.indeterminate) | {"$type":"Static","value": v} envelope | the bare value v – the INVERSE confusion (models wrap plain fields); unwrap is unambiguous |
SelectOption element | bare JSON string "A" | {"label":"A","value":"A"} (the HTML <select> prior; label canonicalises as the 0.2.0 bare-string Literal) |
Transform.params | name→binding map {"status": <Binding>} | [{"name":"status","from": <Binding>}] – params are a NAME-KEYED SET (ColExpr.Param lookup), so object key order carries no meaning; also value aliases from at the element |
Grid layout with no cols/columns/templateColumns | {"$type":"Grid"} | {"$type":"Auto"} – the CSS auto-grid prior maps to the language's existing responsive auto-tile layout (accept-and-canonicalise) |
embedded Transform source with no schema | {"columns":{…}} | the explicit-schema form – column types INFER deterministically from the cells (all-int→int, any fractional→float, all-bool→bool, all-string→string; NEVER date/timestamp; empty/mixed → didactic reject). Authority: the Fuaran.Core columnar codec (fuaran-core#88); 2026-07-18 |
embedded Transform source column as a bare array | "amount": [100, 200] | {"values":[…],"validity":[true,…]} – the just-the-data prior; the wire has no JSON null, so a bare array can only mean all-present. Same authority; 2026-07-18 |
grid column kind tagged {"$type":"Pill"} carrying map / toneMap / tones | {"$type":"Pill","field":"status","map":{…}} | {"$type":"TonedPill","field":"status","map":{…}} – "pill" is the word for the thing, so the declarative tone rule arrives under the closure case's tag. Unambiguous: a closure Pill carries only labelFn/toneFn and can never carry a tone map. This coercion prevents silent data loss, not merely a parse failure: before Phase 750 the extra keys were accepted and DISCARDED, so the author's whole intent vanished with no error at any host. 2026-07-30 |
Refused, per the law: the value→label map form ("options": {"A":"Alpha"}) – JSON object key
order IS meaningful for a displayed option list (contrast params, a keyed set, where the map is
admitted); a bare object without $type in a Binding slot – more plausibly a mistyped binding
than a Static value; and null in a Binding slot (ambiguous with absent). Pinned cross-host by
lenient/lenient-shape-* fixtures.
This table is a decoder obligation – it says what is accepted, not which form to write. For which of these forms an author should emit, see §16.1 (Emitter preference).
The Fact kind (same date). The complementary kind the same evidence demanded: a labeled
TEXT fact ({"$type":"Fact","label":…,"value":…} – "Patient: Alice Smith"). Metric stays
numeric-only by design (widening it would leave trend/format semantically dead for text);
Fact.value is a TextSource, so static / Bound / I18n values ride the label vocabulary.
New-kind wire posture: only label + value required; tone/emphasis omitted-when-default on
BOTH boundaries; optional help/icon. Pinned by nodes/fact-1 + lenient-fact-explicit-defaults.
Didactic type errors (same date). A decode error at a type boundary models systematically
cross now NAMES the right kind: a text value in Metric.value appends "a labeled TEXT fact
belongs in Fact". Rationale: the launch eval's repair-pass showed one-turn repairs convert at
70–80% when the error signal is actionable, and 38/115 Metric-string failures were repair-proof
against the bare "expected JSON number" – the error channel is part of the language's teaching
surface, not just its rejection surface.
Same date, the omitted-when-default posture (the identity-default table above) extended to the
segmented orientation field on SegmentedChoice (forms and, post-unification, filter chips):
absent ⇒ Horizontal (the language default and the universal segmented-control prior; observed
omitted in eval emission data). 0.2.0 made this encoder-symmetric – the encoder now omits
orientation at Horizontal on SegmentedChoice AND Tabs (the identity-default table above),
so the omitted form is the canonical bytes. The legacy Stack orientation stays required (no
default is neutral there: vertical and horizontal stacks are both common). Pinned by
lenient/lenient-shape-segmented-orientation-omitted + the regenerated nodes/tabs-1.json.
The declarative floor (Phase 430)
The design principle the 423–428 family enforces, stated once so the next spec author designs against it: closures are overrides, never the floor. Every interactive control's event surface has a declarative default (an omitted handler writes the change back to the control's own writable value binding – State/Filter/Selection store write-back); every data-display accessor has a declarative field-name form (field / rowKeyField); every result continuation has a declarative destination (Call … into); and — Phase 750, the same principle applied to appearance rather than behaviour or data — a cell's value-conditional tone has a declarative form (CellKindErased.TonedPill's field + value→tone map) where the closure Pill erased the rule entirely. That last one is worth naming because it was the longest-standing hole in the floor and the least visible: Pill parsed, validated and rendered on a decoded tree, and rendered every row in the same tone, so the failure looked like a styling omission rather than an inexpressible intent. A slot that only works via a closure is dead on the decoded path – it parses, validates, renders, and does nothing. The machine-checked registry of every closure-bearing slot's posture (WriteBack / FieldName / ResultTarget / HostOnly-by-design) is Fuaran.UI.SlotCapability – a new closure-bearing spec field MUST add its row (the completeness test fails otherwise), and the dead-on-decode lint (Fuaran.UI.DeadOnDecode.lint, FUARAN080/081) flags sentinel slots on decoded trees with the declarative remedy. Relatedly, the queryResults population contract: $queries.* population is a host concern – the host feeds BindingSources.QueryResults, or a declarative Call … into Query <name> (Phase 428) writes it live; decoded trees own the names and edges (Query.name, dependsOn, into), never the fetch itself.
4. Closure-bearing slots → "<closure>"
Every function-typed payload the encoder cannot observe renders as the sentinel string "<closure>". The decoder reconstructs each as a placeholder that re-encodes to the same "<closure>" sentinel, keeping the round-trip byte-stable. The slots are:
Action.Dispatch _→ encodes as the bare{"$type":"Dispatch"}– 0.2.0: themsgsentinel field is OFF the wire (no decoder ever read it; pure token weight). On decodeAction.Dispatch (box "<closure>").Action.Call(endpoint, _, _)→ endpoint string preserved; aSomeonResultis"<closure>"(omitted whenNone– Phase 428; the declarativeintotarget IS wire-carried data, not a closure).Action.ReadFileBody(file, encoding, _)→file.Idcarried as thefileRefstring +encodingas a bare enum; the blob (file.Handle) never serialises andonReadis"<closure>". The decodedFileRefcarriesHandle = None.FormFieldKind.*onChange/onToggle;SelectSpec.OnChange/OnChangeMulti,TabsSpec.OnSelect/OnSelectTag,Disclosure.OnToggle→ emitted only when present (Phase 426 – an omitted handler arms the write-back default); a present sentinel decodes toSomeno-op placeholder.FileUploadSpec.OnSelectandStepperSpec.OnSelectstay always-emitted closures decoding to a no-op action.CellKindErased.*handlers (onEdit/onToggle/onClick/get/labelFn/hrefFn/toneFn/fractionFn/fn).GridSpec.OnRowClick,ChartSpec.OnPointClick,MapSpec.OnMarkerClick→ emitted only when present (rule 4); the value is"<closure>". (There is no separate table spec record: a static table is thestaticRowsmode ofGridSpec(§3.2) and is non-interactive, so it contributes no closure slot.)Binding.Query/Binding.Selectionaccessors – 0.2.0: OFF the wire entirely (the encoder omits theaccessorkey; no decoder ever read it). A decoded case synthesises the identity projection (Phases 421/427), so the host-fedqueryResults/ store-written selection flows through.Binding.Computedfn,Column.Value,GridSpec.RowKeykeep their"<closure>"sentinels.Binding.LocalonCommit/format/parse(theflushOnDU andinitialFrombinding ARE encoded).StateBehaviour.OnError(the wholeErrorPayload -> Nodecallback).
The orchestrator's typed re-attachment happens downstream via moduleMsgDecoder. The decoder is structural; type recovery is the host's responsibility.
Consequence (v1 limitation): two ops differing only in an opaque 'Msg / closure payload (e.g. Dispatch (SelectRow 1) vs Dispatch (SelectRow 2)) hash identically. The hash chain still detects structural tamper (op kind, NodeId, slot, fixed values, tree shape); it does not detect tamper purely inside an opaque payload.
5. Binding.Static payloads – typed forms + the residual "<opaque>" boundary
Typed Static payloads (Phase 429). The Static payload shapes the language itself enumerates encode typed – the encoder emits the same shape the typed decoders parse, so encode ∘ decode is byte-stable AND decode ∘ encode is value-faithful for them. The typed shapes, per slot:
| Slot(s) | Payload type | Typed wire form | Empty / None form |
|---|---|---|---|
FormFieldKind.Choice / SegmentedChoice .options (forms and filter chips), SelectSpec.source | SelectOption list | array of {"label":<TextSource>,"value":<string>} | [] |
the same specs' .value | string option | the plain string | null |
SelectSpec.values (multi-select, Phase 291) | string list | array of strings | [] |
SparklineSpec.source | float seq | array of numbers (rule 5 layout) | [] |
MapSpec.source | MapMarker seq | array of {"label":<TextSource>,"latitude":<number>,"longitude":<number>} | [] |
GridSpec.source / ChartSpec.source – the grid / chart / table row feed (Phase 665) | Row seq, where Row is an open string→scalar map (not a fixed record) | array of row objects – see Row payloads below | [] |
FormFieldKind.Range.value | float * float | bare {"max":<number>,"min":<number>} – no Static envelope (Phase 423 shape, kept at 0.2.0) | – (both bounds always present) |
FormFieldKind.DateRange.value | string * string | bare {"from":<iso>,"to":<iso>} – no Static envelope (the Range posture, 0.7.0); ordered, from <= to by ordinal compare | – (both ends always present) |
The typed encoding applies at the binding's State.defaultValue position too, and recursively through Local.initialFrom – the whole Binding in a typed slot is typed, not just the Static case. This is not a footnote for the row feed: the canonical editable-grid authoring shape is a State-sourced rows array (nodes/grid-editable-state.json), so a host that routes only Static.value through its slot-typed parser leaves the principal case un-normalised. Route every value-carrying Binding arm.
Row payloads (Phase 665). A row feed encodes as a JSON array of row objects – one object per row, its keys the row's field names, Ordinal-sorted within each row like every other canonical object (§2 rule 2). An empty feed encodes [], never null. Cells are scalars under rule 11's recognised set (string / bool / int / int64 / float / float32 / DateTimeOffset / DateTime → Unix seconds); a null cell omits its key (rule 4 – absence is structural, the wire has no null); anything else renders the "<opaque>" sentinel in that cell position only. Decoded numbers surface as one number population, and an integral number renders in integer form per rule 5's shortest-round-trip layout – so a host whose runtime has a single numeric type emits bytes identical to one whose boxed types are exact. (That last point is a conformance requirement, not an optimisation: a host testing int before float will diverge on any runtime where every number satisfies every numeric test. Test float first.) Canonical fixtures: nodes/grid-editable-state.json and nodes/chart-state-rows.json (a State-sourced feed on both node kinds), nodes/grid-1.json and nodes/chart-1.json (a Static-sourced feed).
Rows carry scalar cells only. A cell that is itself an object or an array decodes structurally (so a lenient ingest is not rejected) but is display-opaque and re-encodes as the "<opaque>" cell sentinel – the residual boundary, narrowed from the whole slot to the cell seam.
The residual-opaque boundary (by design). A Static payload the language does NOT enumerate – a host domain record, a PropValue.Native op value, and (per Row payloads above) a non-scalar cell inside a row – still renders "<opaque>" under rule 11's best-effort primitives. This is deliberate: the wire never invents structure for content only the host can decompose; the decoder passes the sentinel through and MUST NOT attempt to reconstruct the original CLR type – the host's per-app schema re-hydrates downstream (moduleMsgDecoder). Nothing else falls through the catch-all silently: a new slot-typed payload shape MUST land its typed encoder + decoder + corpus fixtures in one §11 change-set, or be added to the residual list here.
The row feed LEFT this list at Phase 665, and it was the last enumerable payload on it. The list above is now exactly the content the wire genuinely cannot decompose – a host's own CLR/runtime object. Note what the removal cost: a row is an open name→scalar map, so a host domain record can no longer be a row; an author projects to that map first. That is precisely what makes rows wire-expressible, and it is why the boundary disappeared for the slot rather than narrowing within it. The visible consequence in the corpus is that no fixture under nodes/ carries "<opaque>" any more – the sentinel now appears only in lenient/ inputs (read-compat) and in the two typed placeholder re-encodes below.
Read-compat (indefinite). Two legacy wire forms – what the earlier encoder produced for a slot before it gained its typed form (pre-429 for the options / values / series / marker slots, pre-665 for the row feed) – stay decode-accepted at every typed slot:
"<opaque>"→ a tagged placeholder: options →[ { Value = "<opaque>"; Label = Literal "<opaque>" } ];string option→Some "<opaque>";string list→[ "<opaque>" ]; float / marker seqs → empty; row feeds → the empty feed, re-encoding as[]. A placeholder's re-encode is its typed form (e.g. the one-element placeholder options array) – pinned cross-host by thelenient/lenient-opaque-static-*corpus fixtures, and for rows bylenient/lenient-665-rows-opaque-sentinel(aState-sourced feed) andlenient/lenient-460-explicit-default-column(aStatic-sourced one).null→ the typed empty form ([]/None). This was the pre-429 F# boxes-to-nullasymmetry (box ([] : 'a list)andbox Noneare null references, which the old encoder wrote as JSONnull); pinned bylenient/lenient-null-static-options.
The rows sentinel stays decode-accepted indefinitely, exactly like the two forms above. Every tree persisted, permalinked, or op-stream-logged before Phase 665 carries "<opaque>" in its row-feed position; each such feed decodes to the empty feed and re-encodes as []. This is a deliberate, permanent read-compat obligation on every conformant host, not a migration window – decoding is lenient, but the sentinel is never emitted for a row feed again. The rows are not recoverable (they were never on the wire); what the rule buys is that an old tree still decodes and renders as an empty grid rather than failing.
For a genuinely residual-opaque slot, the old rule still holds: the substituted placeholder must itself re-encode to "<opaque>" (a non-null reference of a non-recognised type). The invariant there remains encode(decode(encode(x))) == encode(x), not value preservation – residual-opaque content is intentionally lost. Since Phase 665 that invariant governs the cell seam and the non-enumerated Static payloads listed above; the row feed itself is now value-faithful, so for it the stronger invariant holds – decode ∘ encode preserves the rows.
Render semantics of an opaque options source (cross-host contract). The placeholder above keeps the codec round-trip byte-stable, but it is not authored data and MUST NOT reach the DOM. For an options-bearing control (Select / Choice / SegmentedChoice – forms and filter chips alike) whose options binding is an opaque/non-array Static source, every conformant renderer emits no concrete options – only the control's own structural placeholder option (the empty-valued – entry where one is rendered). The decoder's [ { Value = "<opaque>"; … } ] placeholder is dropped at render time, never shown as a selectable <option>. The TS host realises this through its asArray coercion (a non-array source resolves to []); the F# host strips the opaque placeholder in resolveOptions. This is a renderer-behaviour contract, not a wire-shape change – the JSON is unchanged and still round-trips identically. (Settled in workspace Phase 131; it superseded the earlier dual-host form-1 parity gap.)
5.1 Wire-survivability boundary (Phase 378)
Sections 4 and 5 define the two erasure sentinels – "<closure>" (a function value) and "<opaque>"
(a non-enumerated Binding.Static payload). This section names the boundary once, across the whole
author-facing vocabulary: which constructs survive the wire faithfully vs which erase and become
invisible to op-stream replay, structural diffing, AiTools introspection, and the TypeScript / Python
hosts.
Verdicts. survivable – round-trips value-faithfully. host-only – the whole case erases to a
sentinel; a decoded / replayed / introspected tree sees an inert placeholder (dead on decode).
partial – a survivable skeleton with a closure/opaque sub-field that erases (the sub-field's
posture is enumerated per-slot in Section 4 and machine-checked by Fuaran.UI.SlotCapability).
This table is a projection of Fuaran.UI.WireSurvivability (the authoritative, code-side
classification), which the WireSurvivability coverage test asserts covers every union case of
every DU below – so a new NodeKind / Binding / Action / ... case cannot ship unclassified. The
build-time Fuaran.UI.Validator WireSurvivabilityCheck (FUARAN084) steers authors off the one
cleanly-detectable whole-case escape, Binding.Computed (advisory when hand-authored, Error in
orchestrated / AI-emitted contexts); the runtime DeadOnDecode lint (FUARAN080/081) covers the
type-dependent cases (opaque Static, closure grid columns).
The declarative escape ladder – every host-only / partial case has a wire-survivable alternative
(the Recoverable alternative column): omit an event handler to arm the renderer's write-back
default (Binding.State / Binding.Filter); use Column.Field + CellFormat instead of a closure
grid column; use Binding.Transform (data derivation) / Binding.Format (formatting) / Binding.State
instead of Binding.Computed; use Action.Call ... into: State/Query instead of an onResult closure.
NodeKind
| Case | Wire | Recoverable alternative |
|---|---|---|
NodeKind.Layout | survivable | – |
NodeKind.Display | survivable | – |
NodeKind.Input | survivable | – |
NodeKind.Visualisation | survivable | – |
NodeKind.Custom | survivable | – |
NodeKind.ErrorBoundary | survivable | – |
NodeKind.Switch | survivable | – |
NodeKind.FragmentDecl | survivable | – |
NodeKind.FragmentRef | survivable | – |
NodeKind.Mount | partial | – |
LayoutKind
| Case | Wire | Recoverable alternative |
|---|---|---|
LayoutKind.Box | survivable | – |
LayoutKind.SplitPanel | survivable | – |
LayoutKind.Tabs | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
LayoutKind.Stepper | partial | – |
LayoutKind.SummaryList | survivable | – |
LayoutKind.Disclosure | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
LayoutKind.Modal | survivable | – |
LayoutKind.ScrollArea | survivable | – |
DisplayKind
| Case | Wire | Recoverable alternative |
|---|---|---|
DisplayKind.Heading | survivable | – |
DisplayKind.Markdown | survivable | – |
DisplayKind.Metric | survivable | – |
DisplayKind.Badge | survivable | – |
DisplayKind.Sparkline | survivable | – |
DisplayKind.Callout | survivable | – |
DisplayKind.Progress | survivable | – |
DisplayKind.Skeleton | survivable | – |
DisplayKind.LabelValueRow | survivable | – |
DisplayKind.Link | survivable | – |
DisplayKind.Image | survivable | – |
DisplayKind.List | survivable | – |
DisplayKind.Toast | survivable | – |
DisplayKind.CodeBlock | survivable | – |
DisplayKind.Math | survivable | – |
DisplayKind.Fact | survivable | – |
DisplayKind.Drawing | survivable | – |
InputKind
| Case | Wire | Recoverable alternative |
|---|---|---|
InputKind.Form | survivable | – |
InputKind.Filters | survivable | – |
InputKind.Button | survivable | – |
InputKind.FileUpload | partial | – |
InputKind.Select | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind
| Case | Wire | Recoverable alternative |
|---|---|---|
FormFieldKind.Text | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.Number | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.Checkbox | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.Choice | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.TextArea | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.Range | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.RangedNumber | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.SegmentedChoice | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.Date | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
FormFieldKind.DateRange | partial | omit the handler – the renderer's write-back default writes the change to the control's writable Binding.State / Binding.Filter value slot |
(The FilterKind table is retired at 0.2.0 – filter chips are FormFieldKind controls; see the rows above.)
VisKind
| Case | Wire | Recoverable alternative |
|---|---|---|
VisKind.DataGrid | partial | use Column.Field + CellFormat instead of a closure Value; RowKeyField instead of RowKey; the click write-back default for OnRowClick. The row feed on source is survivable since Phase 665 (§5) – the remaining erasure in this kind is its closure slots, not its data |
VisKind.Chart | partial | – |
VisKind.Map | partial | – |
GridSpec.staticRows is itself survivable: its TextSource headers and cells round-trip
value-faithfully, so a static table's content is visible to op-stream replay, structural diffing,
and every host. Since Phase 665 the same is true of the row feed a data-bound grid's source
carries (§5), so the two modes no longer differ in survivability — only in meaning (§16.1). There is
no separate table spec record on the wire (§3.2); the retired Table kind's surface lives here.
CellKindErased
| Case | Wire | Recoverable alternative |
|---|---|---|
CellKindErased.Text | survivable | – |
CellKindErased.Numeric | survivable | – |
CellKindErased.Date | survivable | – |
CellKindErased.Editable | host-only | Column.Field + CellFormat for display; interactive edit needs host wiring |
CellKindErased.Checkbox | host-only | – |
CellKindErased.Button | partial | – |
CellKindErased.ButtonGroup | partial | – |
CellKindErased.Link | host-only | Column.Field projecting the href row property + a Text cell |
CellKindErased.Pill | host-only | CellKindErased.TonedPill – a field + value→tone map, fully wire-expressible |
CellKindErased.TonedPill | survivable | – |
CellKindErased.Progress | host-only | – |
CellKindErased.Custom | host-only | – |
CellFormat
| Case | Wire | Recoverable alternative |
|---|---|---|
CellFormat.None | survivable | – |
CellFormat.Number | survivable | – |
CellFormat.Currency | survivable | – |
CellFormat.Percent | survivable | – |
CellFormat.SignificantDigits | survivable | – |
CellFormat.Date | survivable | – |
CellFormat.Custom | host-only | one of the six typed CellFormat cases – they are the declarative set |
Binding
| Case | Wire | Recoverable alternative |
|---|---|---|
Binding.Static | partial | a language-enumerated slot payload round-trips – including the grid/chart row feed since Phase 665; a non-enumerated value (a host domain record, a non-scalar cell inside a row) erases to "<opaque>" – prefer a typed slot, Binding.State / Binding.Filter, or Binding.Transform |
Binding.Query | partial | – |
Binding.Filter | survivable | – |
Binding.Selection | partial | – |
Binding.State | survivable | – |
Binding.Computed | host-only | Binding.State / Binding.Filter for reactive values; Binding.Transform for derivation; Binding.Format for formatting |
Binding.I18n | survivable | – |
Binding.Local | partial | Binding.Format is the declarative twin of the Local format/parse closures |
Binding.Format | survivable | – |
Binding.Transform | survivable | – |
Binding.Invoke | survivable | – |
Action
| Case | Wire | Recoverable alternative |
|---|---|---|
Action.Dispatch | host-only | the substrate actions – Action.SetState / Action.Call / Action.Notify / Action.Navigate / Action.AiTool |
Action.Call | partial | Action.Call with into: IntoState / IntoQuery is the declarative result target |
Action.Notify | survivable | – |
Action.Navigate | survivable | – |
Action.SetState | survivable | – |
Action.AiTool | survivable | – |
Action.Chain | survivable | – |
Action.CommitLocal | survivable | – |
Action.WriteToClipboard | survivable | – |
Action.ReadFileBody | partial | – |
Action.Invoke | survivable | – |
TextSource
| Case | Wire | Recoverable alternative |
|---|---|---|
TextSource.Literal | survivable | – |
TextSource.Bound | survivable | – |
TextSource.I18n | survivable | – |
Design note - the Binding.Computed replacement spike (deferred, Phase 378). Phase 378 assessed
introducing a bounded scalar-expression binding as a wire-survivable replacement for Binding.Computed,
to let the host-only escape retire entirely. Decision: defer, do not adopt now. Rationale: the
declarative, wire-survivable derivation path already exists - Binding.Transform carries a serialisable
Fuaran.Core.DataFrame pipeline as data (no closure on the wire) for data-bearing nodes, and
Binding.Format / Binding.State / Binding.Filter cover formatting and reactive scalars. The residual
gap is only arbitrary scalar expressions, for which no concrete demand is yet recorded, so
Binding.Computed stays as a clearly-marked F#-only escape (named here, in its /// doc-comment, and
flagged by FUARAN084) rather than being replaced speculatively. This is not pre-publish-gated:
Binding.Computed already erases to "<closure>", so keeping it does not shape the frozen wire, and a
future scalar-expression binding would be a purely additive Binding case (a minor-version change per
Section 15.4) - it can land post-publish if demand materialises. Tracked as a candidate follow-on, not a
blocker.
6. DecodeError envelope + the seven codes
Every wire-shape violation surfaces a structured, recoverable error (never a throw). The envelope:
{ "Code": "<one of the seven codes>",
"Path": "<JSONPath-ish location, e.g. $.kind.text>",
"Message": "<human/AI-readable description>",
"ExpectedShape": "<optional hint string>" }
Path uses a $-rooted dotted form; $type appears literally in the path when the discriminator is at fault (e.g. $.kind.$type). The seven codes:
| Code | Raised when |
|---|---|
INVALID_JSON | The input is not syntactically valid JSON (garbage, truncation, empty string). Path is $. |
MISSING_FIELD | A required key is absent on a Node / Spec / Op object. Path names the missing key. |
WRONG_TYPE | A value is present but the wrong JSON kind (e.g. id is a number, children is an object). |
UNKNOWN_DU_CASE | A $type discriminator (or bare-enum string) is not a recognised case. ExpectedShape enumerates valid cases. |
WRONG_NODE_KIND | The top-level kind.$type is not a recognised node kind – i.e. not one of the flat Layout/Display/Input/Visualisation primitives (§3.2) nor Custom/ErrorBoundary/FragmentDecl/FragmentRef. Raised at $.kind.$type. (Distinct from UNKNOWN_DU_CASE for the eval gate-1 surface.) |
EMPTY_NODE_ID | An "id" field is present but the empty string. (Same defect the post-apply validator catches; surfaced at decode time to save the round-trip.) |
LIMIT_EXCEEDED | A §21 resource limit is breached – node depth, JSON depth, string length, array length, or total node count. The input is well-formed JSON; it is refused for being structurally unbounded, which is why this is not INVALID_JSON. Message names the limit and the observed value. |
The 30 reject fixtures in the corpus exercise every code except LIMIT_EXCEEDED, whose fixtures are deliberately deferred until the hosts adopt §21 together (§21.5). Each manifest entry pins the expectedErrorCode and an expectedPath prefix. Node-side rejects additionally populate ExpectedShape; op-side rejects assert Code + Path only.
7. Number-edge handling (decode side)
Symmetric with rule 5. At a float slot, a conformant decoder accepts both forms:
JNumber n→nJString "NaN"→ NaNJString "Infinity"→ +∞JString "-Infinity"→ −∞
Integer slots truncate the parsed number via integer cast; round-trip is exact across the int53 range (any 32-bit int).
8. NodeId invariants
"id"present, non-empty string → ok."id"present but empty string →EMPTY_NODE_ID."id"absent →MISSING_FIELDat$.id."id"wrong JSON kind (number, object, …) →WRONG_TYPEat$.id.
The same rules apply at nested NodeId positions (e.g. an InsertChild child's id → $.child.id).
9. Wire-omitted fields (by design)
Three fields on the Node record are never emitted, and a decoder always sets them to their default:
| Field | Default on decode | Why omitted |
|---|---|---|
Node.Motion (Motion option) | None | Motion is consumer-authored, not AI-authored. |
Node.ExtraAttributes (Map<string,string> option) | None | The "AI-opaque consumer-side hatch" for data-* / aria-* test-hook attributes; the §4d JSON wire shape omits it on emit (see Types.fs ~lines 211–251). |
Node.Accessibility (Accessibility option) | None when the accessibility key is absent | Optional per rule 4; present only when authored. |
A conformant host that emits these fields would diverge from the canonical wire shape and fail the corpus.
10. Known v1 limitations
A conformant host MUST reproduce these exactly so the corpus stays byte-stable across hosts. Any change that closes one of them is a single coordinated change across encoder + decoder + corpus + every host (§11).
10.1 Type fields not carried on the wire
One field exists on the typed surface but is not part of the wire format: the encoder does not emit it, and a conformant decoder restores the type's default. A host MUST NOT expect it on the wire:
ButtonSpec.Tooltip– optionalTextSource; decodes toNone.
Closed by Phase 126 (previously listed here as dropped – now carried, so these round-trip losslessly): ChartSpec.Stacked (bool, carried as stacked), TabsSpec.ActiveIndex (Binding<int>, carried as activeIndex). TabsSpec.OnSelect is a closure – it is now carried as the "<closure>" sentinel (§4) and decodes to a no-op action (its behaviour cannot round-trip, but the slot is no longer silently dropped). A decoder still tolerates the absence of stacked / activeIndex (legacy wire predating the change), defaulting to false / Binding.Static 0.
10.2 Other v1 limitations
- Closures are placeholders (§4); typed re-attachment is
moduleMsgDecoder's job. - Residual-opaque
Binding.Staticvalues lose typed content (§5 – host-typed payloads only; the enumerated slot-typed payloads round-trip value-faithfully since Phase 429, and the grid/chart row feed joined them at Phase 665, leaving only host domain records,PropValue.Native, and non-scalar row cells); the host's per-app schema re-hydrates. AriaRole.Customvs named roles both encode as the raw string (e.g."button"); a decoder cannot distinguishAriaRole.Custom "button"fromAriaRole.Buttonand prefers the named case. Encoder-side discriminator tagging would close this.
11. Forward-coupling rule (load-bearing)
11.0 The conformant-host roster
This table is the single authoritative list of hosts the forward-coupling obligations below (and the §11.1 gate) are defined over. The numbered steps and the gate legs no longer name hosts inline – they reference "every codec host in the roster", so adding a host is a one-line edit here, not a sweep across the steps (the drift class this section closes: the step-5 list rotting behind the host set as Python, Go, and Rust hosts came online).
| Host | Language | Package / repo | Role | Conformance bar |
|---|---|---|---|---|
fuaran | F# | Fuaran.UI.* | codec host – the reference (generates the corpus) | round-trip byte-identity (§11.1 Leg A) |
fuaran-ts | TypeScript | @fuaran-ui/* | codec host | round-trip byte-identity vs the corpus |
fuaran-py | Python | fuaran-py | codec host | round-trip byte-identity vs the corpus |
fuaran-go | Go | fuaran-go | codec host (headless) | round-trip byte-identity vs the corpus |
fuaran-rs | Rust | fuaran-rs | codec host (headless + WASM client) | round-trip byte-identity vs the corpus |
fuaran-swift | Swift | fuaran-swift | render projection over the Rust core – not a codec host | render-coverage over the node corpus |
fuaran-kt | Kotlin | fuaran-kt | render projection over the Rust core – not a codec host | render-coverage over the node corpus |
Codec hosts independently encode + decode the canonical wire and are held to the §11.1
byte-identity legs. Render projections consume a codec host's already-decoded tree for native
rendering only; they never canonically encode, so they carry no byte-parity leg – their bar is a
render-coverage checklist over the node corpus (a new NodeKind lacking a renderer arm is a build
error in that native tier, but is not a wire-conformance failure). A native render surface sits on
the cheap side of the host/surface line precisely because it inherits the certified codec from the
Rust core rather than re-implementing one – the §11 forward-coupling tax lands on fuaran-rs once and
the native surface rides it.
A machine-readable mirror of this roster (plus the generated vocabulary enumerations – see §11.2) is
the intended executable anchor in wire-format-fixtures/manifest.json,
so the roster can be mechanically enforced rather than doc-maintained; until that lands this table is
authoritative.
Adding a case to any discriminator family on the wire MUST, in the same commit, do all of the
following. A discriminator family is any union whose members are distinguished by a $type tag —
NodeKind and the per-kind Specs are the visible ones, but the rule is deliberately stated over the
whole class, because the families nested inside a spec are exactly the ones that get missed:
FormFieldKind (the control vocabulary shared by Form.fields[] and Filters.items[]),
CellKindErased / CellFormat / CellValue / ColumnWidth (grid columns), Binding<'T>,
Action<'Msg>, CallResultTarget, TextSource, Format / LocaleSource, BoxLayout,
Shape / CurveCommand (drawing), HoleDecl / HoleValueSpace / FragmentArg / Scalar
(fragments), LocalFlushTrigger, and TreeOp. A case added to any of them is invisible to the F#
compiler at the wire boundary in every host but the reference, so only the corpus and the §11.2
attestations can catch it.
LayoutKind / DisplayKind / VisKind / InputKind are not separate families for this purpose:
they are the NodeKind primitive groups (WIRE_FORMAT §3.2), and their $types are NodeKind names, so
manifest.kinds already enumerates them. EffectClass is a record, not a $type union. Both are
called out because a list of families that quietly over- or under-counts is the failure this section
exists to end.
- model the case in the IDL — the single source for the F# structural layer. The vocabulary is
declared as data in
fuaran-core(tests/Fuaran.Core.Tests/UiIdl.fs); regenerating (dotnet run --project tests/Fuaran.Core.Tests -- --regen-snapshots) and syncing (fuaran-dotnetscripts/sync-generated-layer.ps1) emits the generatedFuaran.UI.Generatedmodule — the type, canonical encoder, structural decoder andmkconstructor for the case, never hand-edited. There is no hand-written node encoder to update: the F# op codec (CanonicalJson.fs) splices the generated encoder and adding a kind does not touch it. (ATreeOpcase is the exception — the op envelope codec itself is hand-maintained there.) - update the policy decoder (
JsonDecode.fs) — the diagnostics / §16 lenient-accept layer above the generated structural decoder, - update the JSON Schema generator (
SchemaGen.fs) – add the new$typebranch /$defso the schema keeps describing the wire shape exactly, - add a fixture to
Fixtures.fs(or a reject case toRejectFixtures.fs) and regenerate thewire-format-fixtures/corpus +schema.json(dotnet run --project src/Fuaran.UI.JsonDecode.Tests -- --emit-corpus <workspace-root>/wire-format-fixtures– the same command writes the corpus payloads and the schema), and - bump every non-reference codec host in the §11.0 roster to match – the F# reference is covered by steps 1–4; each other codec host gets the same encoder/decoder (+ schema-shape) update: the TypeScript host's
@fuaran-ui/schemashape +@fuaran-ui/uismart-ctor (when applicable) + TS encoder/decoder, and the equivalent codec update in the Python (fuaran-py), Go (fuaran-go), and Rust (fuaran-rs) hosts. The render-projection surfaces (Swift/Kotlin) take a renderer arm, not a codec change – see §11.0, and - update the in-repo authoring veneers + analyzer vocabulary (applies to
NodeKindcases – ops/bindings have no veneer surface): the C# fluent-factory facade (src/Fuaran.UI.CSharp/– a factory + options record for the new kind, plus its conformance expectations), the VB XML-literal mapping (src/Fuaran.UI.VisualBasic/Mapping/– an element registration driving that factory), and the VB analyzer's embedded vocabulary (src/Fuaran.UI.Analyzers/VisualBasic/Vocabulary.cs– the kind name, any new structural sub-elements, and their attribute rows).
Native render surfaces (roster render projections). A NodeKind addition also obliges a renderer
arm in every render-projection surface – the Swift/Kotlin native tiers
render from a sealed/exhaustive tree, so a kind lacking an arm is a build error there. This is the
client-side analogue of step 6's authoring veneers: no codec change (the Rust core owns the codec), a
render arm only. It is not a wire-conformance leg (§11.0) – it is listed here so a vocabulary change's
full obligation set stays derivable from the roster rather than remembered.
A missing case is an UNKNOWN_DU_CASE defect at runtime (the decoder consumes JSON, not the F# DU, so the compiler can't catch it). Encoder and decoder symmetry is load-bearing for hash-chain integrity; both move together. Two CI gates fail if this rule is skipped: the corpus coverage-gate test (Fixtures.allNodes / allOps count == corpus count) catches a fixture added without regenerating the corpus, and the stale-schema guard (SchemaConformanceTests.fs – re-derives SchemaGen.wireFormatSchema and asserts byte-equality with the committed schema.json) catches a contract change that didn't regenerate the schema. The schema-conformance suite additionally asserts every accept-fixture validates and every reject-fixture fails against schema.json using an off-the-shelf Draft 2020-12 validator – so the schema stays a faithful drop-in for external tooling.
Step 6 is pinned by three further gates in the same repo test run: the C# and VB conformance suites' coverage-vs-corpus tests (every node fixture's kind.$type must have a C# authoring factory / a VB XML element – they fire the moment step 4's fixture lands), and the VB analyzer vocabulary-pin test (Vocabulary.Kinds == FuaranXml.KnownElements()). Mind the pin's blind spot: a kind missing from both the VB translator and the analyzer keeps the pin green (the two lists agree on the gap) – it is the corpus anchor that surfaces the omission, which is one more reason step 4's fixture must land in the same commit as the kind. Mount and then Switch both shipped without step 6 and left the repo test gate red for every subsequent session until a follow-up closed each gap; the step is named here so that class of drift dies at authoring time instead.
11.1 Cross-implementation conformance gate (step 5 enforced mechanically)
Steps 1–4 above are enforced inside the F# repo's own test run (coverage-gate + stale-schema guard). Step 5 – keep every non-reference codec host in the §11.0 roster byte-identical – is enforced by pinning each codec host to the committed corpus, so a divergence between any two conformant hosts is caught rather than discipline-maintained. The committed corpus is the F# encoder's canonical output (Corpus.emit writes CanonicalJson.encode* into the expectedFile payloads and the DecodeError code/path into manifest.json); each codec host's leg asserts its own canonical output is byte-identical to that corpus, and X == corpus for every host X proves X == Y byte-for-byte across the roster.
The legs are enumerated from the roster (one per codec host; render projections have no leg – §11.0):
- Leg A –
fuaran(F#):dotnet runtheFuaran.UI.JsonDecode.Testssuite – the current encoder/decoder re-produces the committed corpus byte-for-byte (round-trip), surfaces the canonical reject code/path, and is schema-valid + stale-schema-guarded. ⇒F# == corpus. - Leg B –
fuaran-ts(TypeScript): a Node runner drives the TS encoder/decoder over the same corpus and asserts its canonical output is byte-identical to the F# canonical form and schema-valid againstschema.json(off-the-shelf Draft 2020-12 validator). ⇒TS == corpus. Legs C/D extend this to a generative sample space (FsCheck-emitted trees round-tripped F#→TS and TS→F#). - Leg E –
fuaran-py(Python): thefuaran_pycodec round-trips the corpus byte-for-byte (node + op), surfaces the canonical reject code/path + float layout, re-encodes to schema-valid wire, and holds its offline-snapshot drift guard. ⇒Python == corpus. fuaran-go(Go) /fuaran-rs(Rust): each host pins itself to the same corpus in its own repo's conformance suite (fuaran-go/conformance/,fuaran-rs/tests/conformance.rs), consuming the workspace corpus directly (no bundled snapshot). ⇒Go == corpus,Rust == corpus.
Enforcement topology (current). Legs A–E run in the workspace CI gate .github/workflows/wire-conformance.yml, driving wire-format-fixtures/conformance/; the host repos POST a repository_dispatch on push-to-main so a host-side change fires the workspace gate. The Go and Rust legs run in their own repos' run.ps1 suites today; their workspace CI legs (so a corpus change fails centrally for all five codec hosts, not only F#/TS/Python) are pending. A one-byte divergence in any host's encoder turns its leg red with a per-fixture byte diff naming the fixture, host, and first differing byte. This is the mechanical enforcement of the forward-coupling rule across the roster – see wire-format-fixtures/conformance/README.md.
11.2 Vocabulary attestation (the discriminator-family enumerations)
Step 5's byte-identity legs certify that every host agrees on the fixtures the corpus contains. They say nothing about a case a host has never met — a host that simply lacks a decode arm for a new discriminator still passes every fixture that does not exercise it, and the corpus grows a fixture the unadopted host quietly skips or fails as an ordinary decode error attributable to anything. Vocabulary attestation is the separate leg that names the gap.
manifest.json therefore carries a generated enumeration per attested family, derived from the encoded
node fixtures by Corpus.emit (never hand-authored), and each codec host pins its own declared
vocabulary against it in both directions — the manifest names a case this host lacks and this
host declares a case the corpus does not know. Both failures name the offending case, so the report is
"host X lacks DateRange", not a diff.
| Manifest array | Family | Wire position(s) | Attested in |
|---|---|---|---|
kinds | NodeKind | $.kind.$type, recursively | all five codec hosts (since the kind-set pin landed) |
formFieldKinds | FormFieldKind | Form.fields[].kind.$type, Filters.items[].kind.$type | F#, TypeScript, Python; Go and Rust pending |
Match a carrier by its parent discriminator, never by property name. DataGrid.columns[].kind.$type
is a CellKindErased and shares the token Text with FormFieldKind; a sweep keyed on the property
name kind under any array silently attests the wrong family and reports green.
Every other family named above is unattested: a case added to one of them is caught only by the
fixture it ships with, in the hosts that decode that fixture. Most of their case sets are already
published — schema.json carries a $defs entry per family, with a const $type per case — so
those are extendable without a further manifest change. Two shapes are not directly readable that way
and would need the manifest route this phase took: TextSource, whose §16 bare-string shorthand makes
one oneOf branch a plain string rather than a discriminated object, and any family whose cases are
spread across sibling $defs. The families are enumerated so the scope is a stated one rather than an
assumed one. Adding a row to the table above is the way to close one.
12. Regenerating / consuming the corpus
The corpus is generated from the F# fixture values (the authoritative Node/TreeOp constructions in Fixtures.fs + RejectFixtures.fs):
# from fuaran-dotnet/
dotnet run --project src/Fuaran.UI.JsonDecode.Tests -- --emit-corpus ..\wire-format-fixtures
Layout:
wire-format-fixtures/
├── manifest.json # index: { version, schema, description, fixtures: [ { id, kind, decoder, inputFile, expectedFile?, expectedErrorCode?, expectedPath?, description } ] }
├── schema.json # canonical Draft 2020-12 JSON Schema (see §13) — co-emitted by the same --emit-corpus run
├── nodes/ *.json # 70 canonical Node wire forms
├── ops/ *.json # 21 canonical TreeOp wire forms (incl. the Phase 364 nested-path set)
└── reject/ *.json # 30 malformed inputs
A conformant host's test harness loads manifest.json and, per entry:
kind: "node-round-trip"/"op-round-trip"→ decodeinputFilewith thedecoder-named entry point, re-encode, assert byte-equal toexpectedFile.kind: "reject"→ decodeinputFile; assert the error's code ==expectedErrorCodeand its path starts withexpectedPath.
12.1 Third-party certification kit
Third-party implementations do not need to hand-build the harness above: the published @fuaran-ui/conformance npm package is a packaged certification kit – it bundles a versioned snapshot of this corpus (named in every report by manifest version + SHA-256 content digest), drives a candidate implementation through a small adapter seam (decodeNode / encodeNode / decodeOp / encodeOp, all optional), and emits a per-leg pass/fail report with honest partial-certification semantics for hosts that implement only part of the contract. The certification procedure – what "conformant host" means, mandatory vs optional legs, how to read the report, and the per-corpus-version caveat that follows from §11 – is defined in the TypeScript reference repo's CONFORMANCE.md. The bundled snapshot is byte-synced from this corpus and guarded by the kit's own test suite; when the corpus advances under §11, a new kit release ships the regenerated snapshot and hosts re-certify against it.
13. Canonical JSON Schema artefact (schema.json)
The corpus ships a machine-readable Draft 2020-12 JSON Schema at wire-format-fixtures/schema.json – the third co-equal expression of this contract, alongside this prose spec and the fixture corpus. It is the enabling input for provider-native constrained emission, a drop-in artefact for external validators and editor tooling, and a second executable check on the wire shape.
$id:https://fuaran.dev/wire-format/v1/schema.json. The/v1/segment pins the wire-format major version (see the Version banner at the top of this doc).- Generated, not hand-authored. It is emitted by
Fuaran.UI.Ops.SchemaGen– a structural hand-walk of the same DU surfaceCanonicalJson.fswalks, so it describes the canonical JSON the encoder produces (and the decoder accepts) rather than introducing a parallel contract. It is regenerated by the same--emit-corpuscommand that writes the fixture payloads (§12). - Shape. DU positions encode as
oneOfof branch objects, each pinned by a$typeconstdiscriminator (an unrecognised$typematches no branch – mirroringUNKNOWN_DU_CASE/WRONG_NODE_KIND). Bare-string enums (§3.5) encode as{ "type":"string", "enum":[…] }. Closure slots (§4) are theconst "<closure>". OpaqueBinding.Staticvalues (§5) aretrue(any JSON); structured JSON payload positions (rule 12) are likewisetrue– any JSON exceptnull, which the decoder rejects – the schema deliberately does not constrain content the encoder cannot decompose. Wire-omitted fields (§9, §10.1) are absent from the schema. The schema does not setadditionalProperties:false, matching the decoder's tolerance of unknown keys (§2 rule 2). The top-level schema isoneOf: [ {$ref Node}, {$ref TreeOp} ];$defs/Nodeand$defs/TreeOpare exposed directly for hosts that want to validate one shape. - Conformance.
SchemaConformanceTests.fsvalidates every accept-fixture (must validate) and every reject-fixture (must fail) againstschema.jsonusing an off-the-shelf Draft 2020-12 validator, and runs the stale-schema guard (§11). The schema describes the existing wire shape only – it introduced no change to the canonical JSON (additive-only; the fixture payloads are byte-unchanged by Phase 96).
Canonical IDL vocabulary artefact (idl.json)
The corpus also ships wire-format-fixtures/idl.json – a canonical data rendering of the IDL, the declarative model of this wire vocabulary. manifest.json points at it under the idl key, exactly as it points at the schema under schema.
The two artefacts answer different questions, and neither subsumes the other. schema.json is the validation surface: given a payload, is it legal? idl.json is the structural source: what is the vocabulary? A JSON Schema is lossy about precisely the things a vocabulary consumer needs, because validation does not need them:
- Optionality collapses. Draft 2020-12 can say a property is
required; it has no way to say "omitted when equal to this value". So every omit-at-default field (§3.6) is indistinguishable from a plain optional one in the schema, and the identity default value is absent entirely.idl.jsoncarries a four-way optionality class per field –required/optional/omitDefault(with the default value) /hostOnly– which is what makes §3.6 and §9 mechanically derivable rather than prose-only. - Unions flatten into
oneOfbranches;idl.jsonkeeps the union, its named cases, its type parameters, and its transparent case (the bare-value encoding ofTextSource.Literal, §3.5). - Host-surface declarations have nowhere to live in a schema at all.
Consequences for a consumer:
-
Where they disagree about legality,
schema.jsongoverns – it is the artefact validators actually run, and the fixture corpus is the arbiter above both. -
Today the two are independent expressions of one vocabulary, not one derived from the other.
schema.jsonis emitted by a structural hand-walk of the same DU surface the encoder walks (above);idl.jsonis emitted from the IDL. Each carries its own regenerate-and-byte-compare drift guard, and the shared fixture corpus is what holds them to the same contract. Do not assume a change to one has reached the other. -
hostSurfacekeys are not wire spec. Function-typed slots (fn) and host-codec slots (hosted) carry the host-language declarations the F# and TypeScript tiers generate from. Nothing in them is observable on the wire – the accompanyingwirekey states the fixed wire form ("<closure>", or arbitrary JSON) – and a host building a codec from this artefact must ignore them. -
Shape. A single JSON object:
version(the encoding version, bumped when this artefact's shape changes, never when the vocabulary does),description, thenkinds,unions,enums,records,defaultsandnodeFields(the node envelope, §3.1). Object keys are Ordinal-sorted throughout, per §2 rule 1. Ordering is a contract, so the artefact is diffable: the top-level collections are sorted by identity (kinds by tag; unions, enums and records by name; defaults by kind then field), so reordering a vocabulary declaration produces no diff and an addition lands as one clean insert – while within an entry the declared order is preserved verbatim, because union-case fields and type parameters are positional and a reorder there is a real change. -
Generated, not hand-authored – and not by the
--emit-corpuscommand that writes the fixtures andschema.json(§12). The encoder (Fuaran.Core.Idl.Artifact) and the vocabulary it renders both live in theFuaran-Coresibling, so the artefact is emitted from there:cd Fuaran-Core dotnet run --project tests/Fuaran.Core.Tests -- --emit-idl ../Fuaran-UI/wire-format-fixtures -
Conformance. A stale-artefact guard on the
Fuaran-Coreside asserts byte-equality between the committedidl.jsonand a fresh emission, and names the regeneration command on failure – the same discipline as the stale-schema guard above, so a vocabulary edit that skips regeneration fails a test rather than quietly serving a stale spec. Adding the artefact changed no fixture payload and did not touchschema.json. -
Scope. The IDL models the node vocabulary.
TreeOps (§3.4) are outside it, as are decode-side policy surfaces a structural model cannot state: the §16 lenient-accept profile, the reject semantics of §6, and the §15/§17/§18 envelopes. For those, this prose spec and the corpus remain the only sources.
14. Markdown rendering (render-only; not a wire concern)
DisplayKind.Markdown carries its content as a raw TextSource on the wire – markdown is never
parsed into the wire format, so adding/removing a markdown feature is not a wire-format change and
does not touch the corpus in this directory. How that text is rendered to HTML is a separate,
render-only contract: one deterministic GFM → HTML renderer (Fuaran.UI.Renderer.Markdown.toHtml
in Renderer.Core), shared by the F# client + server renderers and re-implemented byte-identically
by the TS and Python hosts. It has its own conformance corpus at
wire-format-fixtures/markdown/corpus.json and its
own cross-host gate, mirroring §11.1. The supported GFM subset, the IN/OUT/DEFERRED buckets, and the
Phase 292 behaviour change (npm marked + Markdig removed) are documented in
MARKDOWN.md.
15. Wire versioning + forward/backward compatibility (Phase 319)
§11 keeps every host in lockstep – one coordinated change across encoder + decoder + schema + corpus + every host. That is the right discipline while the hosts ship together, but it cannot run a published standard with N independently-generated hosts: an older consumer will eventually meet a newer artifact, and §3.2's WRONG_NODE_KIND / §6's UNKNOWN_DU_CASE hard-reject it – a crash, not a graceful degrade. This section adds the missing contract: a versioned wire that lets a behind consumer detect → preserve → degrade, while the authoring/generation surface stays closed and exhaustive – no host can ever emit an unknown kind. Tolerance lives only on the decode boundary of a consumer that is behind.
The mechanism is host-neutral substrate in Fuaran.Core.Wire (Versioning module, FSharp.Core-only + Fable-clean); each language host (F#, TS, Python) adopts the same envelope + tolerance rules, certified against the corpus.
15.1 Profile id + the versioned envelope
A profile id names a capability set: <name>@<major>.<minor> – e.g. core@1.0. name is the capability namespace; major is the /vN/ incompatibility boundary (it pins the $id path of §13 – a removal/rename mints a new major); minor is the additive capability counter (a new kind/case/field bumps it). The current wire is core@1.0.
An artifact may be wrapped in a versioned envelope that carries the producer's authored profile alongside the payload tree/op:
{ "$payload": <Node | TreeOp>, "$profile": "core@1.0" }
$payload / $profile are $-prefixed so they sort before any lower-case data key under the §2 rule-2 canonical order (and are reserved per §2.1). The bare (un-enveloped) form is unchanged and is read as the implicit base profile core@1.0, so every existing v1 fixture is byte-unchanged – the envelope is opt-in carriage, not a reshape of the artifact. A producer MAY instead declare the profile an artifact requires inline via an optional "$requiredProfile":"<profile>" key on the artifact object (the "artifact declares what it needs" shape); a behind consumer reads it to name the gap in a degraded placeholder.
Decision –
$requiredProfilereservation (Phase 404). Phase 319 first shipped this inline key un-prefixed asrequiredProfile. It is a spec-minted key that sits directly on the artifact object alongside a host's lower-case data keys, so it belongs under the §2.1 reservation exactly as$profile/$payloaddo:$-prefixing marks it spec-reserved and guarantees it sorts before the data keys instead of interleaving among them (requiredProfilesorts whereverr…falls). The three versioning keys are therefore uniformly$-reserved. This is a wire-visible rename, taken now (pre-1.0, pre-flip, no external consumers) so it lands before theenvelope-*fixtures freeze. The wire migration –Fuaran.Core.Wire.Versioning'sUnknowncarrier key, the TypeScript host (@fuaran-ui/opsversioning.ts), and theenvelope-*corpus fixtures + manifest – lands with Phase 403's fixture regeneration (the workstream that owns the sharedwire-format-fixtures/corpus + cross-host envelope certification). Until that lands, the shipped hosts still emit the un-prefixedrequiredProfile; this spec states the reserved target so 403 freezes the$-prefixed shape.
15.2 Capability negotiation
A consumer compares its own supported profile against the authored profile:
| Outcome | When | Consumer behaviour |
|---|---|---|
| Current | same name+major, authored minor ≤ consumer minor | decode fully |
| Behind | same name+major, authored minor > consumer minor | may meet unknown kinds – tolerate (§15.3): preserve + degrade, never crash |
| Foreign | different name, or different major | an incompatible /vN/ boundary – hard-refuse, never silently mis-decode |
Minor-ahead is always tolerable (additive-only by the §15.4 policy); a major or namespace difference is never tolerable (it may have removed or renamed a kind this consumer relies on).
15.3 Transport-only Unknown + must-ignore-but-preserve
When a Behind consumer's decoder meets a discriminator it does not recognise, it does not raise WRONG_NODE_KIND / UNKNOWN_DU_CASE. It produces a transport-only Unknown { kind, payload, requiredProfile }:
- Transport-only means it is reachable on the decode path and nowhere on encode – there is no authoring constructor and no encoder entry point that takes one. The closed/exhaustive authoring surface (the AI-reliability moat) is intact: a producer still cannot emit an unknown kind; only a behind reader ever materialises one. This is enforced by construction in
Fuaran.Core.Wire.Versioning(decodeTolerantis the sole producer ofUnknown;reencodeof aKnownvalue can never yield one). payloadis the verbatim parsed object. Re-encoding it with the canonical renderer (§2) reproduces the producer's bytes exactly – must-ignore-but-preserve: an old client that doesn't understand a kind round-trips its bytes intact, so it cannot destroy data a newer producer authored. This is load-bearing for op-stream / collaboration, and the hash chain (§4 consequence) makes the preservation verifiable – a preserved-but-unrendered subtree hashes identically through an old client.$requiredProfile(the reserved key of §15.1 – when the artifact declared one) lets the consumer render a labelled placeholder ("needscore@1.4") rather than a blank.
A behind consumer thus has three honest responses to an unknown kind: detect it (negotiate → Behind, decode → Unknown), preserve it (re-encode the verbatim payload), or degrade it (render a labelled placeholder). Crashing is no longer one of them. A genuinely malformed object – no discriminator at all – still fails the decode (the tolerance is for unknown kinds, not invalid ones).
15.4 Evolution policy – additive=minor, removal/rename=major
| Change class | Version step | Old-consumer effect |
|---|---|---|
Additive – new NodeKind / Spec / Binding / Action case or a new optional field | minor (core@1.N → core@1.(N+1)) | Behind → must-ignore-but-preserve (§15.3) absorbs it; no migration needed |
| Removal / rename – a kind/case/field present before and absent after | major (core@1.x → core@2.0, new /vN/ + $id) | Foreign → hard-refuse; a migration shim rewrites old→new |
The classification is derivable, not hand-disciplined: an IDL diff (the canonical-source inversion, Phase 316) over two capability snapshots classifies the change – no removed tags ⇒ additive/minor; any removed tag ⇒ breaking/major (a rename surfaces as a removal + an add, correctly breaking). Fuaran.Core.Wire.Versioning.classify / bump are the host-neutral primitives; the IDL generator is what emits the per-host migration shims for a major step. This makes "is this change breaking?" a computed property of the IDL delta, not a reviewer's judgement call – the same posture as the §11 forward-coupling gate, extended across version boundaries.
15.5 Cross-host coordination
This contract is part of the wire format, so it is a cross-host change like any §11 addition: each host implements the same envelope shape (§15.1), the same negotiation table (§15.2), and the same transport-only-Unknown + preserve rule (§15.3) – the envelope and tolerance are conformance-corpus-certified, not host-private. The substrate primitives live once in Fuaran.Core.Wire.Versioning (the F# reference); a host in another language re-implements them against this section + the corpus, exactly as it does the rest of the codec.
The contract is executable in the shared wire-format-fixtures/ corpus as the envelope-round-trip / envelope-reject fixture families (regenerated by the §12 --emit-corpus run). A conformant host reads the $profile / $payload envelope, negotiates the authored profile against its own core@1.0, and either re-renders byte-identical (Current/Behind – unknown kinds preserved verbatim) or refuses a Foreign profile with the FOREIGN_PROFILE code; the emitter proves the law at generation time. Certification status:
- F# (
Fuaran.UI+Fuaran.Core.Wire.Versioning) – certified against the corpus families, in addition to the value-levelVersioningTestsin theFuaran.Corecodec suite. - TypeScript (
@fuaran-ui/ops) – certified against the same families via the@fuaran-ui/conformancekit (theenvelope-round-trip/envelope-rejectlegs). - Python – adopts the same envelope + tolerance rules and certifies against these fixtures through its own conformance phase; the corpus families are the shared authority the moment it does.
16. Lenient AI-ingest profile (decode-only convenience)
The encoder is strict and canonical – it always emits the verbose forms above, and the byte-stable
round-trip property (§1) is defined over that canonical output. The decoder additionally accepts a
small set of author-friendly shorthands so a model spends fewer tokens emitting a tree. These are a
decode-only convenience: every shorthand decodes to exactly the value its verbose form would, and
re-encodes to the verbose canonical bytes – so the shorthand never becomes a second wire dialect,
and encode(decode(x)) == encode(verbose(x)).
Normative (every conformant host, present and future – F#, TS, Python, Go, …):
- A conformant decoder MUST accept each shorthand below and decode it to exactly the value its verbose form denotes. Rejecting a shorthand, or decoding it to a different value, is non-conformant – an AI author must be host-independent.
- A conformant decoder MUST NOT extend the profile with shorthands not listed here (a private leniency is a second dialect and a silent cross-host divergence).
What earns a place in this profile (Phase 673). A shorthand is admitted only when it is a genuine assist to the emitting model — evidence that models actually produce that form, and that its intent is unambiguous. §16's own origin is exactly that: 38 of 122 first-time parse failures in the first 0.2.0 cohort. Backward compatibility is NOT an admission ground. Accepting a superseded spelling so that older output still parses is a deprecation seam, and §1.1 rules those out for this language. The distinction matters because the two are easy to confuse in review: both look like "the decoder accepts more", but only one of them pays for itself at the point where models fail.
- The profile is enforced by the corpus: the
lenient-acceptfixture family (wire-format-fixtures/lenient/, manifest kindlenient-accept) assertsencode(decode(shorthandInput)) == expectedFilefor every shorthand. A host's conformance run MUST include this family alongside the round-trip and reject families – a host that skips it can pass certification while diverging, which is precisely what this family exists to prevent. (Round-trip fixtures themselves stay canonical.)
Accepted shorthands:
-
The
Literalenvelope (0.2.0 direction-flip). Anywhere aTextSourceis expected (a heading'stext, a button'slabel, help text, a callout body, …), the bare JSON string IS the canonical form since 0.2.0 – the encoder emits"Revenue", and labels/text being the most common leaves makes this the largest single token saving. The verbose{"$type":"Literal","text":"Revenue"}envelope is the lenient-accept side of this pair now: decode-accepted indefinitely (pre-0.2.0 trees keep parsing) and normalised to the bare string on re-encode. (Bound/I18nstill require their$typeobject.) -
Omitted default fields (already implied by rule 4). Because the encoder omits
None/ all- default fields, the decoder already restores them on absence; an author may likewise omit any field whose default is acceptable (state/style/accessibility, aBox'sheading, a bare- default style, …). Genuinely required fields (id,kind, a node's discriminating spec fields) still error withMISSING_FIELD. (No dedicated fixtures: the canonical encoder itself omits defaults, so every round-trip fixture already exercises restore-on-absence.)
Op-value spelling (producer discipline). In a TreeOp.UpdateProp value position targeting a
TextSource field, both the bare string and the $type: "Literal" object decode to the same typed
value at apply time – but the op encoder is faithful to the JSON it carries, so the two spellings
serialise (and therefore hash-chain) differently. Producers SHOULD emit the bare-string spelling
for literal-text op values (the 0.2.0 canonical form, what the reference diff emits, and the
token-cheapest); hosts MUST accept both. Two op logs differing only in this spelling are semantically equivalent but not
hash-identical – a comparer that needs spelling-independence compares post-apply trees, not chain
hashes.
The profile is additive and does not change the negotiated wire version (§15).
16.1 Emitter preference – accepted is not the same as preferred
§16 and the shape-coercion table in §3.6 say what a decoder MUST accept. They deliberately say nothing about which of the accepted forms an author should write, and the choice is otherwise rediscovered by every emitting surface. This subsection states it once. It binds authoring emissions only – any surface producing a tree for the first time, whether generated or built through a programmatic authoring API – and it is a SHOULD: nothing here narrows what a conformant decoder accepts, and an emission that ignores it is still conformant, merely more verbose than it needs to be.
Why it is worth stating. Several accepted forms are strictly more compact than the verbose form
they normalise to, and the verbose form is the one an author reaches for by default. The difference
is not only emission budget: a long run of low-information literal tokens – a validity mask that is
true all the way down, a schema restating types the cells already carry – is an error surface in
its own right. The longer the mechanical run, the more chances to drop an element, mis-align a column
against its neighbour, or disagree with the data sitting beside it. Preferring the compact form
removes the run rather than checking it.
| Prefer | Over | Rule |
|---|---|---|
a column as a bare array – "amount": [100, 200] | {"values":[100,200],"validity":[true,true]} | an embedded Transform source column whose cells are all valid SHOULD be emitted bare. The wire has no JSON null, so the bare array already denotes all-present – the mask carries no information (§3.6) |
an embedded Transform source with no schema | "schema":[{"name":"amount","type":"int"},…] | when every column's type is inferable – string / int / float / bool – schema SHOULD be omitted and left to inference. Emit it explicitly only where inference cannot decide: an empty or mixed column, or a date / timestamp type, which never infers (§3.6) |
a Static source carrying the values | a Binding.Transform whose pipeline is [] | literal data the author already holds SHOULD be emitted as the slot's own Static source. Transform is the declarative-compute case; with an empty pipeline it buys a schema, a columnar re-shaping of data already in hand, and an empty step array, for no computation. Reach for it when the pipeline does work – filter / groupBy / sort / derive. (§5.1 still governs survivability where a slot's Static payload is not one the language enumerates; this row ranks compactness, it does not re-rank that boundary) |
a DataGrid with staticRows | a DataGrid whose literal rows sit in source | a static table of literal text SHOULD be authored as the staticRows mode (§3.2). This row ranks semantics, not compactness — and its original rationale has been superseded: before Phase 665 a grid source carrying literal rows erased to "<opaque>", so this was a survivability ranking. Both forms now round-trip value-faithfully (§5), so the preference rests on what the two modes mean: staticRows cells are TextSource, so Bound and I18n apply and localisation reaches table content, and the mode declares the table read-only and non-interactive (§3.2), which a renderer honours with semantic <table> markup. A source feed carries bare scalars and declares a data-bound grid. Choose by which of those you mean. The preference is stated for literal text tables; how a data-bound grid should best carry its rows is ranked by the rows above, not here |
The same preference for the omitted form applies to every omitted-when-default field (§3.6): an emission carrying only the semantic fields is the preferred one, not merely an accepted one.
Boundary – canonical encoding is UNCHANGED. Every row above is about which accepted form an
author emits, never about what a host produces. encode(decode(x)) still emits the canonical
envelope form in each case – a bare column re-encodes to {values, validity}, an inferred schema
re-encodes explicitly, a Static payload re-encodes per §5 – exactly as §16's normalisation law
requires. Hosts' byte-parity conformance legs (§11) are therefore untouched by this subsection, and
no round-trip or lenient-accept fixture changes on account of it.
17. Teleport state bundle (Phase 437)
A teleport bundle serialises a running application – the tree, its Binding.State values, an optional bounded op-history window, and the op-chain head hash – into one string small enough to ride a URL fragment or a QR code, and resume exactly where it was on any device. It is a new, additive top-level artefact: the Node/TreeOp wire forms of §§1–16 are embedded unchanged, and the existing tree-only fragment-permalink format is untouched. The F# reference implementation is Fuaran.UI.OpStream.Abstractions.Teleport (encode/decode), over the byte substrate in Fuaran.UI.Compression (Utf8 / Base64Url / Deflate).
17.1 String format
FT1.<base64url(deflate(canonical-JSON envelope))>
FT1.– the self-identifying format tag (Fuaran Teleport, format 1). A future compression/framing change mintsFT2.; the envelope's ownbundlefield versions the JSON shape.- deflate – a raw RFC 1951 stream (no zlib/gzip wrapper). The reference encoder emits a single fixed-Huffman block with deterministic greedy LZ77 (32 KB window), so the same bundle produces the same string on every host and pipeline; a conformant decoder accepts the full RFC 1951 range (stored / fixed / dynamic blocks), so a host producing bundles with a standard deflate library interoperates. Decoders MUST cap decompression output (the reference default is 1 MB) and fail a bomb as a typed error.
- base64url – RFC 4648 §5, unpadded. The encoded string is pure ASCII (chars = bytes).
17.2 Envelope (canonical-JSON inner form)
{ "bundle": "teleport@1",
"chainHead": "<64-hex op-chain head>",
"digest": "<64-hex SHA-256>",
"history": [ <TreeOp>, … ],
"state": { "<key>": <value>, … },
"tree": <Node> }
Rendered under the §2 canonical rules (Ordinal-sorted keys, canonical numbers, canonical escapes). Fields:
| Field | Required | Content |
|---|---|---|
bundle | yes | the envelope version, teleport@1. An unrecognised version is refused by name (never mis-decoded). |
digest | yes | integrity digest – see §17.3. |
tree | yes | the §3 canonical Node wire form of the live tree. |
state | omit when empty | the Binding.State value map: state key → any canonical JSON value (rule 12 discipline; no null). Capture is best-effort per rule 11 – the recognised primitives (string / bool / int / float) and already-JSON-shaped values (the decoded-path SetState payload) ride the bundle; host-typed content the wire cannot decompose is dropped. |
history | omit when empty | a bounded window of §3.4 canonical TreeOps – the most recent ops, newest-last. Carried for provenance/inspection at the destination; resume does not re-apply them (the tree already reflects them). |
chainHead | omit when absent | the op-chain head hash at bundle time (OpRecord(Sequence).Hash, §4-consequence vocabulary) – binds the bundle to a position in the source op-stream, the same anchoring discipline as a checkpoint's PreviousChainHead. |
17.3 Integrity digest
digest = sha256Hex( "fuaran-teleport:v1|" + render(envelope minus the digest field) )
where render is the §2 canonical rendering of the envelope object without its digest member (all other fields, Ordinal-sorted). Because the preimage covers every field, any tamper – a rewritten chainHead included – fails verification as a typed DigestMismatch, before any tree decode runs. Both encode and decode MUST assemble the preimage through the same canonical renderer (the reference implementation re-parses its own sub-documents and renders the assembled envelope through one code path, so verification cannot drift from production). The digest is an integrity check against corruption and casual tamper, not an authenticity proof – signing is the attestation seam's job, as for checkpoints.
17.4 Decode–validate–resume
A conformant decoder runs, in order, and surfaces every failure as a typed, recoverable error (the §6 envelope discipline – never a throw):
- Size gate – reject over-long input before any decompression work; cap inflate output (both
Oversize). - Unwrap – prefix check, base64url, inflate, UTF-8 (
InvalidFormat), JSON parse (InvalidJson). - Envelope shape + version (
InvalidEnvelope/UnsupportedVersion). - Digest verification (
DigestMismatch) – before decoding any payload. - Standard wire decode of
treeand eachhistoryop through the §§3–7 decoder (TreeDecode/HistoryDecodecarry the standardDecodeError). - Pre-emit validation of the decoded tree. Node-identity defects (duplicate / empty NodeId) refuse the bundle (
TreeInvalid) – state re-seat is keyed on stable identity; other findings surface as non-fatal defects. - State re-seat – the host seats the decoded
statemap into its state store (the reference lowering is the standard structuralJVal → objone), and the tree'sBinding.Statereaders – same stable keys, same stable NodeIds – resume mid-interaction (a wizard's active step, a buffered form draft).
FGP 3 – closures cannot exist on the wire. The bundle rides the standard codec: closure-bearing slots are "<closure>" sentinels that decode to inert placeholders (§4), and only the wire-survivable action cases dispatch after resume – through the host's standard CanDispatch default-deny gate, exactly as for any decoded tree. A teleported app is interactive to precisely the bounded, gate-checked extent any decoded tree is.
17.5 Size budgets (measured)
Budgets are in encoded characters (= bytes; the string is ASCII). Reference ceilings (TeleportBudget):
| Surface | Budget | Rationale |
|---|---|---|
| QR, hard ceiling | 2 953 | byte-mode capacity at QR version 40, EC level L |
| QR, comfortable | 1 273 | ≈ version 25-L; above this, dense codes scan poorly on mid-range cameras |
| URL fragment, practical | 8 000 | browsers accept far more, but shared-link surfaces (chat, mail, logs) degrade beyond a few KB |
Measured on the reference exemplar (an onboarding wizard: heading + 3-step stepper + 2-field form + button, 4 state keys, a 2-op history window, chain head):
| Bundle | Encoded size |
|---|---|
| tree only | 983 chars |
| tree + state | 1 071 chars |
| full (+ history + chain head) | 1 196 chars |
– comfortably inside every budget; roughly, the canonical JSON compresses ≈ 3× and base64url costs the ⁴⁄₃ back. When a bundle runs over budget (the reference encodeWithin refuses with a typed Oversize): truncate the history window first – it is provenance, not resume material – then prune state keys the tree no longer reads; the tree itself is the floor. Producers targeting QR should treat 1 273 as the working budget and 2 953 as the hard stop.
17.6 Conformance
Round-trip is byte-exact at the string level: encode(decode(s)) == s for every valid bundle (closures re-encode to their sentinels, §4). The executable fixtures live with the F# reference implementation (Fuaran.UI.OpStream.Tests/TeleportTests.fs: byte-exact round-trip, determinism, tampered-chain-head and tampered-state rejects, oversize/bomb rejects, version/envelope rejects, the budget pin). Cross-host certification (a TS teleport leg in the shared corpus) follows when a second host adopts the bundle format; the Node/TreeOp payloads inside the envelope are already corpus-certified.
18. Elicitation envelope – question-as-UI with a typed answer contract (Phase 465)
An elicitation is a question posed as a live Fuaran tree: the asker emits a canonical Node
tree plus a declared answer contract – which nodes' committed state entries constitute the
answer, each typed by a value space – and the interaction resolves to exactly one typed outcome.
The answer is canonical typed JSON conforming to the contract, never prose for the asker to
re-parse.
Like §17, this is a new, additive top-level artefact: the Node wire form of §§1–16 is embedded
unchanged, and no NodeKind / Action / Binding case is added – the envelope wraps a tree, it
does not extend the tree vocabulary. All §2 canonical-encoding rules apply (Ordinal-sorted keys,
canonical floats, no whitespace); optional fields are omitted when absent, never null.
18.1 The envelope
{ "$elicitation": "1",
"contract": { "fields": [ {
"name": "salary",
"nodeId": "ask-form",
"required": true,
"space": { "$type": "intRange", "max": 1000000, "min": 0 },
"stateKey": "salary" }, … ] },
"default": { "grade": "a", "salary": 45000 },
"id": "elc-full",
"timeoutMs": 30000,
"tree": { …canonical §3.1 Node… } }
| Field | Required | Meaning |
|---|---|---|
$elicitation | yes | Format-version tag, currently "1". $-prefixed (reserved per §2.1) so it sorts first. Any other value is refused (UNSUPPORTED_VERSION) – the envelope is a protocol artefact whose evolution is explicit, not tolerance-based. |
contract | yes | The answer contract: a non-empty fields array (below). |
default | no | A proposed answer the presenting host may pre-fill / fall back to. Must itself conform to the contract (DEFAULT_NONCONFORMANT). |
id | yes | The elicitation id (non-empty). Outcomes correlate back to it. |
timeoutMs | no | Integer ≥ 1. Data only – no conformant codec reads a clock; the presenting host's clock decides when to dispatch a TimedOut outcome. |
tree | yes | The question, as a standard §3.1 canonical Node. Decode errors from the embedded tree surface re-rooted under $.tree (e.g. MISSING_FIELD at $.tree.kind). |
Answer fields. Each contract.fields[i] object (all five keys required):
| Field | Meaning |
|---|---|
name | The key this field's value takes in the answer object. Unique across the contract (CONTRACT_DUPLICATE_FIELD). |
nodeId | The id of the node whose committed state carries the value – for a form question, the Form node itself (a FormField is a spec record, not a child node). Must name a node present in tree (CONTRACT_UNKNOWN_NODE). |
stateKey | The state key the node's binding (e.g. a Binding.Local initialFrom/commit target) writes the value under. |
space | The value space (below). |
required | Whether a conforming answer must carry the field. |
Value spaces reuse the platform's established $type-tagged space vocabulary (the same wire
shape the capability codec uses – one space vocabulary across the platform):
{ "$type": "intRange", "max": 5, "min": 1 }
{ "$type": "floatRange", "max": 1, "min": 0 }
{ "$type": "stringLen", "max": 10, "min": 0 }
{ "$type": "enum", "values": ["s", "m", "l"] }
{ "$type": "anyString" }
min/max are inclusive and must satisfy min ≤ max; enum.values is a non-empty string array;
anyString is the only unbounded space. An unrecognised $type is UNKNOWN_DU_CASE.
18.2 The answer object
An answer is a JSON object mapping declared field names to scalar values (string or number –
booleans and structured values are not answer values; model a yes/no as a two-value enum).
Conformance rules, per field:
intRange– a JSON integer in[min, max]. A string of digits isANSWER_TYPE_MISMATCH, not a coercion. Integer classification is by value, never by spelling: a whole-valued number (4.0≡4; the canonical layout renders it4) within 32-bit signed range is an integer – JSON has one number type, and hosts whose parsers cannot see the spelling must agree with hosts whose parsers can.intRangebounds and values are 32-bit signed; a whole-valued number beyond that range is not an integer (ANSWER_TYPE_MISMATCH).floatRange– a JSON number in[min, max].stringLen/enum/anyString– a JSON string (length within bounds / a member ofvalues/ any string).
Every required field must be present (ANSWER_MISSING_FIELD); optional fields may be omitted;
keys not declared by the contract are refused (ANSWER_UNDECLARED_FIELD) – the answer surface is
closed by shape, exactly as the authoring surface is.
18.3 Outcomes
The outcome set is closed – exactly one of four $type-discriminated shapes, correlated by
elicitationId (non-empty, required on every outcome):
{ "$type": "Answered", "answer": { "grade": "a", "salary": 52000 }, "elicitationId": "elc-full" }
{ "$type": "Declined", "elicitationId": "elc-full" }
{ "$type": "TimedOut", "elicitationId": "elc-full" }
{ "$type": "Superseded", "by": "elc-next", "elicitationId": "elc-full" }
Superseded.by (the elicitation that replaced this one) is optional. An unrecognised $type is
UNKNOWN_DU_CASE; a key not declared for the outcome's shape is UNDECLARED_FIELD (a Declined
outcome cannot smuggle an answer). Decoding an outcome does not check contract conformance –
the outcome does not carry the contract; conformance is the §18.4 validation step, run by whatever
pairs the outcome with its pending elicitation before the answer reaches the asker.
Strictness. Every object position in this artefact – envelope, contract, field, space, outcome,
answer document – rejects undeclared keys (UNDECLARED_FIELD). The elicitation envelope is a
protocol artefact, not a forward-compat carrier: there is no must-ignore tolerance here; evolution
is explicit via $elicitation (and, when carried inside a §15 envelope, the profile negotiation
applies to the embedded tree as usual).
18.4 Decode + validation pipeline
A conformant decoder runs, in order, failing fast with one structured §6-shaped error
({ code, path, message }) so every host surfaces the same first error:
- JSON parse (
INVALID_JSONat$); root must be an object (WRONG_TYPE). - Undeclared envelope keys (
UNDECLARED_FIELDat$.«key», first offender in document order). - Version tag –
$elicitationpresent, a string, equal to"1"(MISSING_FIELD/WRONG_TYPE/UNSUPPORTED_VERSION). id– non-empty string.tree– the standard §§3–7 node decode; errors re-rooted under$.tree.contract– structure (fieldsnon-empty:CONTRACT_EMPTY), per-field shape in array order (strict keys, non-empty strings, space decode), duplicate names (CONTRACT_DUPLICATE_FIELDat the second occurrence), and tree membership of eachnodeId(CONTRACT_UNKNOWN_NODE).timeoutMs– integer ≥ 1 when present.default– decoded as an answer object, then validated against the contract; any violation isDEFAULT_NONCONFORMANTat the offending path under$.default.
Answer validation (the gate before an Answered outcome reaches the asker) is deterministic
and fail-fast: (1) undeclared answer keys, in the answer's Ordinal key order; then (2) each
contract field in declaration order – missing-required, then JSON-type-vs-space
(ANSWER_TYPE_MISMATCH), then in-space (ANSWER_OUT_OF_SPACE).
18.5 Error codes
Structural failures reuse the §6 codes (INVALID_JSON / MISSING_FIELD / WRONG_TYPE /
UNKNOWN_DU_CASE) on the same error envelope; the elicitation layer adds (kept OUT of the core
six-code set, like §15's FOREIGN_PROFILE):
| Code | Meaning |
|---|---|
UNSUPPORTED_VERSION | $elicitation names a version this codec does not accept |
UNDECLARED_FIELD | an object position carries a key its shape does not declare |
CONTRACT_EMPTY | the contract declares no fields |
CONTRACT_DUPLICATE_FIELD | two answer fields share a name |
CONTRACT_UNKNOWN_NODE | a field's nodeId names no node in tree |
ANSWER_MISSING_FIELD | a required field is absent from the answer |
ANSWER_UNDECLARED_FIELD | the answer carries a key the contract does not declare |
ANSWER_TYPE_MISMATCH | an answer value's JSON type does not fit its declared space |
ANSWER_OUT_OF_SPACE | a well-typed answer value is outside its declared space |
DEFAULT_NONCONFORMANT | the envelope's default violates its own contract |
18.6 Conformance
Four corpus families in the shared wire-format-fixtures/ corpus
(regenerated by the §12 --emit-corpus run; the emitter proves every law at generation time):
elicitation-round-trip– decode with thedecoder-named entry point (elicitation⇒ the envelope codec,elicitation-outcome⇒ the outcome codec), re-encode, assert byte-equal toexpectedFile.elicitation-reject– decode with the named entry point; assert the error's code ==expectedErrorCodeat a path starting withexpectedPath.elicitation-answer-accept/elicitation-answer-reject(decoder: "elicitation-answer") – theinputFileis a{ "answer": …, "contract": … }conformance document (it carries no tree, so theCONTRACT_UNKNOWN_NODEprobe does not apply); run the host's answer validation and assert acceptance, or the expected refusal.
Like §15's envelope families (and unlike the node/op families), these fixtures are outside
schema.json's scope – the embedded tree payloads are already schema-described; the envelope's
own shape is normative here. F# (Fuaran.UI.OpStream.Abstractions) and TypeScript
(@fuaran-ui/ops) certify against these families; any further host certifies the same way.
19. Renderer URL-scheme floor (normative renderer obligation)
URL-valued slots – DisplayKind.Image.src, InteractiveKind.Link.href, the Action.Navigate
destination, and every other slot documented as carrying a URL – are opaque strings on the wire.
The decoder does not validate them, and this section does not change that: a URL that fails the
floor below is still a valid wire document, and a decoder MUST NOT reject it.
What this section adds is the other half of the contract. A rendering host – any host that emits
markup, or drives a live document, from a decoded tree – MUST apply the following floor to a
URL-valued string before it reaches an href, src, or equivalent navigation/fetch sink. This was
previously a per-host choice, which meant a tree vetted on one host was not thereby safe on another;
it is now an obligation. Hosts that only decode, re-encode, or transform trees are unaffected.
The floor. Given the slot's string value:
- Strip leading and trailing ASCII whitespace. An empty result is accepted and passed through (a same-page reference; the documented HTML behaviour).
- Determine the scheme: the substring before the first
:that occurs before any/,?, or#. If no such:exists, the reference is schemeless – go to rule 5. Otherwise remove every character at or below U+0020 from the candidate and ASCII-lowercase it, so that obfuscations such asjava<TAB>script:,about:blankandabout:blankall classify asjavascript. - Accept the scheme if and only if it is one of
http,https,mailto,tel,ftp,sftp. - Reject every other scheme. This is default-deny, not a denylist:
javascript,vbscript,fileanddataare rejected because they are known execution or exfiltration vectors, and an unrecognised scheme is rejected because the floor cannot reason about it. Widening the accept set is an additive, per-host-coordinated change; narrowing it is not a wire-format change at all. - A schemeless reference is a relative reference and is accepted – except a
protocol-relative reference, which MUST be rejected. A reference is protocol-relative when
its first two characters are each
/or\; that is,//host,/\host,\\hostand\/host. - On rejection the host MUST NOT emit the original value. It either omits the attribute entirely or
substitutes the literal
about:blank;about:blankis recommended where the element would otherwise be invalid or lose its semantics.
Why rule 5 is part of the floor and not an edge case. A protocol-relative reference carries no
scheme, so rules 2–4 never see it and the schemeless branch would admit it. But a browser resolves
it against the current document's scheme and lands on the named host – which is off-origin.
The same-origin intent that makes a schemeless reference safe simply does not hold for it. On a link
that is off-origin navigation the tree never asked for; on an image source it is an off-origin
request that leaks the referring URL. The backslash forms are included because WHATWG URL parsing
treats \ as / for special schemes, so all four spellings resolve identically – a floor that
rejected only // would be trivially evaded.
Case folding in render-time sanitisers (normative where it applies). A host whose sanitiser scans a case-folded copy of a string and then applies the resulting offsets to the original MUST use an ASCII-only fold. Locale-aware and full-Unicode folds are not length-preserving – U+0130 folds to two code points – so the two strings desynchronise and the host operates on the wrong span: it removes the wrong bytes, leaves a fragment of the construct it meant to remove, and, in a byte-indexed host, can split a multi-byte character and emit invalid UTF-8. The vocabulary such a scan matches (element names, scheme names) is ASCII, so an ASCII-only fold loses no matches. A host that folds and rescans without reusing offsets is unaffected.
20. Decode determinism (PROPOSED – NOT YET NORMATIVE)
Status: proposal, not contract. Nothing in this section is binding on any host, no fixture pins it, and no host should be changed to conform to it before the questions below are settled and every host can move together. It is recorded here so the decision has a starting point rather than a blank page.
§1 states the fundamental conformance property as byte-stable round-trip, and the corpus enforces it
per fixture. That property is silent about a narrower question: given the same input bytes, do
two conformant hosts produce the same tree, or the same rejection? Today, for a small set of
inputs, they do not — and because every host is individually self-consistent, the corpus cannot see
it. The divergences are all at the JSON syntax layer, below the $type dispatch this document
otherwise specifies, which is why they escaped: §2 describes what a conformant encoder emits and
has never constrained what a decoder must refuse.
The measured behaviour, across the five codec hosts:
| Input | F# (ref) | TypeScript | Python | Go | Rust |
|---|---|---|---|---|---|
Duplicate object key ({"id":"a","id":"b"}) | first wins | last wins | last wins | last wins | last wins |
| Content after the root value | accepted | accepted | rejected | rejected | accepted |
Overflowing exponent (1e999) | → Infinity | → Infinity | → Infinity | → Infinity | → Infinity |
Leading + on a number (+1) | accepted → 1 | accepted → 1 | rejected | rejected | accepted → 1 |
Bare NaN / Infinity literal | rejected | rejected | accepted | rejected | rejected |
| §7 sentinel string at a typed float slot | accepted | accepted | rejected | rejected | accepted |
Why the first row is the serious one. The other rows differ on whether a document is accepted; a disagreement there is loud, and the stricter host simply refuses to proceed. Duplicate keys differ on what the document means, silently, with no error anywhere. A host that vets a tree and a host that renders it can therefore be looking at two different trees derived from identical bytes — which is a smuggling primitive, not merely an inconsistency. It is also the only row where the reference host is the outlier: it is first-wins because its object parser accumulates entries in reverse and then folds them into a map that lets later list entries win, so the reversed order leaves the first-parsed key standing. That is emergent, not designed.
The last row is a round-trip hole, not just a divergence. §7 requires a decoder to accept the
quoted "NaN" / "Infinity" / "-Infinity" sentinels at a float slot, and every host emits them.
Two hosts do not accept them at every such slot, so a non-finite value encoded by one host does not
decode on those hosts at all. Unlike the rows above, this one is already a §7 conformance defect
rather than an open question, and it can be fixed per host without any spec decision.
Proposed rules, for the decision to accept, amend or reject:
- Duplicate keys — reject as an
INVALID_JSONsyntax error at the object's path. Rejection is the only option that cannot silently differ: both first-wins and last-wins are defensible, so a host that picks the other one is wrong in a way nothing detects. It also costs nothing legitimate, since no conformant encoder can emit a duplicate key. Last-wins is the fallback if rejection proves incompatible with a host's parser shape; first-wins is not recommended even though the reference host does it, because it is the minority behaviour and was not a decision. - Trailing content — reject. A wire artefact is a single JSON document (§1); requiring end-of-input after the root value makes that explicit and closes an obvious framing ambiguity.
- Leading
+— reject, per RFC 8259, which does not permit it. - Bare
NaN/Infinityliterals — reject, per RFC 8259. §7's quoted sentinels are the specified representation for non-finite values and are unaffected. - Overflowing exponent — specify the existing behaviour (
1e999→ the corresponding infinity) rather than change it. All five hosts already agree; it is unspecified, not divergent. - §7 sentinels — bring the two hosts into line with §7 at every float-valued slot, including float sequences, independently of rules 1–5.
What landing this requires. Rules 1–4 are each a decoder-visible breaking change for at least one host, so they need a version/profile decision under §15 as well as a coordinated §11 change across encoder, decoder, corpus and every host. Fixtures pinning them are deliberately not in the corpus yet: the corpus is a shared gate that every host runs, so a fixture landing ahead of the hosts turns their builds red for a rule none of them has adopted. The fixtures land with the hosts, not before them.
21. Resource limits (normative)
§6 promises that every wire-shape violation surfaces a structured, recoverable error, never a
throw. That promise held on semantics — a wrong-typed field, an unrecognised discriminator — and
was silent on shape. A decoder for this format is a recursive descent over a recursive document,
and nothing in §1–§20 bounded the recursion. A payload of a few hundred kilobytes consisting only of
[[[[[… — two bytes per level — drives a host off the end of its stack, and that is not a
DecodeError. On several host languages it is not even a catchable condition: the .NET
StackOverflowException cannot be caught and terminates the process outright, and Python's
RecursionError and JavaScript's RangeError escape a decoder that catches only its own error
type. Any host decoding untrusted input therefore had a one-request remote kill — and this document
mandated it, because rule 12 required structured payloads to round-trip "at any nesting depth".
This section closes that. The limits below are part of the format, not a per-host deployment choice: a document within them is a valid wire document that every conformant host MUST be able to decode, and a document beyond them is one that every conformant host MUST refuse, with the same typed error.
21.1 The limits
| Limit | Value | Bounds |
|---|---|---|
| max node depth | 24 | NODE nesting – the longest root-to-leaf chain of Node objects, the root counting as 1. |
| max JSON depth | 256 | SYNTACTIC nesting – the depth of the underlying JSON document; every { and [ counts, whether it carries a node, a spec, or a rule-12 payload. |
| max string length | 1 048 576 | Characters in a single decoded JSON string. |
| max array length | 100 000 | Elements in a single JSON array, and members in a single JSON object. |
| max total nodes | 100 000 | Node objects in one document, summed across the whole tree. |
Why node depth and JSON depth are two numbers and not one. They are not derivable from each
other in either direction. One tree level costs several JSON levels — a Box costs three (the node
object, its children array, the child object) and the worst-shaped kinds about five — so a single
figure cannot express both. More importantly they bound different things: a rule-12 structured
payload position nests freely within one node and consumes no node depth at all, so the node bound
does not constrain it and the syntactic bound is the only thing that does. 256 is chosen so that it
comfortably admits a maximally-deep tree of any kind shape with payload room left over — a host must
never report a node-depth breach as a syntax-depth breach, because that diagnosis sends the author to
repair the wrong thing.
Why a total-node bound is needed once depth is bounded. Depth, string length and array length together still admit a document that is hostile by being wide: 24 levels of 100 000 siblings is within every other limit. Its cost is linear in the input, but the constant is not — a decoded tree is far larger in memory than the bytes that produced it.
What these limits do not bound. They bound structure, not total payload size, and a host still owns the transport-level size limit (a request-body cap) separately. The two are complementary: a size limit cannot express "not more than 24 levels deep", and a structural limit cannot express "not more than 8 MB".
21.2 Host obligations
- A conformant host MUST accept any document within every limit above. Refusing one is not conservatism, it is non-conformance: a tree vetted on one host would not be decodable on another.
- A conformant host MUST refuse any document exceeding any limit above, with a
LIMIT_EXCEEDEDerror in the §6 envelope.Pathnames the position at which the limit was breached;Messagenames the limit and the observed value, so an author repairing the document knows which bound to come back under. A limit breach MUST NOT be reported asINVALID_JSON— the input is well-formed and merely too large to walk, and calling it malformed is an actively wrong diagnosis. - The refusal MUST NOT be an exception, a panic, a process exit, or any escape from the host's declared error type. This is the obligation the section exists for, and the one a host is most likely to satisfy partially: catching a language-level recursion error is not equivalent to counting depth, because in at least one host language the condition is not catchable at all, and in others it is catchable only outside the decoder's own error contract.
- The bound MUST be enforced on the way down, before the recursion that would breach it — never detected afterwards by measuring the structure that was built. A check that runs after the walk it is meant to bound has already paid the cost it exists to refuse, and on a host with a hard stack limit it never runs at all.
- Every walk over a decoded tree is subject to the node-depth bound, not only the decoder —
validation, transformation, cost accounting, and rendering alike. A document that decodes must not
be able to kill a later stage. Where a host's walk has a signature that cannot express refusal (a
total
tree -> markuprenderer, say), it MUST still bound the walk rather than recurse, and MUST make the truncation observable in its output rather than silently emitting a shortened tree. - A host MAY apply a tighter operational ceiling than the values above — a per-tenant node budget, for instance. A tighter ceiling is deployment policy, not a conformance claim: the host MUST document it, and MUST NOT describe a document it refuses under a tighter ceiling as malformed.
21.3 Amendment to rule 12
Rule 12's "faithfully at any nesting depth" is amended by this section to "faithfully at any nesting depth within the §21 limits". Nothing else about rule 12 changes: within the bound the round-trip guarantee is exactly as strong as it was, and the key-ordering, number and no-null rules are untouched.
21.4 How the values were chosen
The node-depth figure is the only one derived from measurement rather than judgement, and it is the tightest, so the derivation is recorded rather than asserted. Each walk in the reference (F#) host was bisected for its true overflow depth, with the guards raised out of the way, on a thread with an explicitly-sized 1 MB stack — the platform default — in both build configurations. Because a stack overflow terminates the process, each probe ran as its own process; the figures are the deepest level that survived.
| Walk | Optimised | Unoptimised | Unit |
|---|---|---|---|
| JSON parser | 2 095 | 805 | JSON nesting |
| structural node decoder | 186 | 31 | tree nesting |
| canonical encoder | 348 | – | tree nesting |
| pre-emit validator | 294 | 151 | tree nesting |
| server-side renderer | 67 | 30 | tree nesting |
Depth scales linearly with stack size (512 KB / 1 MB / 4 MB gave 31 / 67 / 285 for the renderer), so these are genuine per-frame costs rather than an artefact of one stack size. The binding constraint is the server-side renderer at roughly 15 KB of stack per node level optimised and 34 KB unoptimised — its per-kind dispatch is one large function whose frame carries every branch's locals. 24 is the largest round figure that keeps a real margin on that walk in both configurations. A larger figure was rejected deliberately: 32 fits the optimised build comfortably but is past the unoptimised renderer's and unoptimised decoder's budget, which would leave the guard working only in the configuration hosts ship and not in the one they debug — and a guard with a hole is worse than a smaller limit, because it reads as covered.
For scale, the deepest tree in this corpus is 3 levels, and a deliberately deep application tree (dashboard > grid > card > stack > tabs > panel > split > disclosure > form > field) reaches about
- Other hosts' per-frame costs will differ; the limit does not, because it is a protocol number.
A host that measures a tighter budget than 24 on some walk of its own should bound that walk by §21.2 rule 5 rather than propose a smaller wire limit.
21.5 Conformance status
The reference (F#) host enforces all five limits. Specifically: its JSON parser enforces the
syntactic-depth, string-length and array-length bounds; its structural decoder enforces the
node-depth and total-node bounds; its op decoder enforces the same node-depth figure over
TreeOp.Batch nesting, counted on its own axis; and — per rule 5 — its pre-emit validator, its
server-side renderer and its interaction-cost accounting each enforce the node-depth bound on their
own walks, the renderer by the visible-marker route rule 5 allows for a total signature.
A note for implementers, because it cost this host a second pass. Bounding the node decoder is
not sufficient. TreeOp.Batch makes the op decoder self-recursive on a separate axis, and the
syntactic bound looks like adequate cover for it (two JSON levels per Batch level, so 256 admits only
about 127) — it is not. On the reference host, 2.6 KB of 100 nested Batches killed the process with
every other bound already in place. Enumerate every recursive entry point, including the ones whose
recursion is over ops rather than nodes.
The remaining hosts have not adopted them, and their behaviour on over-deep input is the uncaught language-level recursion error §21.2 rule 3 forbids:
- TypeScript –
parseValue/parseObjectValue/parseArrayValueare mutually recursive with no counter, and neither the parser nor thedecodeNodeentry point wraps the walk in atry/catch. The engine'sRangeErroris catchable in principle but is not part of the declaredResultcontract, so it escapes the decoder as a throw. - Python –
decode_nodecatchesValueErroraroundjson.loads, and CPython raisesRecursionErroron deep nesting, which is not aValueError. It escapes the same way. - Go, Rust – unassessed here; both should be measured before adopting a figure, per §21.4.
Bringing each into line is a per-host change against this section, not a spec question.
Reject fixtures for this section are deliberately not in the corpus yet, for the reason §20
gives: the corpus is a shared gate every host runs, so a fixture landing ahead of the hosts turns
their builds red for a rule none of them has adopted. They land with the hosts, not before them. Note
also that a LIMIT_EXCEEDED fixture is unusually expensive as a corpus artefact — the smallest input
that breaches the smallest limit is hundreds of kilobytes — so the eventual fixtures are likely to
be generated from a rule rather than stored verbatim, which is itself a corpus-shape decision to
take with the hosts.
See also
MARKDOWN.md– the deterministic GFM markdown-render contract (render-only; §14).STABILITY.md→ "Wire format" – the stability declaration + breaking-change criteria.AI_AUTHORING_GUIDE.md"Self-checking before you emit" – the encoder-side pre-emit gate; the wire format is what it validates against.../src/Fuaran.UI/Types.fs– the §4b record contract this format serialises.