Skip to content

tables: the fixed form — SPEC §3.4, THE FIRST BYTE, the variable-form rename, and selection by the keyword - #829

Merged
gafferongames merged 6 commits into
mainfrom
fixed-form-spec-pass
Sep 10, 2026
Merged

gafferongames merged 6 commits into
mainfrom
fixed-form-spec-pass

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Draft. Two passes over the fixed form, on one branch.

Pass one (a69f0ea6..07bcdb6a) is SPEC §3.4: the rename Glenn's read
asked for, the version byte his ruling removed, the closed kind set that ruling
implies, the layout validation an untrusted peer makes necessary, and the size
bounds his own rule about fixed tables deserves.

Pass two (073c762a) is Glenn's decisions of 2026-09-09, all locked, put
into the page and the code: form byte 1 is the variable form, THE FIRST
BYTE is one rule at the head of §3, selection is by the fixed table keyword,
a form byte's refusal says which DIRECTION, and the unknown-form negative
control moved off 3. It is the pass that closes the one deviation and the two
follow-ons pass one reported.


Pass two: THE FIRST BYTE

A. Form byte 1 is THE VARIABLE FORM

"so let's rename byte 1" … "don't call it fixed form" … "call it variable
form"
— Glenn

"The file form" named the CARRIER and not the class, which left the one word
every other page uses for form 1's class — variable — unspoken, and made
"a fixed table on the file form" a sentence with two forms in it. The registry,
every heading and every line of prose now say the variable form: 36 places
in docs/SPEC-TABLES.md, 102 elsewhere.

Identifier renames NOT done, and why. message_form_as_file keeps file:
it names a file EXPECTATION — a batch handed to a reader that wanted a file
— and not form 1, so renaming it would make it wrong. The C bitfield
retention_message_write_requires_file_form keeps its spelling: it is a
diagnostic identifier every C golden in the tree pins, and the sentence beside
it now says VARIABLE form. Nothing else in the emitters spells form 1 with
File — form 1's constant is kTableWireForm, and fileWire, fileEnums,
SourceFile and the rest all mean a source file.

B. THE FIRST BYTE — one rule, at the head of §3

Stated once, cited everywhere. A schema file is a UNION OF FIVE ARMS and the
form byte is its tag
"so in effect, form byte is like a union id" — under
exactly the append-only rule the baseline lock already enforces for a union in a
fixed table: numbered forever, appended at the end, never reinserted, never
renamed. Byte 0 and every value the registry does not carry are refused by
name
.

A form's header:

offset  0        the FORM BYTE
offsets 1 .. 7   RESERVED, written zero
offsets 8 .. 15  the form's EIGHT-BYTE HASH, little-endian
offset  16       the BODY

The hash is over the LAYOUT for the fixed and message forms, over the BUILD for
the cook and the block form. Sixteen bytes is the alignment a memory-mapped body
needs, and the fixed form does not need it and pads anyway — Rowan's ruling
— so the bytes do not move a second time when the cook and the block form join
the registry.

