Skip to content

projection: scope the wire shape by reachability, ProjectionVersion 3 (#524, #540) - #603

Merged
gafferongames merged 8 commits into
mainfrom
impl-reachability
Sep 6, 2026
Merged

projection: scope the wire shape by reachability, ProjectionVersion 3 (#524, #540)#603
gafferongames merged 8 commits into
mainfrom
impl-reachability

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #524. Closes #540.

What lands

The wire-shape projection is the CLOSURE over the unit's type declarations (SPEC.md §3.1, §3.2), not a listing of the unit. Every type is a root, because the language has no way to say which types go on a wire. flags projects unconditionally and is the one exception. An enum or a union no type reaches is out of the text and out of the id, so a content enum only table bodies read stops buying a coordinated redeploy of both ends for a byte no packet carries.

ProjectionVersion is 3. Every id in existence moves once.

[E.Max]T is refused in a table body (#540, SPEC-TABLES.md §2.4, §11), which is what §3.2's no-fifth-member claim rests on: with the positional spelling gone, an enum a table reaches rides by variant name at every site, and flags is the only positional vocabulary a table has left. The spelling stays legal in a type body, where it is a plain array on the packet wire and every fact of it projects.

The eight edges, and one negative control per edge kind

ir.projectionClosure walks SPEC §3.1's eight edges and nothing else: a field's named type, an array's element type, an array's bound, a keyed array's key enum, a constant's value expression, a union arm's payload and field facts, both sides of a branch, and every item of a reached type transitively.

internal/check/projection_test.go's TestReachabilityEdgeControls holds one case per edge kind, each an enum reachable only through that edge, edited by a variant REORDER. A reorder moves no folded number anywhere in the projection text, so the id moves only if the walk reached the declaration and put its ordered variant names in. Each case is red when its edge is deleted from the walk:

edge 1, an enum named only as a field type: the enum is absent from the projection — the walk does not carry this edge, and a build that reorders Only shakes hands with one that did not
edge 2, an enum named only as an array element: the enum is absent from the projection — ...
edge 3, an enum named only by an [E.Max] bound: the enum is absent from the projection — ...
edge 4, an enum named only as an [E]T key: the enum is absent from the projection — ...
edge 5, an enum named only through a const: the enum is absent from the projection — ...
edge 7, an enum named only inside an else body: the enum is absent from the projection — ...
edge 8, an enum named only through a type two steps away: the enum is absent from the projection — ...

EDGE 6 CANNOT BE ISOLATED, and the page now states that rather than claiming eight reds. A union in a type body takes type payloads only (SPEC-TABLES.md §2.6, and the checker refuses every other arm there by name), and every type is a root, so a projected union's arm payload is a root by construction and is in the closure before the arm is walked. Deleting the arm descent from the walk leaves case 6 green. The descent is implemented because the rule is the rule, and the case is held as the path the page names, red through edge 1 reaching the union and edge 8 descending into the payload type.

What holds edge 6 is the arm-edit pair in TestUnionOutsideTheClosureMovesNoId: every arm edit a union inside the closure can express moves the id, and every arm edit a union no type reaches can express moves none. SPEC.md §3.1 was rewritten to say seven isolable controls and to name that pair as edge 6's obligation. Its old list named "a union named only as an arm payload" as the eighth control, which is a unit that cannot be written.

#540's control, with the refusal disabled:

--- FAIL: TestDiagnostics/[E.Max]T_in_a_table_body
    compiled clean — the language broke silently (want "is refused in a table body")
--- FAIL: TestDiagnostics/[E.Max]T_in_a_table_body,_one_word_of_the_fix
    compiled clean — the language broke silently (want "spell it [E]int32")
--- FAIL: TestPositionalKeyedArrayIsTheTableBodysRefusalAlone
    [ShipType.Max]Cfg compiled in a table body — a variant inserted in the middle of ShipType would land every later element one slot off in every file already written, and no kind number can catch it

Every id moved once, and no wire byte moved with it

WHAT ACTUALLY LEFT THE TEXT, unit by unit, so the change is auditable rather than asserted. 34 declarations across the corpus, every one an enum or a union no type reaches:

examples          2        tables/messages   4
examples128       0        tables/stream     1
examples-wide     0        tables/blobs      0
tables/examples   6        tables/block      6
tables/pointers   1        tables/blockhome  0
tables/maps       2        tables/scalars    1
tables/lists      2        tables/backend    3
tables/arms       6

examples/ is the clearest reading of the rule: exactly MissileType and PropType left it, both declared in Enums.schema and named by no type in the unit. The units at 0 moved their ids too, on the version line alone, which is what "every id in existence moves once" means.

17 units, each id moving exactly once: armdemo, bench, benchtable, blobdemo, blockdemo, blockhome, example, graphdemo, listdemo, ludicrous, mapdemo, messagedemo, realworld, scalardemo, streamdemo, tabledemo, wide. The goldens were regenerated with make update-goldens, never hand-edited.

The wire-bytes control:

tracked changed
packet wire goldens, testdata/wire/*.bin 24 0
table wire goldens, testdata/wire/tables/*.bin 196 20
conformance cook-write corpus, testdata/conformance/tables/cook-write/*.cook 92 92

Every one of the 112 changed binaries moved within the one 8-byte BUILD-VERSION field and nowhere else: the field the cook header, the block form and the message announcement each carry (§20), which derives from this projection and therefore moves with it. 103 of the 112 differ in all 8 of those bytes. Nine differ in 7, all of them the graph unit, whose new build version happens to share one byte with its old one. In every case the differing offsets are bytes 9 through 16, which is that field. Measured over all 112, not asserted:

differing bytes  files
              7      9   (graph_deep, graph_empty, graph_shared, graph_tree, each LE and BE, plus graph_conn.bin)
              8    103
--- testdata/wire/tables/backend_conn.bin      differing bytes: 8   (size 361)
--- testdata/wire/tables/block_render.bin      differing bytes: 8   (size 2816)
--- testdata/wire/tables/arms_ring_cook.bin    differing bytes: 8   (size 192)
--- testdata/wire/tables/message_second_announcement.bin  differing bytes: 8   (size 361)
--- testdata/conformance/tables/cook-write/root_full.cook  differing bytes: 8
--- testdata/wire/tables/graph_conn.bin           differing bytes: 7   offsets 9,10,12,13,14,15,16

So: a unit's wire golden is byte-identical before and after while its id line moves. That is the whole claim, and the 24 packet goldens are the clean half of it.

Tests inverted by the rule, deliberately

  • TestTableArmsAndTablesLeaveTheIdAlone (was TestTableArmsProjectAndTablesDoNot): a union only a table closure reaches now moves no protocol id at all, which is §3.2's stated consequence. A union with a TABLE ARM is excluded whole and needs no rule of its own, because it is a table-closure construct, a type body refuses one by name, and the closure never reaches it.
  • TestTableArmedUnionArmRenameMovesTheBuildVersionAlone (was ...MovesBothIds): the arm rename moves the BUILD VERSION and not the protocol id. That is the division scoping makes explicit: the connect gate holds the packet wire, and §20.2's cook projection holds the table-only vocabulary.
  • TestUnionOutsideTheClosureMovesNoId holds both directions in one place: the table-held union's arm edits move nothing, the type-held union's arm edits move the id.
  • TestProtocolFreeUnitIdPinned's probe gained a Holder type so that every declaration in it is reached. The pin is a RENDERING pin, and a probe whose union and enum dropped out of the text would have stopped being one.

Docs

Four #541 status lines deleted, each made true here: SPEC.md §3.1's "Compiler status: NOT SCOPED YET", SPEC-TABLES.md §2.4's and §11's "CHECKER STATUS: NOT REFUSED YET", USAGE.md's not-scoped-yet aside and its #540 aside. VERSIONING.md's owed-before-3.0.0 rows for #524 and #540 are gone and its evolution table no longer says the checker does not refuse.

SPEC.md §3.1's control list is corrected: seven isolable controls rather than eight, edge 6 named as a root by construction with the arm-edit pair as its obligation, and edge 5's rule stated as an extent reaches and a value folds. The matching comment in internal/check/projection_test.go follows it.

TUTORIAL.md's three printed build versions are re-derived (silence 6 below). Its schema projection sample is regenerated: version 3, and Pending and Weapon are gone from it because nothing in Starlight's types names them. The tutorial now teaches why, which is the clearest place in the tree to teach it. Both printed ids move with it. The reconstruction was verified against the compiler on main before it was used. The tutorial's committed unit reproduces 0xb786cca203ebb6ea and 0x799890f44a60c51f exactly under ProjectionVersion 2, so the new numbers are the same unit under the new rendering and not a guess.

docs/USAGE.md's printed build version for tables/block moved with the id.

Owner law

This PR adds no diagnostic and touches no read or write path: the change is a compile-time projection, and the generated codecs are byte-identical apart from the id constant in each banner. It moves neither the C nor the C++ packet emitter, so the standing reproduction-row gate is not reached (git diff main...HEAD -- internal/codegen/c internal/codegen/cpp is empty).

Silences the page leaves, decided nowhere

  1. Edge 6 is unobservable under the root rule, above. Stated, implemented, not claimed as a red control.
  2. [E.Count]T in a table body is not refused. The page names [E.Max]T and the refusal is written to that spelling exactly.
  3. [N]T where const N = E.Max, in a table body is not refused: the const hides the enum and the page's refusal is spelled at E.Max.
  4. [E.Max]T inside a type a TABLE reaches stays legal, and this one is worth an owner ruling rather than a shrug. §2.4 says the refusal "is the TABLE body's alone", so the checker refuses it in a table body and nowhere else. A type a table holds rides the table wire as a kind-13 body, and its [E.Max]T rides inside that body under kind 14, positional. Verified on a probe: table Root { inner Inner } with type Inner { slots [E.Max]uint8 } emits w.Put8(14) for slots. So a variant inserted in the MIDDLE of E lands every later element one slot off, in every stored file, with nothing on the wire that can say so, which is exactly the shape §2.4 refuses in a table body and exactly the class §4.1 counts as closed. The connect gate does move (the enum is type-reached, so the protocol id moves) and the tables baseline does flag it, but a save file carries neither. Either the refusal extends to every type a table closure reaches, or §2.4, §3.2 and §4.1 gain a fifth member. This PR implements what the page states and names the gap.
  5. The walk follows array bound expressions and not int-range, size or default expressions. An enum behind | max = E.Max contributes only a folded number, which is already in the text and already moves when a variant is added, and no ordinal of it means anything on the wire, so reaching it would only cost churn. Edge 5 used to read "the bound or the default that does", which reads either way. It now states the rule as an extent reaches and a value folds.
  6. THE THREE TUTORIAL BUILD VERSIONS ARE NOW RE-DERIVED. The build version's digest opens with the protocol id, so all three moved. The tutorial's unit is assembled across the parts and never listed in one place, so it was reassembled the way a reader following the page would build it, in a scratch directory, and each state was measured with the page's own command:
$ schema build-version .          # Part 11 base
0x8a4897ed86a715f6                #   was 0x4186cdc67f83f559
$ schema build-version .          # Part 11 with par_time added to GameConfig
0x8272fb7f3068abdf                #   was 0x7d56db7dd7e25376
$ schema build-version .          # Part 12 with Render.schema and MaxShips/MaxLasers
0x5042e348da4b84ea                #   was 0x1b087f2fcefc4628

The page prints three of the four as cook output rather than as a bare number, so those lines were produced by the page's own cook commands and reproduce whole, byte counts included:

$ schema cook --root GameConfig --in tree --out Game.cook --verbose .
cooked Game.cook: 464 bytes, build version 0x8a4897ed86a715f6, little-endian, root GameConfig, 1 nodes, 384 data bytes, 16 attribution bytes
$ schema cook --root GameConfig --in tree --out GameBig.cook --byte-order big --verbose .
cooked GameBig.cook: 464 bytes, build version 0x8a4897ed86a715f6, big-endian, root GameConfig, 1 nodes, 384 data bytes, 16 attribution bytes
$ schema cook-check --root GameConfig --verbose Game.cook .
ok: build version 0x8a4897ed86a715f6, little-endian, root GameConfig, 1 nodes, 384 data bytes, 16 attribution bytes, 0 reference slots

The control that the reassembly is the page's unit: a compiler built at origin/main prints 0x4186cdc67f83f559, 0x7d56db7dd7e25376 and 0x1b087f2fcefc4628 over those same three directories, and 0xb786cca203ebb6ea for the pre-change protocol id. So the numbers moved because this branch moved them, not because the assembly drifted. schema id . over the base state prints 0x60d7cbad6bb296f2, the page's committed value, and schema build-version --facts . reproduces the page's facts block line for line.

  1. Two page fixes deliberately NOT taken here, because The [E.Max]T refusal follows the bound's provenance and reaches every type a table closure holds (#540, one body away) #605's page PR carries them: SPEC-TABLES.md §2.4 and its line 5836, which carry the bound-provenance rule, and SPEC.md's stale doc-comment status clause, the /// line saying no comment kind is read yet that spec: doc and tags columns, field-level tags (#523) #529/Design lock-in before the sweep: the cold read's shortest list #523 landed in 02b3fc1. Both are in flight in that PR, so this one leaves them alone rather than racing it.

gafferongames and others added 5 commits September 6, 2026 00:20
…#524, #540)

The projection is now the CLOSURE over the unit's `type` declarations rather
than a listing of the unit (SPEC.md §3.1). Every `type` is a root, `flags`
projects unconditionally, and an enum or a union no `type` reaches is out: a
content enum only `table` bodies read stops buying a coordinated redeploy of
both ends for a byte no packet carries.

The walk carries SPEC §3.1's eight edges, and internal/check holds one
negative control per edge kind, each an enum reachable only through that edge
and edited by a variant REORDER, which moves no folded number in the text and
so is caught only by the walk having reached the declaration.

`[E.Max]T` is refused in a table body (#540, SPEC-TABLES.md §2.4, §11), which
is what leaves `flags` as the only positional vocabulary a table has and
therefore the only exception the scoping needs. The spelling stays legal in a
`type` body, where it is a plain array on the packet wire.

Every unit's id moves once and no wire byte moves with it: the 24 packet wire
goldens are byte-identical, 172 of the 192 table wire goldens are byte-
identical, and the 20 that moved each moved exactly 8 bytes, the build version
the cook, block and announcement forms carry (§20).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… new ids

`make update-goldens` does not reach two places the moved ids ride:

- the CONFORMANCE cook-write corpus, 92 files, regenerated with `make
  conformance-generate`. Each moved exactly 8 bytes, the build version in the
  cook header (docs/SPEC-TABLES.md §7.1, §20), and nothing else.
- the BENCH corpora for the eight non-C++ targets, regenerated from
  bench/corpus. 25 files, 41 lines, every one of them an id constant or a
  banner.

The tutorial's Part 1 id is re-derived the same way Part 5's was — the
committed unit reproduces the old number under the compiler on main — and the
`build-version --facts` block's `protocol` line is the Part 5 id verbatim, so
it moves with it.

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

The remaining generated/*/.stamp trees CI regenerates: generated/bench/tables
for c, cs, dart, elixir, go, java, js and rust. Every changed line is an id or
a build-version constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`[<Union>Type.Max]T` is edge 3 through a name no declaration spells (SPEC
§4.2, §4.8). The array is positional over the arms, so slot i belongs to arm
i + 1 and a reorder changes what every element is. Red with the generated-set
resolution removed from the walk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An export is a semver commitment and justifies itself on schema's own needs
(CLAUDE.md, docs/VERSIONING.md). Nothing outside this rendering has a use for
the reached set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames and others added 2 commits September 6, 2026 01:10
…ue folds

§3.1's control list claimed one case per edge kind and eight controls, and
named "a union named only as an arm payload" as edge 6's. That unit cannot
compile: a union in a `type` body takes `type` payloads only, every `type` is a
root, so an arm payload is in the closure before the arm is walked. State seven
isolable controls, and state edge 6's payload as a root by construction whose
obligation is TestUnionOutsideTheClosureMovesNoId's arm-edit pair.

Edge 5 said the walk followed "the bound or the default". It follows an extent
and not a value: state the rule as an extent reaches and a value folds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The build version's digest opens with the protocol id, so scoping the
projection by reachability moved every one on the page. Parts 11, 12 and 13
still printed the pre-change numbers.

Re-derived by assembling the tutorial's own unit as the page builds it and
running `schema build-version .` on each of the three states:

  Part 11 base                    0x4186cdc67f83f559 -> 0x8a4897ed86a715f6
  Part 11 with par_time added     0x7d56db7dd7e25376 -> 0x8272fb7f3068abdf
  Part 12 with Render.schema      0x1b087f2fcefc4628 -> 0x5042e348da4b84ea

A compiler built at origin/main reproduces all three old numbers over the same
three units, which is the control that the assembly is the page's unit and the
move is this branch's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ng refusal

The ProjectionVersion ledger narrated what versions 1 and 2 could not see.
State what version 3 renders. The map-descent comment said the descent reaches
nothing "today"; a `type` body cannot spell a map, so no unit reaches it.

The edge-control comment likewise dated itself, and its header quoted a page
line that now reads seven isolable cases. Point edge 6 at the arm-edit pair
that holds it.

The [E.Max]T-in-a-table refusal carried an em dash and a semicolon. Plain
sentences. The substring both tests match on is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames
gafferongames merged commit c8d0df5 into main Sep 6, 2026
20 checks passed
@gafferongames
gafferongames deleted the impl-reachability branch September 6, 2026 08:28
rowan-claude added a commit that referenced this pull request Sep 6, 2026
The projection landed (#603), which carries #524 whole and #540 in part, so
the four conflicts are all one question: what the pages say the checker does
today.

`schema check` now refuses `[E.Max]T` in a table body and in a union arm,
which is what main deleted the "NOT REFUSED YET" lines for. It still accepts
`[E.Count]T` and `[N]T` under a `const N` that folds from either, because
`checkPositionalKeyedSpelling` matches the SPELLING where this branch's rule
follows the bound's PROVENANCE. The merged status lines say exactly that, in
SPEC-TABLES.md §2.4 and §11, in USAGE.md's keyed-array section and in
VERSIONING.md's owed list, and #540 stays owed for the gap alone. The #524
entry goes, landed whole.

The #606 fence stands untouched: the type-held case is stated as ruled and
not refused, and no sentence in §2.4 or §6.6 commits to the closure-wide
refusal.

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.

check: the [E.Max]T refusal in a table body (§2.4, §11) is not made A reachability-scoped wire-shape projection (ruling 11 on #523)

1 participant