Skip to content

tables: the id-table wire (#435) - #507

Merged
gafferongames merged 17 commits into
mainfrom
wire-id-table
Sep 4, 2026
Merged

tables: the id-table wire (#435)#507
gafferongames merged 17 commits into
mainfrom
wire-id-table

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The table wire's id-table form (docs/SPEC-TABLES.md §3, merged as #501), in both implementations, with the corpus, the gates and the fuzzer moved to it.

The form

A saved table is three parts: the form byte, the root body, and the id table a reader finds from the END. A body is id reference, kind, payload terminated by the zero reference; every length, count, index and reference is one canonical LEB128; identity is fnv1a64(name) at sixty-four bits for a field, an enum variant, a union arm and a table's own name alike. The table holds every id the body used, once each, in first-use order, and the body names them by position — so a file naming forty ids across ten thousand fields carries forty ids and spends one byte a header.

Three kinds ride with it: 30 an enum, carrying the reference to its variant name's id; 31 the escape; 32 the payload-free arm. An arm header is a field header and carries the arm's kind. The node table rides in one field under the reserved id 0xFFFFFFFFFFFFFFFF.

What holds it

  • §3.1's worked save reproduces byte for byte — all 120 — as a golden of its own, and every id the page prints reproduces from the hash alone.
  • The cross-implementation lock closes: all 46 text-carrying conformance instances agree byte for byte between the C++ reference and the compiler's own engine, held by a Go gate.
  • §20.2's worked projection reproduces character for character, and both of the page's digests reproduce from it: 0xa9df771f6051391f and 0xc4adaacfe6cb2809.
  • The wire fuzzer: 93,651 enumerated mutants over 89 seeds, 0 divergences, plain and under ASan/UBSan, with the reference-class, id-table, canonical-spelling and form-byte passes §4.2 names. All four negative controls go red on their own check.
  • It found three reader bugs and two missing rules, each a real divergence from the page — an unsigned fit check, a getleb that moved the cursor on a number it refused, a damaged count that dropped an optional's presence, the reserved id in a nested body, and a repeated node-table field.
  • Twenty new report rows for the kinds and refusals this form added, each built from §3's grammar and pinned by the reference.
  • The reserved-id and collision refusals at sixty-four bits, with a compiler test hook: eight rows red under a planted collision and green without one.
  • The elision, first-use and distinctness instrument over every pinned instance.

make conformance is green — the reference leg passes every registered surface — and so are go test ./..., the tables, maps, block, cook, pack, hostile and fuzz batteries.

Also in

What the ports lose, and what is still open

The corpus is pinned in the new form and the eight ports write the previous one, so their five wire-carrying surfaces are ABSENT — wire, report, json-read, json-write, json-hostile — each driver saying so and naming its issue (#511#518). The seven per-port harness negative controls go dormant with them.

Open: each port's own allocation gate, soak and forgery fuzzer reads the pinned corpus too, so make test's port legs still fail. Keeping them dormant loses those gates until the ports move; giving the ports a corpus in the previous form keeps them running. That is a decision, not an oversight.

Also open: the tool's cook half for maps; the dispatch nanosecond figures, which need a bench sitting rather than a laptop under load.

🤖 Generated with Claude Code


The tree brought current (25f9f06)

Three checks were red on 9a036cc, and none of them was a defect in the new wire code.

generated. The bench table units for nine languages still carried the previous build version. The Go one was committed and the other nine were not. The C++ bench table unit also had to take the whole id-table wire, which is what this branch emits. Regenerating every generated/*/.stamp from this branch's compiler moves 11 files and nothing else.

The byte-identity half of the block zero-cost gate, which the generated job runs after the staleness check and so never reached. 22 C++ Table sources under testdata/golden/tables/ move with the emitter this branch changes. 12 C Table sources move on one line each, the build version constant, and the new values are the ones testdata/golden/build-version/ already pins on this branch. Nothing under testdata/wire/ moved: the wire pins were already re-cut here. 82 Table sources are byte-identical to their pins again.

lint. Eight golangci-lint findings and eight modernize findings. Three dead functions go, seven untagged switches over a kind take their tag, one if-else chain becomes a switch, four errors.As refusal probes become errors.AsType, and two backward index loops become slices.Backward. The emitters' output is byte-unchanged across all of it.

big-endian was not a byte-order verdict. The job died on

qemu-s390x ./build/schema_test_c_soak_be 0
soak: testdata/wire/tables/root_full.bin does not re-save to its own bytes — refusing to soak a codec that does not reproduce the corpus
make[1]: *** [make/c.mk:317: tables-c-big-endian] Error 1

The same binary built for the HOST fails on the same case with the same message. ./build/schema_test_c_soak 0 on a little-endian laptop:

soak: testdata/wire/tables/root_full.bin does not re-save to its own bytes — refusing to soak a codec that does not reproduce the corpus

So this is the port-leg case the section above already names, and not an endianness defect: the soak's golden gate reads testdata/wire/tables/, which is the id-table form now, and the C port writes the previous one. tables-c-big-endian goes dormant with schema#512 behind it, on the rule the eight per-port controls on this branch already follow. The cross-compile rule stays, because that is what the leg wakes with. The C++ half of the big-endian job is untouched and green: the tables battery, maps, the block driver both ways and the cook reader both ways all still run on s390x under qemu.

All 20 checks pass on 25f9f06.

gafferongames and others added 5 commits September 4, 2026 17:40
…435)

The wire law first, then the compiler's own engine over it
(docs/SPEC-TABLES.md §3, §3.1, §3.2, §5).

ir/tablewire.go carries the identity and the kinds this form adds: one
hash at one width — fnv1a64, no fold and no rebound — for a field, an
enum variant, a union arm and a table's own name; the reserved
node-table id 0xFFFFFFFFFFFFFFFF; the map entry's two constants; kind 30
for an enum, kind 31 for the escape, kind 32 for a payload-free arm; and
the form byte.

internal/tablewire is the form whole: the form byte read first, the body
as `id reference, kind, payload` terminated by the zero reference,
canonical LEB128 for every length, count, index and reference, the
trailing id table in first-use order located from the end, and the node
table under the reserved id in one field rather than a chain of them.

The page's worked save is a golden of its own: the 120 bytes of §3.1
reproduce byte for byte, and every id on the page — head, palette, Node,
value, next, Palette, id — reproduces from the hash alone.

Nine gates ride with it: the form refusal at 0, 2 and 0xFF, the
reference bound in both directions, the non-canonical reference, the
repeated entry, the stray byte before the table, the unnameable entry
that counts nothing, the key reference of 0, and the keyed body under
the positional kind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The generated codecs move to the form whole (docs/SPEC-TABLES.md §3,
§3.1, §3.2): the form byte, LEB128 for every length, count, index and id
reference, 64-bit identity, the trailing id table located from the end,
and the three kinds this form adds.

The runtime gains what the form needs and nothing else: `putleb` and
`getleb` with the canonical check, `TableIds` — the writer's table, its
capacity a compile-time fact of the unit's closure so a save still
allocates nothing — `TableIdTable` for the reader, `TableOpen` with its
three verdicts, and `TableBodyEndsEarly` for the stray byte between the
root's terminator and the trailer.

A length cannot be patched in place when its own width varies, so every
length is MEASURED before it rides: the array, keyed and map bodies join
the nested table in computing their length first. The id table is built
by the walk that writes, and an ELIDED field costs no entry — the walk
interns, builds the payload that decides, and truncates when nothing
rides.

An arm header is a field header now: the arm reference, the arm's KIND
byte, its L and its payload. A retyped arm is a kind mismatch where a
retyped field is one, and an arm whose L is not its kind's width is that
arm's own framing damage.

The node table rides in ONE field: a 64-bit L frames a numbering of any
size, so the chunking and its two ceilings are gone.

The descriptors carry uint64_t ids, and the report gains the refusal
verdict — five zero counters and a false flag are what a clean read
prints too.

Held so far by the corpus compiling under -Wall -Wextra -Werror -Wshadow
in every unit, and by 36 of the 46 conformance instances agreeing with
the compiler's own engine byte for byte; the other ten are pinned by
hand-built old-form bytes in test/tables/main.cpp, which the next commit
moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…seline 7 (#435)

The two texts an id is taken over move with the wire (docs/SPEC-TABLES.md
§18.1, §20.2).

The COOK PROJECTION is form 3: every `field` and `slot` id renders at
sixteen hex digits, an enum field renders under its own kind 30, and a
map entry's anonymous record key is two 64-bit ids. §20.2's worked
example now reproduces character for character, and both of the page's
digests reproduce from it — 0xa9df771f6051391f for the worked unit and
0xc4adaacfe6cb2809 for the same unit with no table.

The TABLES BASELINE is rendering version 7: every `id=` on every line is
sixteen hex digits of fnv1a64, which the page names as a rendering fact
of the same shape as a new judged token, so a file written under the
narrower spelling refuses and names `--update`. The four corpus
baselines are regenerated with that reason recorded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#435)

ONE ID IS HELD BACK, the node table's 0xFFFFFFFFFFFFFFFF, and no
declared name may produce it — a `was` alias included, because the alias
is what rides (docs/SPEC-TABLES.md §5, §11). Two TABLES of one closure
whose name ids collide are refused naming both, because a node record
says what it is by that id alone (§3.1).

The field, enum-variant and union-arm collision refusals move to
sixty-four bits with the rest of the identity.

At sixty-four bits no input reaches any of them, so the controls PLANT
THE COLLISION BELOW THE HASH through a compiler test hook that returns
the colliding value for one named spelling. Eight rows go red under a
plant and green without one, which is what says each row shows the
refusal rather than the source — the two 16-bit fold pairs the old rows
leaned on (`costarring`/`liquid`, `Agj`/`Atj`) cannot collide at this
width and are gone.

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

Copy link
Copy Markdown
Contributor Author

Rulings on the three open points:

  1. The flags block renders after the enum blocks and before the unions: enum, flags, union, in that order, each block sorted as the enum block is. State it in §20.2 in this PR.
  2. The dispatch mechanism is the implementation's, not the contract's. §3's sentence about resolving each entry to its slot at open and dispatching through an array index becomes: a reader resolves each reference through the id table once, and how it dispatches from the resolved id is its own; the measured figure stays as measured on the reference. Amend the page in this PR.
  3. The ports. The eight ports keep writing and reading the previous form until each is ported; two forms never coexist on one wire, so their table-wire surfaces become ABSENT in the conformance registry when the corpus is re-pinned: each port driver's list drops wire and report, the per-leg table gates are disabled by name, and one issue per port is filed and cited from PORTING.md and the matrix. The fixed-class table-wire row of the matrix reads reference-only until the sweep re-ports each language, which the sweep was going to do for the eleven C++-only rows anyway. This is the visible cost of the form change and it is stated, not hidden.

gafferongames and others added 9 commits September 4, 2026 21:26
test/tables/main.cpp is rewritten to the grammar. Every hand-built wire
in it was magic bytes of the previous framing; each is now built through
a WireBuilder that speaks §3 — references interned in first-use order,
canonical LEB128 lengths, and the id table the body actually named laid
out behind it — so a forgery says the one thing that is wrong and the
framing around it is the page's.

`seed` is what a repeated-id row needs: the writer's own body minus its
terminator and the table it named, so a second occurrence splices in
under the same references, which is what a repeat IS (§3).

The independent field-id implementation moves to fnv1a64, no fold and no
rebound, and pins the compiler's hash at sixty-four bits. An empty wire
is ten bytes, not two. The framing arithmetic is spelled from the
grammar rather than as `3 + 4 + 5`.

THE ARM EVOLUTION ROWS MOVE WITH THE FORM (§4): an arm header carries the
arm's KIND now, so an int32 arm read as a float32 arm and a scalar arm
read as a string arm are KIND MISMATCHES where they were §4.1's silent
class, and four bytes read as a body is a kind mismatch where it was
framing damage. A2.schema's own comment says what each row is.

And the CROSS-IMPLEMENTATION LOCK closes: all 46 text-carrying instances
agree byte for byte between the reference and the compiler's engine, held
by a Go gate that encodes each instance's text and compares.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hand-built row wires modelled the previous framing with fixed
offsets and u32 patches. A canonical LEB128 length cannot be patched in
place, so the row is built FROM THE GRAMMAR instead: a RowSpec names the
entries and one knob, and every reader control turns that knob rather
than editing bytes behind the writer's back.

The WireBuilder moves to test/tables/wirebuilder.h so the batteries share
one implementation of the form rather than three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every wire golden, every cooked pair, the block images and reports.txt
are the reference leg's under the id-table form, and `go test ./...` is
green over them — internal/tablepack included, which reads the pinned
wires through the engine.

The three `parts` report rows were bytes fixed by the manifest that
nothing regenerated. They are the reference's now, built from §3's
grammar and pinned by the battery like every other golden, so a form
change moves them with everything else instead of leaving them behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Five zero counters and a false flag are what a clean read prints too, so
a FORM BYTE this reader does not carry needs a VERDICT to be visible at
all (docs/SPEC-TABLES.md §3). Every report row is now five counters and
`read` or `refused`: the engine's, the C++ driver's surface, and
FORMAT.md's own statement of the shape.

Three report rows carry it — form 0, form 2 and form 0xFF over a wire
that is otherwise a good v1 Cfg — and the battery holds each to the whole
rule: the verdict refused, no counter moved, no damage reported, and the
value left at its declared defaults. The form byte is read FIRST, so a
file that is both a newer form and damaged is a refusal too.

A TEXT read never refuses, so the json-hostile rows carry `read`.

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

The framing model moves to the id-table wire (docs/SPEC-TABLES.md §4.2).
Every number is a variable-width LEB128 now, so a mutation is a SPLICE
rather than a patch: the spot's bytes are replaced and every length
framing it grows or shrinks by the same delta, innermost first, so the
one thing wrong with a mutant is the thing the mutator aimed at.

The passes the page names ride with it: the FORM BYTE at 0, 2 and 0xFF;
the REFERENCE CLASS — every reference to 0, to the entry count (the last
legal slot, which must RESOLVE), to the count plus one and to the
extremes; every number in its NON-MINIMAL spellings, one redundant
continuation byte, then nine, and an eleven-byte form; the ID TABLE —
the entry count off by one each way and at both extremes, an entry's own
bytes flipped, and the same id in two entries; and the four 64-bit
extremes on every length, count and index.

The refusal verdict rides the leg's protocol, so a form byte the leg
does not carry is compared as a verdict rather than folded into damage.

IT FOUND THREE READER BUGS, each a real divergence from the page:

  - `has( (int64_t) n )` on a 64-bit length: 0xFFFFFFFFFFFFFFFF cast to
    int64 is -1, and a negative length looked like room. The fit check
    is unsigned now, as `room( uint64_t )`.
  - `getleb` advanced the cursor on a number it REFUSED, so a body whose
    terminator was non-canonical still ended exactly at its L and the
    damage was stepped over. A refused number leaves the cursor alone.
  - a damaged COUNT dropped an optional array's PRESENCE: the field rode,
    so it is present (§2.3), and only a foreign ELEMENT KIND says the
    payload is not the array's at all.

And two rules the C++ did not carry: the reserved id inside a NESTED
body is malformed (§3.1), and a body carrying the node-table id twice
keeps the LAST occurrence (§3).

91,587 enumerated mutants over 80 seeds, 0 divergences, plain and under
ASan/UBSan. All four negative controls go red on their own check.

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

Nine new report rows for the kinds this form added, each built from §3's
grammar and pinned by the reference: kind 31 at a field the reader
CANNOT name (unknown), at a field it CAN (kind mismatch), at an arm, and
as an element kind; kind 32 at a named field. WHERE THE READER MEETS
THEM DECIDES WHICH EVENT IT COUNTS, and each row says which.

Four more for THE ARM'S KIND BYTE: an enum arm and a pointer arm read as
an integer arm, a `bytes` arm read as a `string` arm — kind mismatches
with the union None and the siblings intact — and an arm whose `L` is not
its kind's width, which is that arm's own framing damage.

K1 and K2 are the ENUM-AGAINST-INTEGER pair: the same two field names
with the spellings swapped, so one wire read the other way names both
directions at once. Two kind mismatches each way, both fields at their
declared defaults.

And the ELISION, FIRST-USE AND DISTINCTNESS instrument: over every pinned
instance, the id table must hold exactly the ids the body names, once
each, in the order the body first names them. It says WHICH of the three
rules a drifted wire broke where the byte compare only says one moved.

The fuzzer's locator gained what the instrument needed to be true: a
nested-union arm's payload, an enum or pointer arm's reference, a keyed
triple's element by its own kind, and a BLOB record's body left opaque.
Aiming at those spots found one more divergence, and the leg was right:
a damaged count inside an ARM's `L` is that arm's own framing damage, so
the union reads None (§3) where a FIELD would stay present.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The compiler's engine carries maps now (docs/SPEC-TABLES.md §2.8). A map
rides as kind 14 over element kind 13, an array of the generated
`{ key, value }` entry, so nothing here is a second framing. What is the
map's own is the ORDER and the reader's rules, and both halves have them:

  - the WRITE side sorts into ASCENDING KEY ORDER with no key twice, and
    an empty map elides under §3's by-value rule;
  - the READ side scans each entry's body for the KEY BEFORE choosing its
    slot, keeping the last occurrence §3's repeated-field rule keeps;
  - the KEY KIND is the reader's declaration, never the first entry's, and
    a key under another kind empties the map for ONE kind_mismatch;
  - KEYS NEVER CLAMP: a key past the bound drops its whole entry and counts
    clamped, one per entry;
  - a repeated key is `duplicate` and last wins WHOLE; a descending key
    stops the map with its ascending prefix and flags malformed, and the
    parent reads on past the field's length.

The TEXT form is a plain JSON object keyed by the key, with integer keys
spelled as strings of digits, written in the same ascending order.

Held against the reference: the engine reads the C++ leg's three pinned
map wires — a map, an empty map, and a map of maps with a keyed array and
a union arm holding one — writes them back BYTE FOR BYTE, renders the
text and reads that text back to the same bytes.

The by-name refusal narrows to the COOK surfaces, which is what the tool
still owes, and says so.

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

§2.8 gains the four sentences #504 left open: iteration on `map[K]*T`
yields the resolved pointer as `Find` does; a wrong key kind that also
desynchronizes the scan is that one kind mismatch and nothing more, the
map reading empty; an entry whose alignment exceeds the arena's is
refused at compile time by name; and `Find` on a builder-form map is a
program error, as it is for a TableRef, the form saying which encoding is
in force.

§3's dispatch sentence becomes what it is: the table is resolved once at
open and a body names an id by POSITION, and HOW a reader turns a
reference into a decision is its own choice — nothing on the wire depends
on it and no conformance case can see the difference.

§20.2's `flags` block lands, after the enums and before the unions, and
the projection emits it: each variant with its BIT POSITION, counted from
zero, because the bit is what a stored mask means.

The measured paragraph is re-pinned from THIS tree: 0.98x without the
210 KB blob, 1.80x over the tiny class, 0.99x over the whole corpus, over
69 pinned wires. The DISPATCH nanoseconds are not re-stated — they need a
bench sitting of their own, and a number measured on a laptop under load
is worse than none.

ONE PAGE CONFLICT the flags block exposes, resolved in the direction
§20.2's own reason points: §20.1's table said WHICH flags declaration a
field names carries nothing, and the block makes that false — a field
swapped to a declaration with other variants changes what every stored bit
MEANS. The row now says so, and the control moves from "stays" to "moves".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The C++ authoring walks visited every pointer slot whatever the value
said. §3.1 states otherwise: a pointer under a FALSE GUARD is not visited
and its target takes no index, so a save never writes a record no written
field names — and it is the same walk `Lock` lays a region out in, so the
region holds no such node either. The numbering, PackMeasure and Pack are
one walk here, so gating it gates all three.

G1 is the corpus row and its control: one value with both pointer slots
filled, saved under a false guard and again under a true one — one record
against two, with the region size moving with them. A walk that visited
every slot would write two both times.

The OTHER half of §3.1's sentence, a pointer inside an ABSENT OPTIONAL,
has no declaration to write: `?T` over a variable-length closure is a
named follow-on (§2.3, §15), and its diagnostic names exactly this change
as the precondition — "the authoring walks must gate on the presence
companion before an optional field may hold pointer edges". The walks now
do; lifting the follow-on is a separate decision and is not taken here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d each (#435)

The corpus is pinned in the id-table form and the eight ports write the
previous one, so every surface whose expectation is WIRE BYTES says
ABSENT rather than failing: `wire`, `report`, `json-read`, `json-write`
and `json-hostile`. That is five and not two — json-read writes wire
bytes from a text, json-write reads wire bytes to a text, and
json-hostile packs a hostile text to wire — so all five move together on
the same reasoning.

Each driver's `list` says so where a reader will find it, naming the
port's issue: Go #511, C #512, C# #513, Dart #514, Elixir #515,
JavaScript #516, Java #517, Rust #518. Each issue states what moves and
the rows the port passes to get its surfaces back.

PORTING.md gains M20, the id-table wire, with the reference's cells and
the eight gaps; M19's cell reads the tool's map halves too.

`make conformance` is green again: the reference leg passes every
registered surface, and the ports keep cook, block and the forgery
batteries — none of which the wire moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ces (#435)

Seven of the harness's negative controls sabotage a port's answer on a
surface that is now ABSENT — `go`, `go-walk`, `c`, `cs`, `dart`,
`elixir`, `js` and `java` all turn `wire` or `json-read` red, and there
is no longer a cell there to turn. Each says so in one line naming its
port's issue rather than passing silently or failing on nothing, and the
sed scripts that drove them are gone rather than left to rot.

What still proves the harness can go red is untouched: the reference
leg's own control, the block-dump one, the absence controls and the
reference-surface one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames gafferongames changed the title tables: the id-table wire — the law, the engine and the C++ reference (#435) tables: the id-table wire (#435) Sep 4, 2026
@gafferongames
gafferongames marked this pull request as ready for review September 4, 2026 12:54
gafferongames added a commit that referenced this pull request Sep 4, 2026
Cold read of #510, changes named by the review.

Table closures (land-and-expand): the id-table wire assigns wide text no
kind, so SPEC-TABLES.md §11 refuses by name a type holding a wstring(N)
field inside a table closure, and §2.8 says wstring(N) is not a map key.
§4.12 carries the cross-reference and states that the packet wire is done
while the table wire is the deferred half, with no ProjectionVersion bump
for either. Expand half: #522, after #507 stops re-pinning table goldens.

The write side is now precise: the used length within [0, N] and a zero code
unit among the used units are refused on write in every target's own idiom,
symmetric with the reader's zero-group refusal and with §4.7's interior
null. Surrogate pairing is not checked on write and the page says whose job
it is. Elixir additionally requires an even byte_size and raises
ArgumentError otherwise.

Also: exhaustion is stated at any group rather than as a positional list
entry; the storage table names its departure from the reference's
destination rule and why (§6.1 forbids the per-value allocation); the
allocation claim carries the estate's standing Elixir exception (PORTING.md
M1); the corpus refusals ride gate 7 rather than gate 5 and the two length
refusals ride at wstring(4); and the interop field, the schema-side golden
pins and the examples/ declaration are named as owed by the first
implementation PR.
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against bin/schema built from b74a7d4 (#507). What moved: a saved
ShipConfig is 89 bytes where it was 42, an all-default one is 10 where it was
2, the report carries a sixth member (the refusal verdict), and a truncated
file now yields nothing at all rather than a good prefix — the id table is the
trailer, located from the end, so a cut file has no vocabulary to name its
fields with. The page now teaches the form byte and the refusal verdict beside
the five counters. What did not move: every drift result and every counter
value, in both directions, and all nine targets still generate the surface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). Every Measure moved: the fighter is 10 and 22
where it was 2 and 11, GameConfig is 142 where it was 99, and the three tool
messages are 79, 49 and 45 where they were 42, 22 and 18. What did not move:
the optional storage layout, all four ? refusals word for word, the None-key
abort in a release build, the keyed array's survival of a mid-enum insertion
with unknown=0, and the message union's tag enum and arms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: the route wire is 163 bytes where
it was 172, the fleet is 145 where it was 165, and the four-thousand-node
parallel build is 51,904 where it was 111,989 — the id table in the trailer
carries each field id once, so a pointer-heavy graph is where the new form is
cheapest. The page now says so beside the number. What did not move: the region
size, sharing and null preserved through the round trip, the builder's 8,264
bytes, the map's ascending-key iteration and Find, and all three pointer
refusals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
…#447)

Re-run against b74a7d4 (#507). Nothing in part 9 moved: the JSON a table
writes and reads is text, and the wire's framing does not reach it. Every
document, every report line, the &node graph form with sharing preserved, the
unpacked tree's file list, --one-file, and the strict-mode refusal are all
byte-identical to what the page carries. The packed file is larger for the
reason part 6 now explains, and the page never printed its size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: the baseline projection is version
7 where it was 6, and its field ids are the wire's new 64-bit hashes where they
were 16-bit. What did not move: every one of the three verdicts word for word,
both was guard rails, the --update refusal without a reason, the history block
the override appends, and the was round trip reading a v1 file into the renamed
field with unknown=0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: both build versions, and the facts
projection is form 3 with 64-bit field ids where it was form 2 with 16-bit ones.
What did not move: the cook's own sizes — 408 bytes, 328 data, 16 attribution —
because a cook is a laid-out region and not the wire, so the id-table form does
not reach it. Nor did Open, cook-check, the uncook byte-for-byte proof, the
big-endian refusal on a little-endian build, the target-neutral build version
across byte orders, or the protocol id's independence from a table edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: the build version stamped in the
block prologue, in both the C++ and the C halves. What did not move: the block
is 56,064 bytes of a 196,672 maximum, exactly as before — the block form is a
laid-out extent and the id-table wire does not reach it — and the count refusal,
the C read through the aligned base, and the same-build refusal after a
one-sided rebuild are all unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). The packet-wire handoff is unchanged: C reads
the C++ packet bit for bit. The table-wire handoff is NOT, and the page now
says so rather than claiming a crossing that does not happen today. C++ reads
its own 89-byte save cleanly and both C and Go answer malformed on the same
bytes, because the reference is on the new form and the eight ports have not
moved to it. The page shows all three outputs, names what the ports do with
bytes they cannot read (report malformed, misdecode nothing), notes that only
C++ carries the refusal verdict, and points a reader who needs another language
today at the packet wire, the cook or the block. Filed as G-15 in #521.

Also moved: the reflection walk's ids are 64-bit and the enum field's kind is
30 where it was 7. The embedding demo is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
#447)

Re-run against b74a7d4 (#507). The command map, the misplaced-flag failure, the
baseline notice, all four edge refusals and the absent UnitView are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
The next red behind the zero-cost gate, and the control caught itself: #507
hardened the map's N-against-L check to compare unsigned, the way §3 requires
every length, count and index on this wire to be compared, and the control's sed
still named the signed spelling. Its own "the sabotage patched nothing" guard is
what said so, which is the guard doing its job.

The sabotage now names the current line and the gate goes red on three cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
* certify: the oracle never panics, and the release legs after #507

THE ORACLE PANICKED, so it was not an oracle. An array, map or keyed body is
framed by its own `L`, and the three readers compute `end := r.off + bodyLen`
and later span the elements with `r.sub(end - r.off)`. Between those two lines
they read the body's header, whose count is a canonical LEB128 read against the
PARENT buffer rather than against `end`. A body's `L` of 2 is the shortest that
carries a header at all, but a LEB128 count is up to ten bytes, so a count
spelled wide leaves the cursor past the end its own `L` set and the span goes
negative.

The C++ reference has the same shape at every one of its sites, but a negative
span there is a reader whose `has()` refuses, so it decodes no elements and
reports malformed on the first one asked for. `subTo(end)` gives the oracle that
same answer by clamping the span at the cursor, and all three body readers go
through it, so the invariant is stated once.

The reference agrees on the mutant, replayed alone and across the whole run: 0
divergences.

THE MUTANT IS PINNED. testdata/wire/tables/fuzz-vectors/ is a corpus of mutants
the fuzzer has already gone red on, seeded into every run by name and fed
exactly as it is, before the random pass. Vectors are never mutated and the
random pass never draws from them, so the mutant sequence stays a function of
the corpus and the seed alone.

A RED IS A SEEK. The run seed was already deterministic, but the failure
message printed neither it nor the mutant. It prints both now, the bytes as hex
up to 4 KB and the SHA-256 above that.

The negative control is tables-wire-fuzz-oracle-negative-control, which joins
tables-wire-fuzz-negative-control and so runs in `make test`: it removes the
clamp through a Go build overlay and requires the run to go red on the panic
AND on the pinned vector by name.

THE PORT GATES THAT READ THE ID-TABLE CORPUS. Four release legs failed on one
cause: they hold a port's codec to testdata/wire/tables, which is the id-table
form now, while the port still writes the wire's previous form. #507 already
ruled on that shape and spelled it as dormancy; what it missed is every gate
that calls a port's wire codec directly rather than through the conformance
harness. The same rule now covers those, by name, citing the same issue per
port: #511 Go, #512 C, #513 C#, #514 Dart, #515 Elixir, #516 JavaScript, #517
Java. No Rust gate reads the corpus.

It reaches past the release legs: `make test` runs the port legs after the wire
fuzzer, so the panic masked them.

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

* tables: the zero-cost gate scans by symbol, and sanctions the reserved id

The gate is the next red on main behind the wire fuzzer's panic, and it is
#507's too. The id-table form's reserved node-table id is `kTableNodeTableFieldId`
in every generated unit, because every reader of the form owes §3.1's refusal of
that id inside a NESTED body whether or not its own closure carries a pointer:
the body it is handed may have been written by a unit that does (§4). The
gate's `TableNode` token matched the constant and called it pointer machinery.

What a pointer-free unit pays for the rule is one `static const uint64_t` and
one comparison. No arena, no builder, no handle, no lifecycle surface and no
extra descriptor column, which is the claim this gate holds.

So the scan is BY SYMBOL now rather than by line, `TableNode` is matched with
its whole spelling so a node symbol nobody has written yet is still refused, and
exactly one spelling is sanctioned by name. tables-zero-cost-negative-control
plants TableNodeMap in a copy of a scanned header and requires the same scan to
refuse it, so the one sanctioned symbol is shown to be the only one.

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

* certify: drop the variables the dormant recipes were the only readers of

The four Elixir sabotage variables and the three Dart GC-count variables
existed for recipes that are now dormant echoes, so they are scaffolding with
nothing behind it. The tunables stay: DART_SOAK_SECONDS, JAVA_SOAK_SECONDS and
their kind are the knobs each gate wakes with.

The failure-message const also moves above wireFailure's own doc comment, which
it had split.

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

* tables: the map fit control's sabotage matches the line #507 left

The next red behind the zero-cost gate, and the control caught itself: #507
hardened the map's N-against-L check to compare unsigned, the way §3 requires
every length, count and index on this wire to be compared, and the control's sed
still named the signed spelling. Its own "the sabotage patched nothing" guard is
what said so, which is the guard doing its job.

The sabotage now names the current line and the gate goes red on three cases.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against bin/schema built from b74a7d4 (#507). What moved: a saved
ShipConfig is 89 bytes where it was 42, an all-default one is 10 where it was
2, the report carries a sixth member (the refusal verdict), and a truncated
file now yields nothing at all rather than a good prefix — the id table is the
trailer, located from the end, so a cut file has no vocabulary to name its
fields with. The page now teaches the form byte and the refusal verdict beside
the five counters. What did not move: every drift result and every counter
value, in both directions, and all nine targets still generate the surface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). Every Measure moved: the fighter is 10 and 22
where it was 2 and 11, GameConfig is 142 where it was 99, and the three tool
messages are 79, 49 and 45 where they were 42, 22 and 18. What did not move:
the optional storage layout, all four ? refusals word for word, the None-key
abort in a release build, the keyed array's survival of a mid-enum insertion
with unknown=0, and the message union's tag enum and arms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: the route wire is 163 bytes where
it was 172, the fleet is 145 where it was 165, and the four-thousand-node
parallel build is 51,904 where it was 111,989 — the id table in the trailer
carries each field id once, so a pointer-heavy graph is where the new form is
cheapest. The page now says so beside the number. What did not move: the region
size, sharing and null preserved through the round trip, the builder's 8,264
bytes, the map's ascending-key iteration and Find, and all three pointer
refusals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
…#447)

Re-run against b74a7d4 (#507). Nothing in part 9 moved: the JSON a table
writes and reads is text, and the wire's framing does not reach it. Every
document, every report line, the &node graph form with sharing preserved, the
unpacked tree's file list, --one-file, and the strict-mode refusal are all
byte-identical to what the page carries. The packed file is larger for the
reason part 6 now explains, and the page never printed its size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: the baseline projection is version
7 where it was 6, and its field ids are the wire's new 64-bit hashes where they
were 16-bit. What did not move: every one of the three verdicts word for word,
both was guard rails, the --update refusal without a reason, the history block
the override appends, and the was round trip reading a v1 file into the renamed
field with unknown=0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: both build versions, and the facts
projection is form 3 with 64-bit field ids where it was form 2 with 16-bit ones.
What did not move: the cook's own sizes — 408 bytes, 328 data, 16 attribution —
because a cook is a laid-out region and not the wire, so the id-table form does
not reach it. Nor did Open, cook-check, the uncook byte-for-byte proof, the
big-endian refusal on a little-endian build, the target-neutral build version
across byte orders, or the protocol id's independence from a table edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). What moved: the build version stamped in the
block prologue, in both the C++ and the C halves. What did not move: the block
is 56,064 bytes of a 196,672 maximum, exactly as before — the block form is a
laid-out extent and the id-table wire does not reach it — and the count refusal,
the C read through the aligned base, and the same-build refusal after a
one-sided rebuild are all unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Re-run against b74a7d4 (#507). The packet-wire handoff is unchanged: C reads
the C++ packet bit for bit. The table-wire handoff is NOT, and the page now
says so rather than claiming a crossing that does not happen today. C++ reads
its own 89-byte save cleanly and both C and Go answer malformed on the same
bytes, because the reference is on the new form and the eight ports have not
moved to it. The page shows all three outputs, names what the ports do with
bytes they cannot read (report malformed, misdecode nothing), notes that only
C++ carries the refusal verdict, and points a reader who needs another language
today at the packet wire, the cook or the block. Filed as G-15 in #521.

Also moved: the reflection walk's ids are 64-bit and the enum field's kind is
30 where it was 7. The embedding demo is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
#447)

Re-run against b74a7d4 (#507). The command map, the misplaced-flag failure, the
baseline notice, all four edge refusals and the absent UnitView are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
* docs: the tutorial, part 1 — a file, a package, and your first constants (#447)

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

* docs: the tutorial, part 2 — enums and flags (#447)

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

* docs: the tutorial, part 3 — the first packet, types and ranged integers (#447)

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

* docs: the tutorial, part 4 — the rest of the field types (#447)

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

* docs: the tutorial, part 5 — branches, unions, and your protocol (#447)

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

* docs: the tutorial, part 6 — tables, data that outlives the build (#447)

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

* docs: the tutorial, part 7 — optionals, keyed arrays, and a config format (#447)

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

* docs: the tutorial, part 8 — pointers and maps (#447)

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

* docs: the tutorial, part 9 — the text form, JSON and the tree workflow (#447)

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

* docs: the tutorial, part 10 — was and the tables baseline (#447)

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

* docs: the tutorial, part 11 — the cook and the build version (#447)

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

* docs: the tutorial, part 12 — the block form (#447)

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

* docs: the tutorial, part 13 — every language, reflection, and embedding the compiler (#447)

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

* docs: the tutorial, part 14 — the tool belt and the edges (#447)

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

* docs: the tutorial in the README hub, and colon-separated part headings (#447)

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

* docs: the tutorial says to put bin on your path (#447)

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

* docs: the tutorial, fence spacing in part 1 (#447)

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

* docs: the tutorial re-run on main at b74a7d4, parts 1 to 5 unmoved (#447)

Every command and program in parts 1 through 5 re-run against bin/schema built
from b74a7d4. The packet wire is untouched by the id-table wire: both protocol
ids, every generated header excerpt, every diagnostic and every program output
are byte-identical to what the page already carried. Only the build stamp in
part 1's install block moved.

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

* docs: the tutorial, part 6 re-run on the id-table wire (#447)

Re-run against bin/schema built from b74a7d4 (#507). What moved: a saved
ShipConfig is 89 bytes where it was 42, an all-default one is 10 where it was
2, the report carries a sixth member (the refusal verdict), and a truncated
file now yields nothing at all rather than a good prefix — the id table is the
trailer, located from the end, so a cut file has no vocabulary to name its
fields with. The page now teaches the form byte and the refusal verdict beside
the five counters. What did not move: every drift result and every counter
value, in both directions, and all nine targets still generate the surface.

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

* docs: the tutorial, part 7 re-run on the id-table wire (#447)

Re-run against b74a7d4 (#507). Every Measure moved: the fighter is 10 and 22
where it was 2 and 11, GameConfig is 142 where it was 99, and the three tool
messages are 79, 49 and 45 where they were 42, 22 and 18. What did not move:
the optional storage layout, all four ? refusals word for word, the None-key
abort in a release build, the keyed array's survival of a mid-enum insertion
with unknown=0, and the message union's tag enum and arms.

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

* docs: the tutorial, part 8 re-run on the id-table wire (#447)

Re-run against b74a7d4 (#507). What moved: the route wire is 163 bytes where
it was 172, the fleet is 145 where it was 165, and the four-thousand-node
parallel build is 51,904 where it was 111,989 — the id table in the trailer
carries each field id once, so a pointer-heavy graph is where the new form is
cheapest. The page now says so beside the number. What did not move: the region
size, sharing and null preserved through the round trip, the builder's 8,264
bytes, the map's ascending-key iteration and Find, and all three pointer
refusals.

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

* docs: the tutorial, part 9 re-run on the id-table wire, nothing moved (#447)

Re-run against b74a7d4 (#507). Nothing in part 9 moved: the JSON a table
writes and reads is text, and the wire's framing does not reach it. Every
document, every report line, the &node graph form with sharing preserved, the
unpacked tree's file list, --one-file, and the strict-mode refusal are all
byte-identical to what the page carries. The packed file is larger for the
reason part 6 now explains, and the page never printed its size.

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

* docs: the tutorial, part 10 re-run on the id-table wire (#447)

Re-run against b74a7d4 (#507). What moved: the baseline projection is version
7 where it was 6, and its field ids are the wire's new 64-bit hashes where they
were 16-bit. What did not move: every one of the three verdicts word for word,
both was guard rails, the --update refusal without a reason, the history block
the override appends, and the was round trip reading a v1 file into the renamed
field with unknown=0.

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

* docs: the tutorial, part 11 re-run on the id-table wire (#447)

Re-run against b74a7d4 (#507). What moved: both build versions, and the facts
projection is form 3 with 64-bit field ids where it was form 2 with 16-bit ones.
What did not move: the cook's own sizes — 408 bytes, 328 data, 16 attribution —
because a cook is a laid-out region and not the wire, so the id-table form does
not reach it. Nor did Open, cook-check, the uncook byte-for-byte proof, the
big-endian refusal on a little-endian build, the target-neutral build version
across byte orders, or the protocol id's independence from a table edit.

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

* docs: the tutorial, part 12 re-run on the id-table wire (#447)

Re-run against b74a7d4 (#507). What moved: the build version stamped in the
block prologue, in both the C++ and the C halves. What did not move: the block
is 56,064 bytes of a 196,672 maximum, exactly as before — the block form is a
laid-out extent and the id-table wire does not reach it — and the count refusal,
the C read through the aligned base, and the same-build refusal after a
one-sided rebuild are all unchanged.

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

* docs: the tutorial, part 13 re-run on the id-table wire (#447)

Re-run against b74a7d4 (#507). The packet-wire handoff is unchanged: C reads
the C++ packet bit for bit. The table-wire handoff is NOT, and the page now
says so rather than claiming a crossing that does not happen today. C++ reads
its own 89-byte save cleanly and both C and Go answer malformed on the same
bytes, because the reference is on the new form and the eight ports have not
moved to it. The page shows all three outputs, names what the ports do with
bytes they cannot read (report malformed, misdecode nothing), notes that only
C++ carries the refusal verdict, and points a reader who needs another language
today at the packet wire, the cook or the block. Filed as G-15 in #521.

Also moved: the reflection walk's ids are 64-bit and the enum field's kind is
30 where it was 7. The embedding demo is unchanged.

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

* docs: the tutorial, part 14 re-run on the id-table wire, nothing moved (#447)

Re-run against b74a7d4 (#507). The command map, the misplaced-flag failure, the
baseline notice, all four edge refusals and the absent UnitView are unchanged.

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

* docs: the tutorial, part 1 rewritten for the cold read (#447)

One cumulative unit named starlight, and the prerequisites a stranger needs
before parts 3 and 13: the Go toolchain, the clone, make, the PATH step, and
the two sibling serialize checkouts at the tags this build pins (v1.16.2 and
v1.9.2). Every diagnostic now shows its Bad.schema before its output, with the
error trailer. New: a paragraph saying once that every id and byte count on the
page belongs to the unit as it stands at that point, and naming schema id and
schema projection as the way to compare.

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

* docs: the tutorial, part 2 rewritten for the cold read (#447)

Enums and flags land in the cumulative starlight unit, with the whole file shown
after the edit. Pending is declared where it is discussed. Every diagnostic
shows its Bad.schema and its error trailer. SystemFlagsNamesMax is shown as
generated rather than asserted, the ADL note now points at the program's
using-directive, and the program is shown entire and runs.

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

* docs: the tutorial, part 3 rewritten for the cold read (#447)

ShipState joins the cumulative unit with the constant it needs shown in place.
The program is shown entire, with the forged-packet half in it rather than as a
loose fragment, and the compile line names -I gen -I ../serialize, the checkout
part 1 now makes. The five range refusals show the Bad.schema shape they share
and each carries its error trailer.

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

* docs: the tutorial, part 4 rewritten for the cold read (#447)

Vector2/GameVector2 replaces the Vec2/Vec3/GameVec2 muddle: one declaration in
its own file of the same unit, one native header shown in full, one field on
ShipState, and the program exercises the operator. The retired [<= N] sentence
is gone and the [2..8] count hazard is demonstrated with the generated write
rather than asserted. Game.schema is shown entire at the end of the part, and
the program compiles as shown with -I gen -I . -I ../serialize.

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

* docs: the tutorial, part 5 rewritten for the cold read (#447)

ChatLine, Snapshot and MaxPayloadsPerPacket are declared where the page uses
them, in Net.schema of the same unit, and the program builds and reads a real
two-payload Packet rather than a loose FireCommand. The scalar-arm refusal is
shown here, where a reader first wants it, and it names the table closure part 7
builds. The id demonstration adds an arm and takes it back out, showing the id
return.

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

* docs: the tutorial, part 6 rewritten for the cold read (#447)

Ruling 1's rule lands here, where tables first appear: the table wire is C++'s
within one build, the eight ports are named with their issues #511 to #518, and
the packet wire, cook and block cross all nine. The check notice is explained
with this unit's own output and its exit status. The evolution demo copies the
whole unit to starlight-2.0 and starlight-3.0, so both programs are shown entire
and every file they read is created on the page. TableReport is shown and printed
with all six members, duplicate included, and the truncation claim is
demonstrated at three cut points rather than asserted. The flags law now uses
this unit's own SystemFlags.

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

* docs: the tutorial, part 7 rewritten for the cold read (#447)

GunnerSettings, WeaponConfig and MaxWeapons are declared where the page uses
them. The second unit lands here as the ruling asks: starlight/tools, its own
package, holding the C++-only table forms (a union a table closure holds, with
table, scalar and payload-free arms; an array of unions; *string and *bytes),
introduced by the refusal that names the move. The main unit is shown still
generating for all nine. config.cpp, none.cpp, keyed.cpp and tools.cpp are each
shown entire with their compile lines, and config.bin is created here for parts
9 and 11.

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

* docs: the tutorial, part 8 rewritten for the cold read (#447)

Pointers stay in the starlight unit, which still generates for all nine, and
maps move to the tools unit where the C++-only forms live. SetName and heads are
declared in the programs that use them, and route.cpp, damage.cpp, wide.cpp and
fleet.cpp are each shown entire with their compile lines.

The 'a forged wire with a reference loop is refused' claim is replaced by what
the loader actually does: an exhaustive single-bit sweep of an 80-byte pointered
wire, showing 295 stopped, 345 opened, and one that hands back a self-referential
node under an all-zero report. The page tells the reader to bound their own
walks. Filed as G-19 in #521.

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

* docs: the tutorial, part 9 rewritten for the cold read (#447)

json.cpp and loadpacked.cpp are shown entire with their compile lines, and the
graph half is in the same program rather than a loose fragment. The level_name
edit is shown before the load prints it, the Corvette edit is shown as the file
the designer saves, and the 'very strong' edit is reverted and repacked on the
page before part 11 cooks the tree. Absent optionals not appearing in ToJson is
stated where the output shows it.

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

* docs: the tutorial, part 10 rewritten for the cold read (#447)

The baseline is created over the real cumulative unit, so the notice part 6
introduced is answered here and check goes silent again on the page. ShipConfig
gains a systems SystemFlags field so the flags law is demonstrated on this unit's
own flags rather than an invented one. The was diagnostic is shown beside the
declaration that produced it, and the was round trip reads ship.bin, written in
part 6 under the old field name.

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

* docs: the tutorial, part 11 rewritten for the cold read (#447)

cook.cpp is shown entire with its compile line, and Mine.cook is produced by the
program on the page before cmp compares it. The build-version demonstration adds
a real field to this unit's GameConfig, shows the baseline passing it, the
protocol id holding and the build version moving in one place, and takes it back
out. Every cook command runs against the tree part 9 packed.

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

* docs: the tutorial, part 12 rewritten for the cold read (#447)

RenderShip, RenderLaser and RenderFrame join the cumulative unit with the two
constants they need, and the unit is shown still generating for all nine.
produce.cpp and consume.c are shown entire with their compile lines, frame.block
is created by the program on the page before the consumer reads it, and
TableBlockRefusal is shown as generated so the field names in the program match.

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

* docs: the tutorial, part 13 rewritten to present state (#447)

Ruling 1: the part now shows the three crossings that work — the packet wire,
the cook and the block, each from the starlight unit into C, each run — instead
of staging a failed table handoff. No rewrite narrative and no in-flight
vocabulary in the page's own voice; the C++-only rule is stated once in part 6
where the promise is made. writepacket.cpp, readpacket.c, opencook.c and
reflect.cpp are shown entire with their compile lines.

The reflection walk now distinguishes an enum's vocabulary from a flags field's
by the variant_id tell, so Shields is no longer dropped, and the Go embedding
example has its go.mod, its imports and a sorted iteration order with the reason
that determinism is a generator's contract.

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

* docs: the tutorial, part 14 rewritten for the cold read (#447)

The 'silent on success' rule is reconciled with the notice part 6 introduced and
part 10 answers, with the rule stated as success is silent except while a table
format is unguarded. Every edge refusal shows its Bad.schema and its error
trailer, the bare-bytes case shows all four errors it really prints, and the
UnitView gap is shown against this unit's own generate listing.

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

* docs: the tutorial, the four programs the page ran without showing (#447)

save2.cpp, load3.cpp, keyed.cpp and j.cpp now appear with their sources and
their compile lines, so every ./program the page runs is a program the page
built. Verified against the real files: load3.cpp was rewritten in the working
tree to match the printf the page shows, and re-run.

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

* docs: the tutorial, the baseline notice comes from check alone (#447)

The cold reader's last item. Only the check branch calls the unguarded-format
nudge; generate prints nothing on success, verified:

  $ schema check .
  notice: nt declares 1 table and . holds no tables.baseline — ...
  $ schema generate --lang cpp --out gen .
  $

Corrected in part 14 and in the identical sentence part 6 carried. Part 10's
separate claim, that generate diffs the closure against a committed baseline,
stands: generate prints the same warnings and the same refusal as check and
exits 1 on a refused edit.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames pushed a commit that referenced this pull request Sep 4, 2026
Corrected against the id-table wire (#507), the reachability-scoped
projection (#527), the refused positional keyed spelling, and the per-language
layout-contract mechanisms. Added the features a stranger could not use from
this page: wstring(N), the /// doc comment and tags, maps, and unbounded
arrays, each with its own status note where no backend emits it yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames pushed a commit that referenced this pull request Sep 4, 2026
Corrected against the id-table wire (#507), the reachability-scoped
projection (#527), the refused positional keyed spelling, and the per-language
layout-contract mechanisms. Added the features a stranger could not use from
this page: wstring(N), the /// doc comment and tags, maps, and unbounded
arrays, each with its own status note where no backend emits it yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
* docs: the two READMEs against the finished specification

README-NEW.md: the field header is a reference, a kind byte and a payload
with the ids in a trailer, and only the skip and the clamp are counted; the
silent class is four edits and not two; the cook's Open checks the reserved
words too and names its refusal beside the null; the block form's layout is
held by a static_assert in C++ and a type-initialization check in C#;
descriptors carry the text key, the doc comment and the tags. Both READMEs
link COMPETITION.md, and README-NEW links PORTING.md and COMPARISON-TABLES.md.

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

* docs: USAGE against the finished specification

Corrected against the id-table wire (#507), the reachability-scoped
projection (#527), the refused positional keyed spelling, and the per-language
layout-contract mechanisms. Added the features a stranger could not use from
this page: wstring(N), the /// doc comment and tags, maps, and unbounded
arrays, each with its own status note where no backend emits it yet.

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

* docs: VERSIONING against the finished specification

The field-header sentence now matches the id-table wire; the projection's
exclusion list names doc comments, tags and const declarations; the evolution
table gains rows for the unbounded array and for annotations; the tiny-message
edge names the message form beside the file form's cost.

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

* docs: PORTING M6 and M18 against the id-table wire

M6 named the node-table reserved id as 0xFFFF and the node index as a u32;
both are M20's own facts and are now spelled from it. M18 said no per-arm
kind byte rides and called a bad arm length a kind mismatch; an arm header is
a field header, so a retyped arm is the kind mismatch and a bad length is
framing damage. The arm terminator control names the one-byte zero reference.

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

* docs: FAQ and SECURITY against the finished specification

FAQ: nothing on the table wire is fatal, and the widening event joins the
list; the independent-versioning answer is the table wire and its message
form rather than another library, with RPC named as what schema still
declines; maps have landed; the packet wire's refusal of unbounded
collections is stated; Elixir's write-side raise joins the idiom list;
wstring is named as the one type not yet emitted. SECURITY: the wire
fuzzer's mutator list names the id-table wire's own numbers.

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

* docs: COMPARISON-TABLES and COMPARISON against the finished specification

The read report is six counters, not five; the silent class is four, not
five; a kind that grew decodes exactly and counts widened; the wire has no
size ceiling now that every length is a canonical LEB128. Maps, sorted
lookup, arrays of pointers, arrays of unions and optional bounded arrays are
closed rather than pending; doc comments are specified rather than deferred;
the unbounded array joins the evolution table. COMPARISON names the table
wire as the answer for the independently-versioned case.

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

* docs: COMPETITION against the finished specification and the owner's ruling

The when-to-use-theirs line no longer declines the independently-versioned
service: that is what a table is for, the message form is what makes it
competitive by the byte, and the remaining gaps are cited as issues. Maps and
general union arms move off the not-built list; the widening rule, the doc
comment and retain-unknown are specified rather than declined or deferred;
the id-table wire's own facts replace the u32 lengths, the 16-bit hash and
the 4 GiB body, and the eight dormant port legs are named in the scoring
rule and in every footnote that turned on them.

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

* docs: the corpus and harness READMEs against the finished specification

bench/tables: the framing split the performance ladder cites from this page.
test/conformance: the id-table wire is the reference's and the tool's, the
eight ports write its previous form, and their negative controls print
dormant until they carry it. USAGE says the same where it introduces the
form byte.

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

* docs: COMPARISON-TABLES declaration and wire rows against the id-table form

Field identity is fnv1a64 with no fold, not a folded 32-bit hash; the
encoding model is the form byte, the reference-and-kind header, canonical
LEB128 and the trailing id table; a union arm carries its own kind byte; the
node index is a LEB128 under kind 17 and the reserved id is the full 64 bits.
Maps, sorted lookup, blob buffers and optional bounded arrays are landed
rather than pending; the unbounded array and wstring join the rows they
belong to; doc comments are specified rather than deferred.

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

* docs: the cold read's named changes

Status notes on every present-tense claim the tree lacks: the protocol id's
reachability closure (#524), the [E.Max]T refusal (#540), wstring and *wstring
(#522), TableRefuseReason and the widened counter (#523), the doc and tag
descriptor columns, and the union of table arms (#392). Maps and unbounded
arrays state their own backend status per section rather than in one trailing
sentence, and the list is owed rather than carried.

FAQ scopes the nine-language answer to the packet wire with the table wire's
state beside it, and gives the whole-wire malformed case and the form-byte
refusal one clause, since both decode nothing. The conformance README says
what the harness prints, N seeds absent, rather than what a make target
echoes. Three additions the sections owed: Insert answers NULL for an
oversized key, Add answers NULL at the arena, and a list's indices are not
stable across an erase while its pointer is, beside the three names it claims.

Em dashes are out of the new prose: the four USAGE sections, COMPETITION's new
paragraphs, the conformance and bench additions and VERSIONING's new rows.

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

* docs: COMPARISON-TABLES rows carry the same status the other pages do

The two unbounded-array rows and the maps row said what the construct is
without saying whether anything emits it, where every other page now does.

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

---------

Co-authored-by: Rowan <rowan@mas-bandwidth.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
…s that are not failures (#542)

* bench: the tables corpus pins its enum by slot, and re-pins to the id-table wire

The next red behind the map fit control, and the producer's own refusal named
it: "record 2 is 2147 bytes, record 0 is 2139 — the table wire elides a field
at its default, so a varied value landed on one. Fix the vary mapping, not this
check."

The diagnosis is one step past what that message says. Nothing landed on a
default. A record's TRAILER carries one eight-byte entry per distinct id the
record uses, and #507 made an enum ride as its VARIANT's id, so the eight
entity slots drawing a weapon at random gave each record its own set of
distinct variants and its own trailer length. Record lengths came out in
eight-byte steps: 2123, 2131, 2139, 2147, one step per entry the draw added or
dropped.

The weapon is structure under this wire, so the slot's own index picks it: the
eight slots take Fists through Grenade in every record, the set of ids is fixed,
and the value still differs from slot to slot. It is the rule the pinned union
arm already followed. A flags needs no pin, because TableDamage rides as raw
bits and names no id.

The goldens re-pin with it. They were the previous form and 2391 bytes, which
is a second thing #507 left behind: the producer refused on the vary mapping
before it ever reached the byte compare.

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

* elixir: the bench gate is dormant with the audit and the soak (#515)

Re-pinning the tables bench corpus to the id-table wire exposed it. The gate
was green against a corpus still in the previous form, which is the form this
port writes, so it was holding the codec to bytes it happened to agree with
rather than to the wire the reference now writes. With the corpus current it
refuses in its own words: "refusing to bench a codec that does not reproduce
the corpus."

That is the same rule as tables-elixir-alloc-audit and tables-elixir-soak, so
it takes the same dormancy and the same issue.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Reproduction:

    $ printf 'package bad\n\ntable T { const(0xC7, 8)\n x int32 }\n' > Bad.schema && bin/schema check .
    Bad.schema:3:11: const(value, bits) is a packet-wire construct — a table's wire is field-tagged TLV with no bit positions; remove it from table T (docs/SPEC-TABLES.md)
    $ printf 'package bad\n\ntable T { x int32\n align\n y int32 }\n' > Bad.schema && bin/schema check .
    Bad.schema:4:2: align is a packet-wire construct — a table's wire is field-tagged TLV with no bit positions; remove it from table T (docs/SPEC-TABLES.md)

The refusals are right and their reason survives the change, but
"field-tagged TLV" is the framing #507 replaced. A field is now
`id reference, kind, payload` against a trailing id table, and the tag is a
position into that table rather than the id itself. These were the only
place in the tool still naming the old shape.

The message now names the framing SPEC-TABLES §3 states, and the cite
carries the section:

    align is a packet-wire construct — a table's wire is `id reference, kind, payload` against the id table, with no bit positions; remove it from table T (docs/SPEC-TABLES.md §3)

`reserved(bits)` carried the same sentence and moves with the pair the issue
names.

Negative control: restoring "field-tagged TLV" turns
TestTableRefusalsNameTheIdTableWire red on all three.

No generated output moves and no golden is re-pinned.
gafferongames added a commit that referenced this pull request Sep 4, 2026
* docs: the payload-free arm is a packet-wire form, not a table-closure one (#521 G-07)

Reproduction, from a checkout of the tool:

    $ cat U.schema
    package pfree
    type LaserFire { target_id uint16 }
    union WeaponFire { laser LaserFire
                       ram }
    type FireCommand { fire WeaponFire }
    $ bin/schema check .            # silent, exit 0
    $ bin/schema generate --lang go  --out gg . && echo ok   # ok
    $ bin/schema generate --lang cpp --out gc .
    schema: unit declares a union with a payload-free arm (WeaponFire) — ...

SPEC §4.8 said such a unit is refused by `check`, by name, in every target.
The tool does not refuse it, and the tool is right: an arm with no payload
has nothing for a port to guess, it rides the packet wire as its tag alone,
and seven backends carry it. C and C++ refuse it at generate time through
compiler/packetvoidarms.go's carrier registry, naming the union, the
carriers and the follow-on, because their tagged-union storage has no member
for an arm that has none. The stale sentence grouped it with the arms that
ARE a front-end refusal: a `table` arm, a pointer arm, a scalar arm.

The page moves, in all three places that carried the grouping: SPEC §4.8,
SPEC-TABLES §11's refusal list, and USAGE's union section, which also now
says which targets carry the arm and that `check` is target-neutral so the
refusal arrives one command later.

The test pins both halves: the tool's behavior across all nine targets, and
the pages' own sentences. Reverting the three page edits turns
TestPagesPlaceTheVoidArmOutsideTheTableClosureClass red on four lines.

No generated output moves and no golden is re-pinned.

* check: one reserved enum variant draws one diagnostic (#521 G-02, #447 F-01)

Reproduction:

    $ printf 'package bad\n\nenum ShipType { None, Fighter }\n' > Bad.schema
    $ bin/schema check .
    Bad.schema:3:17: variant None is a compile error — every enum has None = 0 implicitly (SPEC §4.2)
    Bad.schema:3:17: enum ShipType's generated variant constant collides with enum ShipType's generated None constant — both generate the symbol ShipTypeNone; rename at the source (SPEC §4.6)
    Bad.schema:3:17: variant None collides with the implicit None variant inside enum ShipType (both become the associated constant NONE in Rust) — rename at the source (SPEC §4.6)
    Bad.schema:3:17: enum ShipType's generated variant constants (C form) collides with enum ShipType's generated variant constants (C form) — both generate the symbol SHIP_TYPE_NONE; rename at the source (SPEC §4.6)
    schema: 4 error(s)

Four errors for one mistake, and it is a beginner's first enum mistake. The
last three restate the first per target: the Go symbol, the Rust associated
const, the C #define. checkClaimedNames now registers no generated symbol
for a variant that IS one of the three names an enum generates for itself,
so the reserved-word rule stands alone. The predicate is
reservedEnumVariant, read by both sites, so the two lists cannot drift.

    $ bin/schema check .
    Bad.schema:3:17: variant None is a compile error — every enum has None = 0 implicitly (SPEC §4.2)
    schema: 1 error(s)

The collision checks stay live for a name that merely COLLIDES, such as `none`,
`NONE` and `max`, where the per-target wording is the whole explanation.

The C-form label also stops naming the same side twice. One shared `whyC`
covered the sentinels and every variant, so the C line read "enum E's
generated variant constants (C form) collides with enum E's generated
variant constants (C form)" and could not tell you which two declarations
collided. Each registration now carries its own label.

Negative control: dropping either reservedEnumVariant guard turns
TestReservedEnumVariantDrawsOneDiagnostic red at four errors. Restoring the
shared whyC turns TestCollidingEnumVariantKeepsItsPerTargetDiagnostics red.

No generated output moves and no golden is re-pinned.

* parser: a line-separated enum body names the rule and recovers (#521 G-03, #447 F-02)

Reproduction:

    $ printf 'package bad\n\nenum Big\n{\n    A\n    B\n}\n' > Bad.schema
    $ bin/schema check .
    Bad.schema:5:6: expected }, found "newline"
    Bad.schema:6:5: unexpected "B" at file scope (declarations begin with package, const, enum, flags, type, table or union)
    schema: 2 error(s)

An `enum` or `flags` body is comma-separated variant names. A `type` or
`table` body is line-separated fields. Writing the first the way the second
is written is the most likely early mistake in the language, and neither
line named it. The second was worse than a silence: the newline ended the
variant list, the closing `}` was expected on it, the declaration closed
there, and the next variant reached the top-level loop, so the reader was
told their mistake was at FILE SCOPE and handed the declaration keywords,
two lines below where they actually typed it.

parseVariantList now reads a newline followed by another name as the
separator it was meant to be: it says the rule ONCE per body, then reads the
rest of the body, so the declaration closes where the author closed it.

    $ bin/schema check .
    Bad.schema:5:6: enum variants are COMMA-separated, so A needs a comma after it — a `type` or `table` body separates its FIELDS by newline, and the two body grammars differ (SPEC §4.2)
    schema: 1 error(s)

One fix covers both bodies: `flags` reads the same list.

Negative control: reverting the missing-comma arm turns
TestLineSeparatedVariantsNameTheRuleAndRecover red at two diagnostics
neither of which names comma separation, and
TestLineSeparatedVariantsStillYieldTheirNames red at one declaration.

No generated output moves and no golden is re-pinned.

* check: `?` on a union no longer draws a second, false error (#521 G-09)

Reproduction:

    $ printf 'package bad\n\ntable A { x int32 }\ntable B { y int32 }\nunion U { a A\n b B }\ntable T { u ?U }\n' > Bad.schema
    $ bin/schema check .
    Bad.schema:7:14: field u: ?U marks a union optional, and a union is ALREADY optional — ... drop the ? (docs/SPEC-TABLES.md §2.3)
    Bad.schema:5:1: union U: the arm a A is not a declared type, and no table reaches U — ... hold the union in a table body, or make the arm a type (docs/SPEC-TABLES.md §2.6, §11, §15)
    schema: 2 error(s)

The second message is false on its own terms: `table T` reaches `U` in the
very line the first error is about, and the fix it prescribes is what the
schema already does. The `?` refusal dropped the field, and
checkTableArmsReached walked the surviving IR fields alone, so a
single-token mistake sent the reader off to rewrite a correct declaration.

resolveField now records the union a body's field NAMED at the point its
type resolves, before any later rule can refuse the field, and the
reachability walk reads that beside the surviving fields. Arms are excluded
(c.arm), so a union arm inside another union does not fabricate reach.

    $ bin/schema check .
    Bad.schema:7:14: field u: ?U marks a union optional, and a union is ALREADY optional — ... drop the ? (docs/SPEC-TABLES.md §2.3)
    schema: 1 error(s)

Negative control: dropping the unionNamedBy record turns
TestOptionalUnionDrawsOneDiagnostic red at two diagnostics.
TestUnreachedTableArmUnionIsStillRefused holds the refusal live where it is
true, over a union no closure names and one a `type` body holds.

No generated output moves and no golden is re-pinned.

* cli: cook --verbose names each of its two reads (#521 G-12)

Reproduction:

    $ bin/schema cook --root PackConfig --in tables/pack/pinned/PackConfig \
        --out tree.cook --verbose tables/examples
    report: silent — the data matched the schema exactly
    report: silent — the data matched the schema exactly
    cooked tree.cook: 824 bytes, build version 0x913551e66dc67157, ...

One command, one input, two identical report lines. `cook --in <dir>` packs
the tree and then cooks the wire, so it genuinely reads twice. But two
identical unlabelled lines read as the cook having read one input twice, and
nothing in the output said which read produced which.

reportLineStage names the read. cookInput answers whether it packed, so the
label appears only where there are two reads to tell apart:

    report (pack): silent — the data matched the schema exactly
    report (cook): silent — the data matched the schema exactly

`--in` a wire file reads once and keeps the unlabelled "report:" line.
Without --verbose a silent report still prints nothing. The cooked bytes are
identical before and after.

Negative control: restoring the unlabelled reportLine at both sites turns
TestCookVerboseNamesEachRead red on the indistinguishable pair.

No generated output moves and no golden is re-pinned.

* compiler: a misplaced flag is refused by name, not as a stat error (#521 G-01)

Reproduction:

    $ bin/schema generate --lang cpp --out gen . --verbose
    schema: stat --verbose: no such file or directory

Flag parsing stops at the first positional argument, so everything after it
is another input path and a misplaced flag became a filename. The failure
then surfaced as a syscall error on a string beginning with two dashes.
The reader's mistake is legible to the tool, because nothing that begins with a
dash is a schema path, and every other refusal in the tool names the rule.

    $ bin/schema generate --lang cpp --out gen . --verbose
    schema: --verbose looks like a flag, and flags come BEFORE the first path — everything after the first path is another input path; move it ahead of the paths, or spell a file that really begins with a dash as ./--verbose (SPEC §7)

The guard is in GatherPaths, so every command that takes paths gets it.

Negative control: reverting the guard turns
TestGatherPathsRefusesAMisplacedFlagByName red, back on the stat error.

No generated output moves and no golden is re-pinned.

* check: the table-body refusals name the id-table wire (#521 G-17)

Reproduction:

    $ printf 'package bad\n\ntable T { const(0xC7, 8)\n x int32 }\n' > Bad.schema && bin/schema check .
    Bad.schema:3:11: const(value, bits) is a packet-wire construct — a table's wire is field-tagged TLV with no bit positions; remove it from table T (docs/SPEC-TABLES.md)
    $ printf 'package bad\n\ntable T { x int32\n align\n y int32 }\n' > Bad.schema && bin/schema check .
    Bad.schema:4:2: align is a packet-wire construct — a table's wire is field-tagged TLV with no bit positions; remove it from table T (docs/SPEC-TABLES.md)

The refusals are right and their reason survives the change, but
"field-tagged TLV" is the framing #507 replaced. A field is now
`id reference, kind, payload` against a trailing id table, and the tag is a
position into that table rather than the id itself. These were the only
place in the tool still naming the old shape.

The message now names the framing SPEC-TABLES §3 states, and the cite
carries the section:

    align is a packet-wire construct — a table's wire is `id reference, kind, payload` against the id table, with no bit positions; remove it from table T (docs/SPEC-TABLES.md §3)

`reserved(bits)` carried the same sentence and moves with the pair the issue
names.

Negative control: restoring "field-tagged TLV" turns
TestTableRefusalsNameTheIdTableWire red on all three.

No generated output moves and no golden is re-pinned.

* baseline: the history date says it is UTC (#521 G-18, #447 F-16)

Reproduction, from a machine east of Greenwich:

    $ date && date -u
    Sat Sep  5 01:05:03 AEST 2026
    Fri Sep  4 15:05:03 UTC 2026
    $ bin/schema tables-baseline --update --reason "first baseline before 1.0 ships" .
    $ sed -n '/## history/,$p' tables.baseline
    ## history
    ### 2026-09-04 — first baseline before 1.0 ships

The stamp is UTC, which is the right clock for an artifact read on other
machines in other zones. It did not say so, so an author writes a baseline
in the evening and reads yesterday's date on the file they just wrote, then
doubts their tooling.

The entry now reads `### 2026-09-04 (UTC) — <reason>`.

SPEC-TABLES §18.4 stated no timezone at all, so the page moves with the
tool and now says which clock and why. §18.1's and USAGE's example history
blocks carry the label.

Entries already committed keep the spelling they have: the history is prose,
salvaged verbatim by every rewrite, and nothing reads a date back. The four
committed tables.baseline files are unchanged.

Negative control: reverting the stamp in Update turns
TestBaselineHistoryStampsUTCAndLabelsIt red on the bare date.

No generated output moves and no golden is re-pinned.

* check: a constant's value diagnostics point at the value (#447 F-05)

Reproduction:

    $ printf 'package bad\n\nconst C int32 = 1.5\n' > Bad.schema && bin/schema check .
    Bad.schema:3:1: constant C has integer type int32 but a float expression

Column 1 is the declaration. The refusal is about the expression, and a
range refusal on the same kind of line points at the offending value:

    Bad.schema:5:5: field a: its range [10, 20] excludes zero, ...

The three constant refusals that say something about the EXPRESSION rather
than the name now carry the expression's own position: the float expression
under an integer type, the float32 overflow, and the non-finite float. A
constant with no expression at all keeps the declaration's position, because
there is nothing else to point at.

    Bad.schema:3:17: constant C has integer type int32 but a float expression

Negative control: reverting valuePos turns
TestConstValueDiagnosticsPointAtTheExpression red at column 1.

No generated output moves and no golden is re-pinned.

* docs: USAGE states the two cpp_native facts a first trial meets (#521 G-06, #447 F-21)

Reproduction:

    $ cat A.schema
    package nat
    type Vec2 | cpp_native = GameVec2, cpp_include = "vec2.h" { x float32
                                                                y float32 }
    type Body { p Vec2 }
    $ bin/schema generate --lang cpp --out g . && grep -n 'GameVec2\|vec2.h' g/*.h
    $

No mapping, no include, no diagnostic. Split the two declarations across two
files in the same unit and the feature works as documented.

The behavior is deliberate and right: your native header derives from the
generated basis struct, so it includes the header the basis is in, and a
mapped reference inside that same generated header would be circular. SPEC
§4.2 states it: "Inside the basis type's own generated header the mapping
is off ... Sibling types declared in the same schema file therefore store
the basis type", and USAGE's per-language note did not, so a reader who
starts there meets a single-file unit that can never show the feature work.

USAGE's C++ note now carries both facts SPEC states and the attribute's own
spelling hides: the mapping is off inside the declaring file's generated
header, with the worked repro and its two-file fix, and `cpp_native` names a
GLOBAL type, so a namespaced engine type is reached through an alias.

The tool is unchanged: the attribute is a mapping, not a request, and a file
whose own siblings keep the basis type is correct.

No generated output moves and no golden is re-pinned.

* tables: pin the forged pointer cycle, and pin what it is (#535, #521 G-19)

Reproduction, an exhaustive single-bit sweep over a `Scene` whose head is a
three-node `ListNode` chain: one mutant at byte 26 turns the first node's
`next` index from 3 into 2, which is that node's own index. The wire loads,
the report is all zero, and the loaded node points at itself.

#535 asks the reference and the Go oracle to refuse or count that. Reading
the page through, they should not, and they already do exactly what it
specifies, in four separate clauses:

SPEC-TABLES §3.1: "A pointer field's payload is a NUMBER: it is
bounds-checked and stored, never followed. There is no traversal on the load
path, and therefore no traversal bound — no depth cap, no visited set, no
ordering rule on the indices." The index names a node that exists and whose
type matches, so no §4 event happened and no counter may move. §3.1 again:
"Load itself is safe on any input: it scans, it terminates, it fabricates
nothing. What a cyclic structure costs is paid by whatever WALKS it, and a
consumer walking untrusted table data — a reflection dump, a text export
(§16) — carries its own visit bound." Both walking consumers do, measured:
the text export and a re-encode each refuse this instance by name with the
§3.1 cite, and each returns rather than recursing away.

§6.5's `data_cycle` is not the missing check either: its own row says it is
"a data cycle reached from a builder, which is the AUTHORING side's `-1` and
the one value here that is not about a wire", and §6.5's status line says
TableRefuseReason is spelled in no target yet (owed as schema#523).

The reader-side check #535 asks for is §14 note 9, "A reader-side
acyclicity check — PRICED, NOT TAKEN ... for a property the writer already
guarantees (§3.1). It is the shape the check would take if untrusted-input
hardening ever demands one." Taking it is that ruling, not a fix wave item,
so this lands the gate instead and leaves the ruling on the issue.

What lands:

- testdata/wire/tables/fuzz-vectors/pointer_self_cycle.bin, the mutant,
  rooted at Scene because that is the root the C++ leg carries a codec for.
  Rooted at ListNode it would have gone into the harness's "absent" bucket
  and never reached the reference. Verified: the run reports 91 seeds and
  93653 mutants with it, 90 and 93652 without.
- The wire fuzzer therefore seeks it rather than searching, and the leg and
  the oracle are held to one answer on it: 0 divergences under both the
  plain and the ASan legs.
- internal/tablewire's TestForgedPointerCycleLoadsSilentlyAndRefusesEveryWalk
  pins all four clauses in Go.

Negative controls, all three red:

- reverting the text writer's cycle guard: "the text export walked a cyclic
  graph and produced 134 bytes".
- reverting the wire encoder's: "a save reproduced a data cycle as 83 bytes".
- adding a counter on the load path: "a counter moved for a pointer index
  that names a node of the right type".

No generated output moves and no golden is re-pinned.

* ctable: the C block accessors are one name with two verbs (#521 G-14)

Reproduction:

    $ bin/schema generate --lang c --out gc tables/block
    $ grep -n "TableBlockRows Render\|_span(" gc/RenderBlock.h
    static SCHEMA_UNUSED TableBlockRows RenderFrameShips( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderShip * RenderFrameships_span( const RenderFrameBlock * block )
    ...
    static SCHEMA_UNUSED TableBlockRows RenderFrameDynamicProps( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderDynamicProp * RenderFramedynamic_props_span( const RenderFrameBlock * block )

Two accessors for one field, one exported name PascalCased and the other
gluing the same PascalCase table prefix onto the field's own lowercase
spelling. `RenderFramedynamic_props_span` is what that produces for a
multi-word field. Neither is the snake_case the rest of this backend spells
its name-first surface in, namely `render_frame_block_open`,
`enum_name_ship_type` and `ship_config_load`, and the two could not both
be right.

Both now take the C target's own spelling through `api`, as one name with
two verbs:

    static SCHEMA_UNUSED TableBlockRows render_frame_ships_rows( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderShip * render_frame_ships_span( const RenderFrameBlock * block )
    static SCHEMA_UNUSED TableBlockRows render_frame_dynamic_props_rows( const RenderFrameBlock * block )
    static SCHEMA_UNUSED RenderDynamicProp * render_frame_dynamic_props_span( const RenderFrameBlock * block )

The accessors are `static SCHEMA_UNUSED` and nothing in the tree called
them, so no C source moves. docs/TUTORIAL.md's part 12 C consumer did print
the old pair, two lines under its own `render_frame_block_open`, which is
where the asymmetry was most visible to a reader. It prints the new pair.

The C++ names are untouched and were never mixed: that backend spells the
pair `RenderFrameShips` / `RenderFrameShipsSpan`, PascalCase throughout,
which is its own convention. The emitter is internal/codegen/ctable, outside
bench/LOCK's prefixes (verified against the cpp-lock job's own matcher). No
locked emitter and no locked generated tree is touched.

`make test-c` passes: the conformance surfaces, the forgery fuzzers and the
wire byte counts are all unchanged.

Negative control: restoring either old spelling turns
TestCBlockAccessorsAreSnakeCasePairs red, on the missing pair and on the
mixed name still being present.

No wire golden is re-pinned. No source-copy golden moved either, because the
C table tree is generated into build/ and not committed.
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