The variable form is the one arm whose header is the form byte alone. It
carries no hash (its id table is the wire's own trailer) and maps nothing, so
§3's "nothing is aligned and nothing is padded" is its whole rule and its body
begins at byte 1. Padding it would move every byte of every file on every disk
to buy an alignment its reader does not use.

The one exception beside the rule: inside a packet no form byte is written.
The declaration names the field a fixed table, and "if we KNOW they're all in
message form, we can just drop the byte for type when we put them in packets"

so a fixed table in a packet is the message form by rule. Stated as a
standing rule and marked as one: a table has no packet wire today, so nothing
writes such bytes yet.

THE REGISTRY, six rows

form what it is where
0 never assigned; refused by name §3
1 the VARIABLE form — form byte, root body, id table §3
2 the MESSAGE form — planned to become fixed-only, rebuilt on 3 §3.3
3 the FIXED form §3.4
4 the COOK — planned §7
5 the BLOCK form — planned §19

It lives in code too, in ir/formbyte.go, so the compiler, the CLI and the wire
fuzzer read one table rather than three copies of it.

The fixed form's file header moves — and it is the only wire that does

offset  0        form byte 3
offsets 1 .. 7   reserved, zero
offsets 8 .. 15  the LAYOUT HASH (u64 LE)
offset  16       layout length (u32 LE), then the layout, then the records

It was form byte, u32 layout length, layout, records — a five-byte header.
The records are unchanged, hash and body, and every record still carries its
own eight-byte hash: the header's says which layout is in this file, a
record's says which layout stamped this record. A header whose hash is not the
hash of the layout behind it is refused layout_malformed, and that check runs
last of the three so a broken layout is never reported as a lying header.
The seven reserved bytes are written zero and read by nobody — a later form that
wants to say more takes a new form byte, which is the same rule that keeps a
version byte out of the layout.

C. Selection is by the keyword — the deviation closed

"if we add any feature that stops it from being fixed, it is a compile
error… we don't want to surprise the user"
— Glenn

"now that we have the fixed form (new) of 3, we select this, and do not want
fixed tables to ever encode as the old way."
— Glenn

A fixed table (#823's keyword) encodes as form 3, always; a table
encodes as form 1; there is no path between them. So:

what happens
4096 a WARNING, always on, naming the table and the size
65536, DECLARED fixed a COMPILE REFUSAL by name — a declared fixed table never silently falls back to form 1
65536, merely DERIVED into the fixed mode the form is dropped and the compiler warns; the table asked for nothing and keeps form 1
--fixed-record-limit N a project's policy, off by default, and it only ever LOWERS

Pass one deviated here and this closes it. The reason it deviated was real:
§12.1's RenderFrame at 7.5 MB and §2.8's WideBlob at 280 KB are legitimate
fixed-MODE tables, and a hard refusal over derived-fixed tables would refuse
this repo's own corpus. Glenn's decision resolves it by separating the two
words: the MODE is derived (§2.2) and gives those tables their by-value
storage, cook and block form; the FORM is selected by the keyword, and the
bounds are the FORM's. So the refusal is armed on the marker the keyword sets
and the derived branch is what #823 deletes.

#823 is not merged, so it is implemented against what marks a table fixed in
this tree today
— the derived mode — with ONE pointer to the keyword, on
ir.Struct.FixedDeclared. Two Go tests set that marker and watch the refusal
fire and the small case stay silent.

--fixed-record-limit cannot RAISE the 65536, and that is the one place I
did not follow the item's wording literally. 65536 is the number a peer's
reader holds this build's records to (layout_record_too_large), and a peer's
build has never heard of this project's flag — the read side always checks, so
the write side must not exceed it. Said in the flag's help, in the code and in
§3.4.

D. A form byte's refusal says which DIRECTION

The registry is ORDERED, so one word for both directions was one word too few.
A new previous_form sits beside newer_form:

a fixed reader given answers
1, the variable form previous_form — it is OLDER, and newer_form sent a caller looking for a build that does not exist
2, the message form message_form_as_file
any byte no form defines newer_form
— and a variable reader given 3 newer_form, which it already did

The unknown-form negative control moved from 3 to 6. 3 is the fixed
form and 4 and 5 are reserved, so 6 is the first byte that is neither
defined nor reserved. test/tables/main.cpp's test_form_byte_refusals, and
form_three became form_six in reports.txt, MANIFEST.txt, FORMAT.md and
testdata/wire/tables/. The wire fuzzer derives its byte from the registry
(ir.WireFormNone), so the next form added moves it without anyone remembering
to.

E. schema check says what byte 0 says, first

Handed a saved file, schema check prints one line before anything else it says
about that file:

$ schema check saved.bin
saved.bin: FORM 3 the fixed form (docs/SPEC-TABLES.md §3.4)
$ schema check zeros.bin
zeros.bin: REFUSED: form 0 is never assigned (docs/SPEC-TABLES.md §3)

Held by test over every row of the registry, over a byte no form defines, over
an empty file, and over the ordering when a saved file and a unit are both named.

Pass two's gates

gate result
go build / go vet clean
golangci-lint run ./... 0 issues
go test ./... green but for the three pre-existing dialect-scan failures below
make tables-fixedform green on the new header
make conformance green over cpp, c, cs, go, rust — report 59/59 including the renamed row
goldens + generated/ re-pinned, and byte-stable across two consecutive regenerations
corpus bytes only byte 0 of the unknown-form control moved (36); no other testdata/wire file changed

The three pre-existing failures, reported by pass one and reproduced here at
07bcdb6a with the working tree stashed, untouched:
TestTableHeadersAreTheCDialect, TestTableDialectCheckBites,
TestZeroCostForValueOnlyTables — the fixed form's emitted C++ carries
inline constexpr and template, which §13.9's C-like-dialect scan forbids.

Four legs skipped in make conformance: dart, elixir, java, js.
Each has a pinned toolchain under dist/ that this fresh clone has not
fetched, so they cannot build here; nothing about them is red. cs, go,
rust, c and cpp all ran.

What Glenn's decisions left ambiguous, and what I decided

  1. Does form 1's header pad too? The item says "one header rule for all
    five", and also that the hash is "over the layout for the fixed and message
    forms; over the build for cook and block" — form 1 is not in that list, it
    has no hash, and it is the one form the item says must not move. Decided:
    the variable form's header is the form byte alone, and that is stated as the
    one arm the rule does not reshape.
    Padding it would move every byte of
    every file on disk for an alignment nothing reads.
  2. Does form 2's header move now? No. The registry says form 2 is planned
    to be rebuilt on form 3; its framing today is its own form byte and count.
    §3 writes the header down in full anyway, because a rule three forms adopt
    one at a time has to be decided once.
  3. Are the seven reserved bytes CHECKED on read? Decided: written zero,
    read by nobody.
    A later form that wants them takes a new form byte, which
    is the rule that already keeps a version byte out of the layout, so checking
    them buys nothing a form byte does not already buy.
  4. --fixed-record-limit "raises" the 65536. Decided: it cannot — see C
    above. This is the one place the implementation departs from the wording, and
    the reason is the write-side/read-side asymmetry.
  5. The header pads; do the records end up aligned? No — the body begins at
    16 and the body begins with a variable-length layout, so the records land
    wherever that length puts them. Nothing maps them today. Named as a follow-on
    in §15 so that "the header pads" is not read as "the records are aligned".

Pass one: SPEC §3.4, the layout

Why

Glenn read §3.4 and asked:

"What is the block format? Isn't this for the render data in space game? Or
is this the cooked format?"

Three different structures in this document already answer to "block" — §7's
cooked block, §19's block form, §3.3's announced vocabulary — and §3.4 was
calling a fourth one that. It is THE LAYOUT now. Said once at first use,
"the layout (form 1 called this the vocabulary block)", and form 1's
vocabulary block, the cook and the blob are untouched.

And on the version byte the two previous children had started building:

"I go with new form byte."

So there is no version byte inside the layout, and there never will be. The
form byte is read before anything else and versions everything behind it, the
layout's own format included. A layout format change is a NEW FORM BYTE — never
a wider entry, never a byte in front of the count.

The consequence nobody had drawn from that ruling

§3.4 used to argue that an older reader steps over a kind it cannot name by the
size the entry states. It cannot, honestly. If the form byte versions the
layout's format, then it versions the KIND SET too — so a kind outside §3's set
does not mean a newer layout of form 3, it means a NEWER FORM BYTE, which is a
different form. Stepping over it would be trusting a size whose meaning this
reader cannot check. layout_kind_unknown says what happened instead. (Johnny
raised skip-vs-refuse; Rowan ruled refuse; this is the argument for it.)

The layout arrives from an untrusted peer

§3.4 now states in its own body what a reader holds a layout to, each rule a
refusal by ITS OWN NAME, every one before a single record byte is touched
: the
entry count fits the length exactly, every kind is in the closed set, every
constant size is one its kind admits and its children account for, every kind is
used as its definition allows, the pre-order walk consumes exactly the entries,
no size overflows or passes 65536, and nothing nests past the walk's own bound.

There is no cycle rule and there cannot be a cycle — an entry's children ARE
the entries that follow it, so a child's index always exceeds its parent's and
there is no back reference to make one of. The property is bought by the
representation, not by a check.

The C++ reference applies all of them, and test/tables/fixedform_main.cpp has
one corrupted-layout case per named rule, each breaking exactly one thing in
a layout the reader accepts.

The size bounds

"Effectively, fixed tables should only be used for small things." — Glenn.
Two numbers and a flag, and they are three different kinds of thing:

what it is what happens
4096 ADVISORY, always on the compiler names the table and the size; the table still carries the form
65536 the WIRE's ceiling a reader refuses a layout stating more (layout_record_too_large), so the fixed form is not emitted for such a table and it keeps form 1 — and the compiler says so rather than dropping it in silence
--fixed-record-limit N a PROJECT's policy, off by default a fixed table past N does not compile; a team that wants Glenn's rule enforced rather than advised sets it to 4096

A note on the middle row. It was a deviation from the item as written, and
pass two closes it — see section C above. The item asked for a compile
refusal past 65536; that would have stopped two of this repo's own corpora
compiling, because §12.1's render frame is a legitimate 7.5 MB fixed table
and §2.8's WideBlob a 280 KB one and neither has ever been a form-3 table.
Glenn's ruling separates the MODE from the FORM: the bound is the FORM's, a
DECLARED fixed table past it does not compile, and a table merely derived into
the fixed mode keeps form 1 and warns.

Also stated, each because a reader had to work it out

  • The framing in all three carriers, in one table. The form byte and the
    layout appear once per carrier; a record carries only the hash. A file carries
    both up front, a stream's layout rides the announcement, and the message form's
    form byte is once per batch.
  • The slack rule, on every row that has slack — ZERO on write, UNSPECIFIED
    on read, never a refusal; the reader validates the USED UNITS ONLY, and
    the content rules apply to those and to nothing else. The unit of every length
    is now on its own row: bytes for string(N) and bytes(N), UTF-16 code units
    for wstring(N).
  • A union's tag 0 is None, as an enum ordinal 0 is; the slack behind a
    narrower arm is zero on write and ignored on read. An absent optional's
    payload is likewise ignored on read and zero on write.
  • A DEPRECATED field keeps its slot forever (schema lang: fixed table declares the class, and the compiler refuses what breaks it #823, schema lock: fixed tables evolve append-only, and the compiler holds the line #825): the writer emits
    the declared default and the reader yields it. Keeping the slot is what makes
    the deprecation cost nothing.

Where the arithmetic lives

C(f) and C(T) move to ir/tablefixed.go, beside the kind vocabulary, because
a record's constant size is wire law and not a backend's. The reference emits
its layout and its template from the same functions the warning, the ceiling and
the reader's own bound are measured with, so the bytes and the verdict cannot
drift apart.

The bytes do not move

Every layout byte array and every FixedHash this branch emits was diffed
against the same units generated by the compiler at 3bf940e0:

FX1: 2 layouts, BYTE-FOR-BYTE IDENTICAL (195 bytes)
FX2: 3 layouts, BYTE-FOR-BYTE IDENTICAL (301 bytes)
P1:  2 layouts, BYTE-FOR-BYTE IDENTICAL (144 bytes)
V1:  2 layouts, BYTE-FOR-BYTE IDENTICAL (1198 bytes)
bench/corpus paired: hashes identical

Only the comments beside them changed. Removing the version byte is what put
them back where 508b84e2 had them.

What this pass found that the two reads did not

Two things §3.4 asserts that the tree does not do, named in §15 rather than
left as text nobody had checked. Pass two closed the first and half of the
second:

  1. §4.2's unknown-form negative control still plants 3. §3.4 already says
    it must move to 4, because the control's whole premise is a byte no form
    defines
    — and 3 is now the fixed form. It still passes (a form-1 reader
    does refuse byte 3), so nothing is red; what it no longer does is test
    what it says it tests
    . Moving it renames the shared conformance row
    form_three that every leg reads, so it is a change of its own and §3.4 now
    says it has NOT moved yet. CLOSED in pass two: it plants 6.
  2. A generated reader does not dispatch on the form byte. §3.4 says a reader
    for a fixed-table type "accepts BOTH, by the form byte". The reference emits
    two entry points instead, and the fixed one answers newer_form for byte
    1
    — which is OLDER. The page's rule is right; the reference is one
    dispatch short of it. The NAMING half is closed in pass two
    (previous_form); the dispatch itself is still a named follow-on.

Three gates that were red on the base commit, before this branch

Verified by regenerating and re-running at 3bf940e0 with the working tree
stashed. None is caused by this pass; two are re-pinned here because a
green branch needs them, the third is left alone.

gate state at 3bf940e0 here
TestGolden* + the generated/ tree the same 44 generated files and 45 golden headers regenerate differently — the fixed form landed in 508b84e2 and neither was re-pinned RE-PINNED, and regeneration verified byte-stable across two runs
TestTableHeadersAreTheCDialect, TestTableDialectCheckBites, TestZeroCostForValueOnlyTables RED — the fixed form's emitted C++ carries inline constexpr and template, which §13.9's C-like-dialect scan forbids still red, untouched: it is the fixed form's emission dialect and belongs with the branch that introduced it
make bench-paired-check RED — paired corpus mismatch: bench/paired/corpus/bench_table.bin still red, untouched, same reason

One further red was purely environmental and is not a repo problem: this clone's
sibling ../serialize was at v1.8.0 and ../serialize.rs was absent.


🤖 Generated with Claude Code

rowan-claude and others added 5 commits September 9, 2026 14:11
The new fixed-table wire form, decided by the project owner: a fixed-table
record is an eight-byte hash of the writer's vocabulary block and then the
values in declared order, every field at its bound. No per-field references,
no kind bytes, no lengths, no terminators, no trailer.

THE VERSIONING INVARIANT IS STATED FIRST, because "we must not ever break
versioning in fixed tables" is the constraint every rule is shaped to meet:
the form is positional BY PLAN and the positions are the block's, the block
is always obtainable, the block's own format never moves, and every refusal
is by name.

ONE READER PATH. A plan is a flat array of (source offset, size, destination,
op) entries. For a record whose hash equals the reader's own the plan is the
identity plan, a static constant baked into the generated reader with adjacent
runs coalesced; for any other hash the SAME LOOP runs over a plan compiled
once from the writer's block and cached by hash. No strict flag, no fast/slow
cliff, one row on the board — which is the owner's own answer to the third of
his three questions, "is it good to have read/write being fast or slow
depending on the versioning?"

The writer is the type's constant bytes memcpy'd and then value stores at
constant offsets; MeasureBody is a constexpr.

Form byte 3, on §3.3's own precedent for adding one: forms 1 and 2 are both
live, so nothing is reinterpreted. §3 gains the registry pointer and §4.2's
unknown-form negative control moves from 3 to 4. Three refusal reasons join
the table: no_block, block_malformed, plan_too_large.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The C++ reference for docs/SPEC-TABLES.md §3.4, form byte 3, in two new
files and three lines of the old one.

THE BLOCK is a compile-time constant of the type: a u32 entry count and a
run of seventeen-byte entries — id, kind, constant size, child count — as a
PRE-ORDER walk of the closure in the writer's declared order, with the
fnv1a64 of those bytes as the type's hash. Beside it rides MY side of the
same walk, one row an entry, carrying the storage facts a block entry
cannot: the destination offset, an array's storage stride, a text field's
buffer.

THE WRITER is the template — the hash and zeros, which is also what
zero-fills every byte of declared slack — memcpy'd, then value stores at
constant offsets. MeasureBody is a constexpr.

THE READER IS ONE LOOP OVER ONE PLAN. The identity plan is built AT COMPILE
TIME: the generated constexpr leaf walk emits one entry per run of bytes
that lands, and TableFixedBuildPlan coalesces the adjacent ones, so
BenchMixed's 300-odd leaves become 32 entries and the whole stats array is
one 640-byte move. For any other hash the same loop runs over a plan
compiled from the writer's block — ids matched, an unknown field left out
(which is what skips it), a missing field left out (which is what defaults
it, because the prefill already put the default there), a variant ordinal
remapped through the plan's own table, a union arm remapped and guarded on
the writer's tag.

THE RUN COPY IS WRITTEN AS OVERLAPPING UNALIGNED WORD MOVES AND NOT AS A
CALL, and that is a requirement of the form rather than an optimization: the
ruling that there is one reader path rests on a measurement, and with a
runtime-length memcpy per entry the same measurement is 3.1x instead of
1.3x. The comment in the runtime says so where a port will read it.

The block adds ONE kind to §3's closed set, 35, the OPTIONAL WRAPPER, whose
single child is the payload — a block kind and not a wire kind, because on
this form `?T` and `T` are one present byte apart where §2.3's form-1 rule
makes them wire-identical.

Three refusal reasons join TableMessageReason: no_block, block_malformed,
plan_too_large.

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

§3.4's invariant is that a fixed record is positional BY PLAN and the
positions are the WRITER's block. This is what that sentence is worth.

test/tables/fixedform_main.cpp, one binary, `make tables-fixedform`, in
`make test`:

  same schema        the identity plan, a clean read moving no counter
  an older writer    a field this reader has and the record does not: the
                     prefill's declared default stands, including a whole
                     nested type the writer never heard of
  a newer writer     a field this reader cannot name, stepped over by the
                     size its entry states, AND a whole nested TYPE stepped
                     over by its block size — which is what puts the field
                     after it in the right place
  a rename           arriving under `was =`, in both directions, silently
  a widening         uint16 into uint32, exactly, counting one widened; the
                     way back down is a kind that MOVED and is reported
  an enum, a union   a variant and an arm inserted IN THE MIDDLE, remapped by
                     NAME (V1/V2), and a keyed array whose keys slid
  an optional        P1's value against P3's `?T`

NEGATIVE CONTROLS, because a test that never watched the wrong plan fail
never checked the right one: FX1's identity plan run over an FX2 record must
NOT reproduce it, while the loader — which picks its plan by the hash — must;
a block that is not a block refuses `block_malformed`; a form byte this build
does not carry refuses `newer_form`; a plan that does not fit the caller's
storage refuses `plan_too_large`. Every refusal moves no counter and is never
damage.

FX1/FX2 are new and small, generated into build/tables-generated by the same
macro as every other pair. V1/V2 and P1/P3 are reused as they stand.

TWO EMITTER FIXES the corpus found. An ARRAY OF A FLAT TYPE IS NOW ONE LEAF —
a type whose storage image is its wire image needs no per-element walk — which
takes RenderFrame's plan from 1,226,645 leaves, past clang's constexpr step
limit, to a handful; and a keyed array's slots are spelled `.slots` only when
the owner is a table, which is the rule §2.4 already had.

SPEC: kind 35, the optional wrapper, is named as the ONE kind the block adds
to §3's closed set, with the §2.3 departure it carries stated rather than left
to be found; entry 0 carries the root TYPE's id and every other entry the
field, variant or arm it describes; and the reference's compile-time plan
bound is named as the reference's and not the wire's.

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

THE RENAME FIRST, because it is why this pass exists. Glenn read §3.4 and
asked "what is the block format? isn't this for the render data in space
game? or is this the cooked format?" — three different structures in this
document already answer to "block" (§7's cooked block, §19's block form,
§3.3's vocabulary), and §3.4 was calling a fourth one that. It is THE LAYOUT
now, throughout §3.4, in the C++ reference's identifiers and comments, and in
the refusal names: `no_block` is `no_layout` and `block_malformed` is
`layout_malformed`. Said once at first use — "the layout (form 1 called this
the vocabulary block)" — and form 1's vocabulary block, the cook and the blob
are untouched.

NO VERSION BYTE INSIDE THE LAYOUT. Glenn: "I go with new form byte." The form
byte is read before anything else and versions everything behind it, the
layout's own format included, so a layout format change is a NEW FORM BYTE and
never a wider entry or a byte in front of the count. The two previous children
had built one; it is gone, and the wire is back to the bytes 508b84e emits.

AND THE CONSEQUENCE NOBODY HAD DRAWN FROM IT: a fixed form's KIND SET IS
CLOSED. §3.4 used to argue that an older reader steps over a kind it cannot
name by the size the entry states. It cannot, honestly — a kind outside §3's
set means a newer FORM BYTE, which is a different form, and stepping over it
is trusting a size whose meaning this reader cannot check. `layout_kind_unknown`
says what happened instead.

THE LAYOUT ARRIVES FROM AN UNTRUSTED PEER, and §3.4 now states in its own body
what a reader holds it to, each rule a refusal BY ITS OWN NAME before a single
record byte is touched: the entry count fits the length exactly, every kind is
in the closed set, every constant size is one its kind admits and its children
account for, every kind is used as its definition allows, the pre-order walk
consumes exactly the entries, no size overflows or passes 65536, and nothing
nests past the walk's own bound. There is no cycle rule and there cannot be a
cycle: an entry's children ARE the entries that follow it, so a child's index
always exceeds its parent's and there is no back reference to make one of.
The C++ reference applies all of them, and test/tables/fixedform_main.cpp has
one corrupted-layout case per named rule.

THE SIZE BOUNDS, and they are two different kinds of thing:

  4096 is ADVISORY and always on — the compiler names the table and the size,
  because "effectively, fixed tables should only be used for small things" is
  a design rule and a rule nobody is told about is not a rule.

  65536 is the WIRE's CEILING. A reader holds a peer's layout to it, so a
  record past it is one no conforming reader decodes; the fixed form is not
  emitted for such a table and it keeps form 1, which it never lost. It is
  NOT a refusal of the unit: §12.1's render frame is a legitimate 7.5 MB fixed
  table and §2.8's wide text a 280 KB one, and refusing a unit over a form it
  does not use would refuse the wrong thing.

  --fixed-record-limit is a PROJECT's own gate, off by default: past it a
  fixed table does not compile. A team that wants the owner's rule enforced
  rather than advised sets it to 4096.

The constant-size arithmetic moves to ir/tablefixed.go beside the kind
vocabulary, because a record's constant size is wire law and not a backend's:
the reference emits its layout and its template from the same functions the
warning and the ceiling are measured with, so the bytes and the verdict cannot
drift apart.

ALSO STATED, each because a reader had to work it out: the framing in all
three carriers, in one table — the form byte and the layout appear once per
carrier and a record carries only the hash; the slack rule on every row that
has slack — zero on write, UNSPECIFIED on read, never a refusal, with the
content rules over the used units only; a union's tag 0 is None as an enum's
ordinal 0 is; an absent optional's payload is ignored on read and zero on
write; and a DEPRECATED field keeps its slot forever, the writer emitting and
the reader yielding the declared default (#823, #825).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ame two follow-ons

THE PINS WERE ALREADY STALE. The same forty-four generated files and forty-five
golden headers regenerate differently at 3bf940e as they do here — the fixed
form landed in 508b84e and neither `make update-goldens` nor the generated/
tree was re-pinned after it, so `make generated-current` and TestGolden* have
been red on this branch's base. This re-pins both, which is what those gates
are for, and the delta this pass itself adds is the layout rename and the seven
new refusal names.

AND TWO FOLLOW-ONS §3.4 IMPLIES AND NOBODY HAD WRITTEN DOWN:

  §4.2's UNKNOWN-FORM NEGATIVE CONTROL STILL PLANTS `3`. §3.4 already says it
  must move to `4` — the control's whole premise is a byte NO FORM DEFINES, and
  `3` is now the fixed form. It still passes, because a form-1 reader does
  refuse byte `3`; what it no longer does is test what it says it tests. Moving
  it renames the shared conformance row `form_three` that every leg reads, so
  §3.4 now says it HAS NOT MOVED YET rather than saying it has, and §15 carries
  the change.

  A GENERATED READER DOES NOT DISPATCH ON THE FORM BYTE. §3.4 says a reader for
  a fixed-table type "accepts BOTH, by the form byte". The reference emits two
  entry points instead, and the fixed one answers `newer_form` for byte `1` —
  which is OLDER. The page's rule is right and the reference is one dispatch
  short of it.

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

@gafferongames gafferongames left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emma Audit & Sign-off on §3.4 (fixed-form-spec-pass)

I have audited PR #829 against Emma's eight review findings from emma-63922dca1393 and the C# match-bytes implementation in PR #827:

  1. Little-endian, byte widths, no alignment padding: Cleanly stated. (§3.4: "EVERY FIELD RIDES AS ITS DECLARED STORAGE IMAGE, LITTLE-ENDIAN, AT ITS DECLARED STORAGE WIDTH, AND NOTHING IS PADDED BETWEEN FIELDS.")
  2. Layout format version byte removed: Exactly reflects Glenn's ruling. Stated in §3.4: "THERE IS NO VERSION BYTE INSIDE THE LAYOUT, AND THERE NEVER WILL BE. THE FORM BYTE VERSIONS THE LAYOUT'S FORMAT: a change to the layout's format is a NEW FORM BYTE, never a wider entry and never a byte in front of the count."
  3. Carrier framing unified: Comprehensive framing table provided for file, stream, and message batch. (File carries form byte + layout up front; records carry 8-byte hash + body; packet carrier omits form byte).
  4. Slack rule on strings/bytes: Zero on write, unspecified on read, never a refusal; reader validates used units only.
  5. Union None = tag 0, Optional payload, Kind 35:
    • Union tag 0 is strictly None.
    • Absent optional payload ignored on read, zero on write.
    • Kind 35 (TableWireKindOptionalWrapper) documented in the layout child walk table.
  6. Deprecated fields: Explicitly documented that deprecated fields retain their constant slot forever, writer emits declared default, and reader yields it.
  7. Size bounds: Cleanly tiered:
    • 4096: Advisory compiler warning.
    • 65536: Wire ceiling refusing the fixed form for that table (layout_record_too_large), preserving form 1.
    • --fixed-record-limit N: Project policy flag for strict project-level enforcement.
  8. Untrusted peer layout validation: Complete, enumerated validation rules executing before record bytes are touched, with named refusals.

Wire Hashes & Byte Invariance

Verified that layout byte sequences and hashes are 100% byte-for-byte identical with the C++ reference and C# implementation in PR #827 (FxNestedFixedHash, FxRootFixedHash, FX2 FxRootFixedHash, V1 CfgFixedHash, V2 CfgFixedHash, P1 ChainFixedHash, P3 ChainFixedHash).

Verdict: APPROVED & SIGNED OFF.

…, and selection by the keyword

FORM BYTE 1 IS "THE VARIABLE FORM". "The file form" named the CARRIER and not
the class, which left the one word every other page uses for form 1's class —
variable — unspoken, and made "a fixed table on the file form" a sentence with
two forms in it. The registry, every heading and every line of prose in the
spec and in the emitters now say the variable form; 36 places in
docs/SPEC-TABLES.md, 102 elsewhere. Identifiers keep File where they mean a
FILE: `message_form_as_file` names a file EXPECTATION and is right as it is,
and the C bitfield `retention_message_write_requires_file_form` is a
diagnostic name every C golden pins.

THE FIRST BYTE, ONE RULE AT THE HEAD OF §3, cited everywhere and stated once.
A schema file is a UNION OF FIVE ARMS and the form byte is its tag — "so in
effect, form byte is like a union id" — under the append-only rule the lock
already enforces for a union in a fixed table. Byte 0 and every value the
registry does not carry are refused by name. A form's header is the form byte,
seven reserved zero bytes, its eight-byte hash at 8, and the body at 16, so a
memory-mapped body keeps its alignment. The variable form is the one arm whose
header is the form byte alone: no hash, nothing mapped, and its body at byte 1.
Inside a packet no form byte is written at all — the declaration names the
field a fixed table and a fixed table in a packet is the message form by rule.

THE FIXED FORM'S FILE HEADER MOVES, and it is the only wire that does:

    offset  0        form byte 3
    offsets 1 .. 7   reserved, zero
    offsets 8 .. 15  the LAYOUT HASH (u64 LE)
    offset  16       layout length (u32 LE), then the layout, then the records

It was `form byte, u32 layout length, layout, records` — five bytes of header.
The records are unchanged, hash and body, and every record still carries its
own hash: the header's says which layout is in this file, a record's says which
layout stamped that record. A header whose hash is not the hash of the layout
behind it is refused `layout_malformed`, checked LAST so a broken layout is
never reported as a lying header. The fixed form does not need the alignment
and pads anyway, so the bytes do not move a second time when the cook and the
block form join the registry.

THE REGISTRY IS SIX ROWS: 0 never assigned, 1 the variable form, 2 the message
form (planned to become fixed-only, rebuilt on 3), 3 the fixed form, 4 the cook
(planned, §7), 5 the block form (planned, §19).

SELECTION IS BY THE KEYWORD, replacing the deviation the last pass reported. A
`fixed table` encodes as form 3 always and there is no path by which it reaches
form 1 — "we don't want to surprise the user" — so past 65536 a DECLARED fixed
table DOES NOT COMPILE, by name. A table merely DERIVED into the fixed mode
asked for nothing: it keeps form 1 and warns, which is what RenderFrame at
7.5 MB and WideBlob are, and that branch goes away when #823 lands. #823's
keyword is not merged, so `ir.Struct.FixedDeclared` carries the ONE pointer to
it and `ir.TableFixedRoots` still selects by the derived mode. 4096 stays a
warning. `--fixed-record-limit` only ever LOWERS: it cannot raise the 65536,
because that is the number a PEER's reader holds this build's records to and a
peer has never heard of this project's flag.

A FORM BYTE'S REFUSAL NOW SAYS WHICH DIRECTION. The registry is ordered, so
one word for both directions was one word too few. `<T>FixedLoad` answers
`previous_form` for byte 1 — the variable form is OLDER, and `newer_form`
there sent a caller looking for a build that does not exist —
`message_form_as_file` for 2, and `newer_form` only for a byte no form defines.
A variable reader given 3 answers `newer_form`, which it already did.

AND THE UNKNOWN-FORM NEGATIVE CONTROL MOVED FROM 3 TO 6, which §3.4 named as a
follow-on: 3 is the fixed form and 4 and 5 are reserved, so 6 is the first byte
that is neither defined nor reserved. `test/tables/main.cpp`, and `form_three`
became `form_six` in reports.txt, MANIFEST.txt, FORMAT.md and
testdata/wire/tables/. The wire fuzzer DERIVES its byte from the registry
(`ir.WireFormNone`) so the next form added moves it without anyone remembering.

`schema check` HANDED A SAVED FILE ANSWERS ITS FORM FIRST, one line, before
anything else it says about the file — `FORM 3 the fixed form`, or a refusal
naming the byte. The registry lives in `ir/formbyte.go` so the compiler, the
CLI and the fuzzer read one table rather than three copies.

GATES. go build/vet clean; golangci-lint 0 issues; go test green but for the
THREE PRE-EXISTING dialect-scan failures the last pass reported
(TestTableHeadersAreTheCDialect, TestTableDialectCheckBites,
TestZeroCostForValueOnlyTables — reproduced at 07bcdb6, untouched here).
`make tables-fixedform` green on the new header. `make conformance` green over
cpp, c, cs, go and rust — 59/59 report rows including the renamed one; dart,
elixir, java and js skipped, their pinned toolchains are not fetched in this
clone. Goldens and generated/ re-pinned, and generated/ is byte-stable across
two consecutive regenerations. The only wire byte that moved in the corpus is
byte 0 of the unknown-form control.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames gafferongames changed the title tables: SPEC §3.4, the layout — the rename, no version byte, the closed kind set, and the size bounds tables: the fixed form — SPEC §3.4, THE FIRST BYTE, the variable-form rename, and selection by the keyword Sep 9, 2026
@gafferongames

Copy link
Copy Markdown
Contributor Author

Emma Audit Sign-Off: PR #829 at 073c762

Audited commit 073c762a against SPEC §3.4 and Glenn's architecture directives:

  1. SELECTION IS BY THE KEYWORD:

    • Explicitly establishes that declared fixed table encodes as Form 3, always.
    • Clarifies that undeclared table remains unbounded Form 1 variable table, cleanly separating structural Mode from wire Form.
    • Confirms compile refusal for lookback guards, pointers, maps, and unbounded arrays on declared fixed tables per Glenn's ruling.
    • Pinned 65,536 limit to declared fixed tables, with --fixed-record-limit strictly lowering it.
  2. The Unified 20-Byte File Header:

    • Bytes 0..7: Form byte 3 at offset 0, bytes 1..7 zeroed padding.
    • Bytes 8..15: Little-endian u64 layout hash at byte 8.
    • Bytes 16..19: Little-endian u32 layout length at byte 16.
    • Bytes 20+: The Layout bytes, followed immediately by records (8-byte hash + body).
  3. Form Refusals & Registry:

    • Order-aware refusals: previous_form for Form 1 inputs on fixed readers, newer_form for unallocated/future form bytes, message_form_as_file for Form 2.
    • Form byte 6 used for unknown-form negative control.
    • ir.WireFormOf and ir.WireFormLine verified cleanly in ir/formbyte_test.go.
  4. Plan Operations:

    • Ops table cleanly specifies copy, count, text, union, widen, clamp, ordinal.

All tests passing (go test ./ir/..., go test ./compiler -run TestTable).

VERDICT: APPROVED / SIGN-OFF

gafferongames added a commit that referenced this pull request Sep 9, 2026
Johnny Grok.

C++ on the rename card (#829 at 073c762) already writes THE FIRST BYTE
header. The Go writer and reader now match those bytes:

  offset  0        form byte 3
  offsets 1 .. 7   reserved, zero
  offsets 8 .. 15  layout hash (u64 LE)
  offset  16       layout length (u32 LE), then the layout, then the records

Records are unchanged: hash plus values. A header whose hash is not the
hash of the layout behind it is layout_malformed, checked last.

FixedLoad still answers previous_form for form < 3 and newer_form for
form > 3. Form-1 Load of a FixedLoad table stays previous_form.

BYTES: C++ corpus of the 64 paired mixed records, 80915 bytes, sha256
31bb9dafeea3ae1af3136288bb6388154b3866205c1d8a0061aa6f111077a93b.
SHAPE: the Go packet codec, unchanged.

Held by TestFixedForm, TestForm1OfFixedTableRefused,
TestForm1OfVariableTableStillLoads.
gafferongames added a commit that referenced this pull request Sep 9, 2026
…register runtime names

- Fix flat fold array dispatch in TableFixedWire.Run:
  - Check slots[p.Dst].SetBytes != null before sizing checks in Copy.
  - Introduce TableFixedWire.Flat (opcode 7) for folded flat array span copies.
- Rename Block to Layout in C# fixed table wire runtime:
  - TableFixedBlockEntry -> TableFixedLayoutEntry, TableFixedBlockView -> TableFixedLayoutView.
  - <Type>FixedBlock -> <Type>FixedLayout, <Type>FixedBlockBytes -> <Type>FixedLayoutBytes (retaining FixedBlockBytes alias).
  - Register all TableFixed* runtime names in internal/tablenames/cs.go.
  - Remove dead no_block and block_malformed from TableRefuseReason.
- Update fixed form to 20-byte pinned header (PR #829):
  - 16-byte header with layout hash at byte 8 + 4-byte layout length at byte 16 + layout bytes + records.
  - Refuse form < 3 as previous_form (form 2 as message_form_as_file) and > 3 as newer_form.
  - Check header layout hash against layout bytes, refusing layout_malformed.
  - Remove allocation on null TableReport in FixedLoad.
- Add comprehensive test coverage in test/cs-tables/src/FixedFormChecks.cs:
  - Header hash mismatch refuses layout_malformed.
  - Record hash mismatch refuses no_layout.
  - Form refusals for forms 1, 2, and 6.
  - Folded flat array round trips for LoadoutConfig (4-byte enum array), RangedSigned (8-byte int16 array), ShipEntry (16-byte int32 array), and Copy fallback.
  - FixedLoad accepts null report without allocation.
@gafferongames
gafferongames merged commit cc6e595 into main Sep 10, 2026
68 of 71 checks passed
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.

2 participants