Skip to content

A union arm is a field line: the arm framing for any field type, on both wires (#396 item 1, #392) - #481

Merged
gafferongames merged 5 commits into
mainfrom
union-arms-spec
Sep 4, 2026
Merged

A union arm is a field line: the arm framing for any field type, on both wires (#396 item 1, #392)#481
gafferongames merged 5 commits into
mainfrom
union-arms-spec

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Spec first. This is the page for #396's adopt item 1, union arms of any field type, and for #392's closure seen from the arm side. It carries docs/SPEC.md and docs/SPEC-TABLES.md, with the two doc-family corrections the change makes owed. The C++ reference, the tool, the corpus and the goldens follow in the implementation PR, built against this page.

What the pages say

An arm is an ordinary field line (SPEC §4.8, SPEC-TABLES §2.6). An arm's type is any type a field's is, with nothing added and nothing held back: a scalar with its bounds, a compressed float, fixed(I, F), a 128-bit integer, an enum, a flags mask, string(N), bytes(N), a bounded array, a pointer, another union, a declared type, and inside a table closure a table. A field and an arm are one shape under two productions, Field and UnionVariant, and the second carries no default and no ?.

An arm may carry no payload at all, written as its name alone. It has no storage, the table wire gives it its arm id and L = 0, the packet wire gives it the tag and nothing else, the text form spells it { "ping": null }, and C++ gives it a tag value with no member and no accessor. It is not None, which says no arm was selected.

The framing. An arm payload is exactly the bytes a field of that type puts after its own framing prefix, under the arm's existing L. §3's kind-15 row states it per type, and says once that those widths are this wire's: under #435 an arm header becomes a field header and the silent members below close by construction, while the framing rule itself carries no width and stands under both forms.

What a reader can see, and what it cannot. A fixed-width arm whose L is not its declared width is kind_mismatch. A length-shaped arm's damaged payload is that arm's own framing damage under L. §3 then names the whole silent class, which is four retypes and not one: two fixed-width arms of one width, any arm read as a string arm at every width, a scalar and a pointer at four bytes, and a scalar read as a bytes or array arm under five bytes. §2.6, §4, §4.1's fifth member and the three-frames table cite that list rather than restating it.

A body's terminator is the end of its payload, for a kind-13 field and for a body arm alike. Bytes left inside L after the terminator are framing damage, so a four-byte payload whose low two bytes are zero no longer decodes as an empty body with nothing counted. §4.2's fuzzer carries the mutator for it, and one for an arm's L moved off its declared width.

The baseline (§18.1, §18.2, §18.3). An arm line has three disjoint spellings: payload=<Name> for an arm naming a declared type or table, kind=none for a payload-free arm, and the field tokens for everything else, in the field line's column order and judged by the field line's rules, min= and max= included. A tightened arm range warns as a field's does. The page also states the rule the compiler already holds, that a new judged token bumps the rendering version, and the example file reads schema-tables-baseline 6, which is main's constant plus the two renderings that bump it, this page and the maps page (#479).

The build version (§20.1, §20.2, §20.4, §20.8). The arm line carries the field line's tokens in the field line's emitted order, with size= unconditional and offset= taken within the union's storage, whose tag sits at 0. The documented field grammar is corrected to that same emitted order, and elem= is stated as the element's size in this projection where the baseline records the element's kind.

The protocol id (SPEC §3.1, §4.8). Every arm projects as the field line it is, so adding a scalar arm to a union moves the unit's id exactly as adding a field to a type does. A union with a table arm is excluded whole, as the table is, because a table has no packet wire for an arm to carry.

Storage, reflection, text and the walks. An arm is one overlay member, and an arm needing a companion rides as one member of an unnamed struct type, because the dialect refuses an anonymous struct member, which is now a row in §13.9 where the dialect's refusals live. ViewVariant gains a field descriptor beside payload, and every offset in §8.1 is taken from the start of the union's storage. An arm's value takes the arm's own row of §16.2's mapping table, and a value of the wrong shape is kind_mismatch with the union reading None and the enclosing object reading on. A pointer arm is a pointer edge visited in the union field's declaration-order position (§3.1).

Refused on an arm, stated once in §2.6 with the reasons and registered in §11 by name: a specified default, ?, was, json, an enum-keyed array [E]T, and an if guard. A map is not among them, because an arm admits every type a field admits.

The kind byte the framing does not spend is priced once, in §14, as the rejected shape it is.

Held by test

The held-by-test sections carry what the corpus holds as the requirement the implementation PR meets, each control named with its corpus row or instance and the one reason it goes red: the seven arm evolution rows in §4, the arm mutators in §4.2, the shared-node pointer arm built the way stream_arm_first is in §3.1, the general arm's two descriptor columns in §8.7, the hostile arm rows in §16.5, the five arm refusal sub-cases with their fixture pairs in §18.6, and in §20.8 the arm's own protocol-id control beside the unchanged-unit twin that regenerates both projections byte-identically.

Doc family

docs/COMPARISON-TABLES.md reads "exactly five" in both places, matching §4.1. docs/USAGE.md's union section states the arm rule.

Issues: #396 (adopt item 1), #392 (the closure), #436 (F19, F24).

🤖 Generated with Claude Code

#396, #392)

SPEC-TABLES §2.6 and SPEC §4.8 lift the declared-types-only restriction on
a union arm: an arm is an ordinary field line, so an arm's type is any type
a field's is — a scalar with its bounds, an enum, flags, string(N),
bytes(N), a bounded array, a pointer, another union, a `type`, a `table`.

The framing question #436's F19 names is answered here rather than left to
nine ports: the arm payload is exactly the bytes a FIELD of that type puts
after its own framing prefix, under the arm's existing `L`. No kind is
added, no per-arm kind byte rides, and a table arm's bytes do not move.

What follows from having no kind byte is stated with it: an `L` that is not
a fixed-width arm's declared width is a kind mismatch, a damaged
length-shaped payload is that arm's own framing damage, and an arm retyped
under one width is SILENT — §4.1's class grows to five, and §18's baseline
is what refuses it. The arm's baseline line and the arm's cook-projection
line gain the FIELD tokens for a general arm and keep `payload=<Name>` for
an arm naming a declaration, so no committed baseline and no build version
moves for a unit that has not changed.

The packet wire's rule is fixed in SPEC §4.8 so no port guesses it — an arm
rides after the tag as the field encoding its type already has — and the
nine backends' packet codecs plus the arm's field facts in the wire-shape
projection are the named follow-on. A union with a general arm is a
table-closure construct today, refused by name outside one, exactly as a
table-armed union is.

Refused on an arm, each by name and each with its reason: a specified
default, `?`, `was`, `json`, an enum-keyed array, a map, an `if` guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames

Copy link
Copy Markdown
Contributor Author

Rulings on the #481 cold read (union arms, docs/SPEC-TABLES.md and docs/SPEC.md §4.8)

Numbers follow the reader's report. Line numbers there are at e89f460 on union-arms-spec. Every ruling is stated on the page as present fact, in the page's voice, with no history, no plan and no owner quotation.

1. Underdetermined

1a. A body's terminator is the end of its payload. A body arm, and a body field, whose terminator is not the last two bytes of its L (one byte under schema#435) is malformed: the payload stops, the union reads None or the field reads its default, and the enclosing body continues past it by L. State it once on §3's body row for fields and arms alike, and add the fuzzer row for it.

1b to 1d. The silent class is stated exactly, once, in §3, and cited from §2.6, §4, §4.1 item 5 and the three-frames row. It is: a retype between two fixed-width arms of one width; any arm retyped to a string arm, at every width (clamped fires only past N); a scalar and a pointer at four bytes (an in-range index reads a node, an out-of-range one is malformed and the slot reads null); a scalar arm retyped to a bytes or array arm at fewer than five bytes (inert: the arm is selected at its defaults and nothing is counted). At five bytes and above the element kind check applies at an arm exactly as at a field.

1e. The build version's arm line carries the field line's tokens in the field line's EMITTED order, with size= unconditional and offset= the arm's offset within the union's storage. Fix the documented field grammar to the emitted order in the same edit. State on both lines that elem= is the element's size in this projection and the element's kind in the baseline.

1f. The baseline's arm line carries every judged token the field line carries, min= and max= included, and a tightened arm range warns as a field's does. Strike "resolution" from §2.6: the baseline judges what it records.

1g. The page states the rule the compiler already holds: a new judged token bumps the baseline rendering version. This page's arm tokens bump it. The number on the page is main's constant plus one at merge time (5 today); the maps page bumps it too, and whichever of the two merges second rebases to plus two.

1h. ViewVariant gains the field row member. §8.1 and §8.3 name it, and the exactly-one-non-NULL rule names both members.

1i. Descriptor offsets are from the start of the union's storage, the tag at offset 0.

1j and 2b. In the text form an arm value of the wrong shape is kind_mismatch: the union reads None and the enclosing object continues. This is what §16.2's report paragraph and the manifest rows already say.

1k. Every arm projects into the protocol id as the field line it is. Adding a scalar arm to a union moves the id as adding a field does. Strike "projects nothing".

1l. Mode derivation cites §2.2's rule for variable length instead of listing cases.

2. Contradictions

2a. The arm id 0 rides in two places: a None element of an array of unions, and a nested-union arm holding None (L = 2, inner id 0). Fix the sentence.
2c. Settled by 1b to 1d.
2d. Fix §16.2's premise and remove §20.1's quotation of the deleted §3 sentence.
2e. §5's reason for refusing was on an arm: arms already evolve by name.
2f. SPEC §4.8: two productions, as the grammar has.
2g. Settled by 1e.
2h. Add the anonymous-struct refusal where the C++ dialect's refusals live and cite that home.
2i. Settled by section 3.
2j. COMPARISON-TABLES.md says exactly five. USAGE.md:241 is rewritten to the new rule. Both in this PR.
2k. Delete the backend status paragraph. The held-by-test sections state what the corpus holds as the requirement the implementation PR meets.
2l. Admit the payload-free arm, per Glenn's ruling on #396: the grammar is a bare ident line inside a union; it has no storage; the packet wire carries the tag alone; the table wire carries the arm id with L = 0; the text form is { "Arm": null }; C++ has the tag value and no accessor; the baseline and build version lines carry a kind token for "no payload" (the worker uses a token the grammars already have if one exists, otherwise states one, once). §15's deferral of it goes.

Also: an arm admits every type a field admits, a map included once #479's field lands. Strike "refuses a map". bits(N) is a scalar in both pages.

3. The #435 seam

One sentence at the arm table: these widths are today's; under schema#435 the arm header is a field header (id reference, kind, L, payload), so the arm carries its kind and the silent members this section names close by construction. Today's members stay stated as today's. The framing rule is width-free and stands.

4. Tests

The held-by-test sections name the ten single-reason controls the reader listed, each with its corpus row or instance and the one reason it goes red. The shared-node case gets stream_arm_first's construction. §18.2's four refusal sub-cases are listed with their fixture pairs.

5. Prose

The fixed-width, length-shaped and silent triad once, in §3. The kind-byte rationale once, in §14, not §13. "today" out. RuleFixed described, not named. "the arm column" named. The overlay excerpt marked as five of ten arms. armour becomes armor and the example ids are recomputed with the tool. No em dashes in rewritten prose. The PR body rewritten to the page as it is.

gafferongames and others added 4 commits September 4, 2026 13:35
…what an arm projects

An arm may carry no payload at all: a bare name line in the grammar, the tag
alone on the packet wire, a tag value with no member in generated code. A
field and an arm are one shape under two productions, which is what §4.2
has. The refusal list moves to its one home in SPEC-TABLES §2.6, and
`bits(N)` is spelled as a scalar in both pages.

Every arm projects into the protocol id as the field line it is, so adding a
scalar arm moves the id as adding a field does. The one union excluded whole
is a union with a `table` arm, because a table has no packet wire for an arm
to carry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the arm lines the baseline and the build version render

§3 owns the arm's read rules and states them once: a body's terminator is the
end of its payload, so bytes left inside `L` are framing damage for a field
and for an arm alike; and the silent class is four retypes, not one, since a
string arm accepts every width, a scalar and a pointer share four bytes, and
a `bytes` or array arm under five bytes is inert. §2.6, §4, §4.1 and the
three-frames table cite it instead of restating it. The kind byte the framing
does not spend is priced once, in §14.

The baseline's arm line carries every judged token a field's does, `min=` and
`max=` included, a payload-free arm carries `kind=none`, and a new judged
token bumps the rendering version, which the example now shows at 5. The
build version's arm line carries the field line's tokens in the field line's
emitted order, with `size=` unconditional and `offset=` taken within the
union's storage, and the documented field grammar is corrected to that same
order.

The registry's arm row gains its `field` descriptor beside `payload`, every
descriptor offset is taken from the start of the union's storage with the tag
at 0, and a wrong-shaped arm value in the text form is `kind_mismatch`. The
backend status paragraph is gone: the held-by-test sections carry what the
corpus holds, each control named with its row and the one reason it goes red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s the arm rule

Both of COMPARISON-TABLES' counts read five, matching §4.1. USAGE's union
section says what an arm is now: any type a field's is, or no type at all,
with the table-closure boundary named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The maps specification (#479) landed first, so the baseline rendering
version on the page is main's constant plus two, and the four overlaps
resolve to both sides: the pointer walk lists a map entry beside the set
arm, §18.2 warns on a tightened range whether it is a field's or an arm's,
and §20.2's field grammar keeps `array=map` while dropping the `stride=`
the emitter puts on `slot` lines only. `elem=` is stated for an array,
which is what an `arm` line carries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant