Skip to content

tables: the table-closure union tag carries Count, and the reservation reaches it (#601) - #661

Merged
rowan-claude merged 6 commits into
mainfrom
fix-601-tag-count
Sep 7, 2026
Merged

tables: the table-closure union tag carries Count, and the reservation reaches it (#601)#661
rowan-claude merged 6 commits into
mainfrom
fix-601-tag-count

Conversation

@rowan-claude

Copy link
Copy Markdown
Collaborator

A PACKET union's tag enum carries None, the variants, Count and Max
plus a debug-name function in all nine targets (#597, F-15). A TABLE-CLOSURE
union's tag shape is a different emitter — internal/codegen/cpptable's
emitTableUnion, written beside the tables rather than among the packet
declarations — and it carried None, the variants and Max alone. One
construct, two surfaces, and Count free as an arm name on one side only,
because the member did not exist there to collide with.

This closes that. Count and the EnumName overload land beside Max, and
the reservation moves from "where the member exists" to every union, because
the member is now on every union.

Merge, not rebase: origin/main at bb5f3bd4 fast-forwarded in before the
first commit.

The measurement, before anything moved

Thirteen tag enums across seven committed C++ table headers carried Max
with no Count beside it:

golden tag enums
tables/arms/{Carry,Gate,Nest,Ring}Table.h 6
tables/messages/MessagesTable.h 3
tables/lists/SaveTable.h, tables/maps/DepthTable.h, tables/stream/StreamTable.h, tables/wide/CaptionTable.h 1 each

Against them, every packet tag enum in testdata/golden/cpp/ already
carried Count = N, // the declared variant count (SPEC §4.2).

internal/codegen/cpptable/unions.go:50 is the ONE emitter of that comment
outside the nine packet backends, which is what makes this a one-target
change where F-15 was a nine-target one: the table layer is the C++
reference's (docs/SPEC-TABLES.md §11, §15). The Go table backend emits no
tag enum at all
gotable references <Union>TypeNone from the codecs
but declares nothing — so there is no oracle view of the enum to move.

Red first

1211337a commits the gates alone, so the red is a fact in the history
rather than a claim in a report:

--- FAIL: TestATableClosureUnionArmNamedCountIsRefusedByName (0.00s)
    tagenumsurface_test.go:192: a table-closure union's arm named count passed check, and its tag enum defines Count twice
--- FAIL: TestTableClosureUnionTagEnumExportsCountAndItsDebugName (0.00s)
    tagenumsurface_test.go:257: cpp: the table-closure tag enum's surface is short, "    Count = 2, // the declared variant count (SPEC §4.2)" not emitted
    tagenumsurface_test.go:257: cpp: the table-closure tag enum's surface is short, "inline const char * EnumName( WeaponFireType value )" not emitted
--- FAIL: TestTableClosureTagEnumDebugNameCoversNoneVariantsAndOutOfSet (0.00s)
    tagenumsurface_test.go:272: cpp: the table-closure tag enum has no name function, "inline const char * EnumName( WeaponFireType value )" not emitted

--- FAIL: TestDiagnostics/union_variant_named_count_on_a_table-closure_union_(exported_spelling) (0.00s)
    diagnostics_test.go:592: compiled clean — the language broke silently (want "carries its declared variant count as the member Count")

The probe unit declares an enum of THREE variants beside a union of two,
the trap #597 found running the packet control: with both at two, the
declared enum's own Count line satisfies a claim meant for the tag enum.
The debug-name gate is anchored to the function's BODY, because None,
Laser and Missile all appear elsewhere in the same header.

TestATableClosureUnionArmNamedCountIsAccepted is deleted. It pinned the
split as law and the split is gone.

The change

The emitter. emitTableUnion writes Count beside Max and the
EnumName overload beneath the enum, to the character the packet emitter
writes them, so a table arm and a type arm read as one family. Nothing on
the read or write path calls the function and no generated code does.

The reservation. Count moves out of resolveUnion's post-loop block
and into the switch beside None, Max and Type, which is where it
belongs once the kind no longer has to be known: the arm errors and stops
rather than resolving on. ir.Union.TableClosureOnly keeps its three other
callers.

Every id stays put

Count is a generated member, not a wire fact:

  • git diff origin/main -- testdata/wire is empty.
  • git diff origin/main -- generated/ is empty — the packet corpus
    declares no table-closure union.
  • testdata/golden/id.txt and testdata/golden/build-version/ are
    untouched.

The CORPUS MOVE the reservation forces moves no id either, because it is a
was rename. tables/messages's EditBody arm becomes
tally int32 | min = 0, max = 100, was = "count", and every one of its ids
in MessagesTable.h stays 0xb1e5e28e4479a274 — the file form's dispatch,
the message form's, the reflection descriptor and the arms table.
examples-wide/Caption.schema's Body.count moves the same way. The
baseline records it and says so:

arm tally id=0xb1e5e28e4479a274 kind=4 min=0 max=100 was=count
...
### 2026-09-07 (UTC) — the EditBody arm count is renamed to tally under was: every union's tag enum now carries the member Count, so the name is reserved (#601)
- no compatibility-affecting edits; the wire absorbs the rest

Fixtures that carry no stored data move by plain rename with no alias:
test/tables/R1.schema and R2.schema (to amount, since tally is
taken there by a keyed array), test/tables/RT2.schema,
ir/buildversion_test.go, internal/baseline/baseline_test.go,
compiler/wasrows_test.go and the harness's rowdemo unit. No pinned
instance selects any of them.

The finding this turned up, and why it lands first

688acf77 is not the pass. It is what the pass depends on.

The was rename only keeps the id if every id derivation reads the
alias, which docs/SPEC-TABLES.md §5 states outright: "Every id derivation
reads the alias: the node record, the cooked node directory, the
connection's announced vocabulary (§3.3), the baseline and the build
version."

Three arm-dispatch sites did not. The announcement's entry is
ir.TableArmEntry, whose own comment says it hashes the WIRE name, and the
encoder spends that id. The message form's readers matched on the DECLARED
name's hash instead:

  • internal/codegen/cpptable/messageload.go, <Table>LoadMessageBody
  • internal/codegen/cpptable/messagecodec.go, the union-arm extent walk
  • internal/tablewire/messagedecode.go, the Go engine's arm dispatch

So a was-renamed arm was written under the old name's hash and read under
the new one: lost by its own build, counted unknown, in the C++
reference and in the engine alike. The file form was always right —
decode.go and every other cpptable site call WireName() — and no corpus
carried a message-form was arm, so nothing pinned could catch it.

--- FAIL: TestAWasRenamedArmSurvivesTheMessageForm (0.00s)
    wasarmmessage_test.go:116: the was-renamed arm was lost: the union reads back None, report {Unknown:1 KindMismatch:0 Widened:0 Clamped:0 Duplicate:0 Malformed:false Refused:false}

The C++ gate slices NoteLoadMessageBody's own body rather than the output,
because the old name's hash is already in the header twice over, in the file
form's dispatch and in the reflection descriptor. Its control, the one line
put back:

--- FAIL: TestTheMessageFormsArmDispatchReadsTheWasAlias (0.00s)
    wasarmmessageid_test.go:57: cpp: the message form does not dispatch the renamed arm on its old name's hash, "case 0xb1e5e28e4479a274ull: // tally" absent from NoteLoadMessageBody
    wasarmmessageid_test.go:60: cpp: the message form dispatches the renamed arm on its DECLARED name's hash, "case 0xaa3f4d29d02d54ddull: // tally" — the announcement writes the alias's

No committed golden moves at that commit: no corpus declared a was arm
reaching a message form yet, and R2.schema's two pin no golden.

The goldens, every moved file and why

file why
tables/arms/{Carry,Gate,Nest,Ring}Table.h, tables/lists/SaveTable.h, tables/maps/DepthTable.h, tables/stream/StreamTable.h the added Count line and the added EnumName overload, nothing else
tables/messages/MessagesTable.h the same two additions on three tag enums, plus the arm's rename: Count -> Tally as a tag member, count -> tally as storage and as the text key, every id unmoved
tables/wide/CaptionTable.h, wide/cpp/CaptionTable.h the two additions plus the Body arm's rename
wide/cpp/WideView.cpp the view's arm name and its spelling, twice; the id beside each unchanged
tables/messages/tables.baseline the was row and a dated history line
testdata/conformance/tables/json/message_arm_edits.json, the two json-hostile/arm-scalar-* texts an arm's name IS its key in the text form (§16.2), so the key follows the rename; the wire pins those texts encode to are byte-identical

The controls, each turning exactly its own claim red

(1) the Count line deleted from emitTableUnion. The coverage gate
beside it stays green, which is right: it gates the name function.

--- FAIL: TestTableClosureUnionTagEnumExportsCountAndItsDebugName (0.00s)
    tagenumsurface_test.go:257: cpp: the table-closure tag enum's surface is short, "    Count = 2, // the declared variant count (SPEC §4.2)" not emitted
--- PASS: TestTableClosureTagEnumDebugNameCoversNoneVariantsAndOutOfSet (0.00s)

(2) the name function deleted. Both claims fire, and the second is the
anchored one:

--- FAIL: TestTableClosureUnionTagEnumExportsCountAndItsDebugName (0.00s)
    tagenumsurface_test.go:257: cpp: the table-closure tag enum's surface is short, "inline const char * EnumName( WeaponFireType value )" not emitted
--- FAIL: TestTableClosureTagEnumDebugNameCoversNoneVariantsAndOutOfSet (0.00s)
    tagenumsurface_test.go:272: cpp: the table-closure tag enum has no name function, "inline const char * EnumName( WeaponFireType value )" not emitted

(3) the reservation scoped back off the table-closure shape, the one
line if un.TableClosureOnly() { break } put back in the switch:

--- FAIL: TestATableClosureUnionArmNamedCountIsRefusedByName (0.00s)
    tagenumsurface_test.go:192: a table-closure union's arm named count passed check, and its tag enum defines Count twice
--- FAIL: TestDiagnostics/union_variant_named_count_on_a_table-closure_union_(exported_spelling) (0.00s)
    diagnostics_test.go:592: compiled clean — the language broke silently (want "carries its declared variant count as the member Count")

(4) the was alias in the message form. Quoted above.

The pages, for the cold read

Present tense, no history. Read them on their own with
git diff bb5f3bd4..HEAD -- docs/.

  • SPEC §4.2, the exported-extent paragraph: "A PACKET union's ... A
    table-closure union's tag shape carries Max alone (tool: the table-closure union tag shape gets its own Count, and the reservation reaches it #601)" becomes a
    union's tag enum carrying Count, with the table-closure shape named as a
    different emitter carrying the same members.
  • SPEC §4.8, the reserved-names bullet: Count is refused on every
    union, the packet shape and the table-closure shape alike, and the
    scoping sentence is replaced by "the reservation reaches where the member
    exists, and the member exists on every union".
  • SPEC §4.8, the table-closure bullet: it stated the split and the
    follow-on. It now states the shared surface, names the emitter's location,
    and says the shape has ONE emitter where the packet shape has nine.
  • SPEC §4.11, the Count bullet: "on a PACKET union" becomes "on any
    union", and the legal-there sentence becomes the reservation reaching it.
  • SPEC-TABLES §2.6 gains a paragraph, THE TAG SHAPE IS THE PACKET TAG
    ENUM'S, MEMBER FOR MEMBER, naming every member and the debug-name
    function outright, since a different emitter writes it; and the
    illustrative union Value's count arm moves to tally, in the schema
    block, the C++ overlay block and the selection paragraph that names it.
  • SPEC-TABLES §11 gains AN ARM NAMED Count beside the other arm
    refusals.
  • USAGE and TUTORIAL part 5: "on a packet union" becomes "on every
    union".
  • docs/VERSIONING.md lists no reserved names, so nothing there moved.

All four #601 links are gone from SPEC.md.

Closes #601

rowan-claude and others added 5 commits September 6, 2026 23:48
RED FIRST, committed before the emitter moves so the red is a fact in the
history rather than a claim in a report.

Three gates and one corpus rename go in together, and all three gates fail
at this commit:

- `TestTableClosureUnionTagEnumExportsCountAndItsDebugName` asserts the
  table-closure tag enum carries `Count` and the debug-name function, the
  way `TestUnionTagEnumExportsCountAndItsDebugName` asserts it for a packet
  union. The probe unit declares an enum of THREE variants beside a union of
  two, the trap PR #597 found running the packet control: with both at two,
  the declared enum's own `Count` line satisfies a claim meant for the tag
  enum.
- `TestTableClosureTagEnumDebugNameCoversNoneVariantsAndOutOfSet` anchors
  its claim to the function's BODY, from the opening line to the out-of-set
  `"???"`. `None`, `Laser` and `Missile` all appear elsewhere in the same
  header, so a whole-output claim stays green with the function deleted.
- `TestATableClosureUnionArmNamedCountIsRefusedByName` replaces
  `TestATableClosureUnionArmNamedCountIsAccepted`, which pinned the present
  split as law. The reservation reaches every union once the member exists
  on every union.

`internal/check`'s diagnostics table gains the packet and table-closure
refusals as a pair, so one rule is read from both sides. The fixture arms
that spelled themselves `count` for unrelated cases move to `tally` and
`size`: those cases test a default on an arm, an optional arm, a json arm
and a range excluding zero, and none of them is about the name.

The red, at this commit:

    --- FAIL: TestATableClosureUnionArmNamedCountIsRefusedByName (0.00s)
        tagenumsurface_test.go:192: a table-closure union's arm named count passed check, and its tag enum defines Count twice
    --- FAIL: TestTableClosureUnionTagEnumExportsCountAndItsDebugName (0.00s)
        tagenumsurface_test.go:257: cpp: the table-closure tag enum's surface is short, "    Count = 2, // the declared variant count (SPEC §4.2)" not emitted
        tagenumsurface_test.go:257: cpp: the table-closure tag enum's surface is short, "inline const char * EnumName( WeaponFireType value )" not emitted
    --- FAIL: TestTableClosureTagEnumDebugNameCoversNoneVariantsAndOutOfSet (0.00s)
        tagenumsurface_test.go:272: cpp: the table-closure tag enum has no name function, "inline const char * EnumName( WeaponFireType value )" not emitted

    --- FAIL: TestDiagnostics/union_variant_named_count_on_a_table-closure_union_(exported_spelling) (0.00s)
        diagnostics_test.go:592: compiled clean — the language broke silently (want "carries its declared variant count as the member Count")

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FOUND WHILE MOVING THE CORPUS FOR #601, and landed ahead of it because the
move depends on it. `tables/messages`'s `EditBody` arm `count` has to move
under the reservation, and it moves under `was` so its id stays the old
name's hash and no wire pin moves. That only holds if every id derivation
reads the alias, which docs/SPEC-TABLES.md §5 states outright: "Every id
derivation reads the alias: the node record, the cooked node directory, the
connection's announced vocabulary (§3.3), the baseline and the build
version."

THREE ARM-DISPATCH SITES DID NOT. The announcement's entry is
`ir.TableArmEntry`, whose own comment says it hashes the WIRE name, and the
encoder spends that id. The message form's readers matched on the DECLARED
name's hash instead:

- `internal/codegen/cpptable/messageload.go`, `<Table>LoadMessageBody`
- `internal/codegen/cpptable/messagecodec.go`, the union-arm extent walk
- `internal/tablewire/messagedecode.go`, the Go engine's arm dispatch

So a `was`-renamed arm was written under the old name's hash and read under
the new one: LOST BY ITS OWN BUILD, counted `unknown`, in the C++ reference
and in the engine alike. The file form was always right — `decode.go` and
every other cpptable site call `WireName()` — and no corpus carried a
message-form `was` arm, so nothing was pinned that could catch it.

The red, before the fix:

    --- FAIL: TestAWasRenamedArmSurvivesTheMessageForm (0.00s)
        wasarmmessage_test.go:116: the was-renamed arm was lost: the union reads back None, report {Unknown:1 KindMismatch:0 Widened:0 Clamped:0 Duplicate:0 Malformed:false Refused:false}

The C++ gate is anchored to `NoteLoadMessageBody`'s own body, not to the
output: the old name's hash is already in the header twice over, in the file
form's dispatch and in the reflection descriptor, so a whole-output claim
stays green with the message form still wrong. The negative control, the one
line put back:

    --- FAIL: TestTheMessageFormsArmDispatchReadsTheWasAlias (0.00s)
        wasarmmessageid_test.go:57: cpp: the message form does not dispatch the renamed arm on its old name's hash, "case 0xb1e5e28e4479a274ull: // tally" absent from NoteLoadMessageBody
        wasarmmessageid_test.go:60: cpp: the message form dispatches the renamed arm on its DECLARED name's hash, "case 0xaa3f4d29d02d54ddull: // tally" — the announcement writes the alias's

No committed golden moves at this commit, because no corpus declares a `was`
arm that reaches a message form yet. `test/tables/R2.schema` declares two,
and R2 pins no golden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n reaches it (#601)

A PACKET union's tag enum carries `None`, the variants, `Count` and `Max`
plus a debug-name function in all nine targets (#597, F-15). A TABLE-CLOSURE
union's tag shape is a different emitter — `internal/codegen/cpptable`'s
`emitTableUnion`, written beside the tables rather than among the packet
declarations — and it carried `None`, the variants and `Max` alone. One
construct, two surfaces, and `Count` free as an arm name on one side only
because the member did not exist there to collide with.

THE MEASUREMENT, taken before anything moved. Thirteen tag enums across
seven committed C++ table headers carried `Max` with no `Count` beside it:
`arms/{Carry,Gate,Nest,Ring}Table.h` (six between them),
`lists/SaveTable.h`, `maps/DepthTable.h`, `messages/MessagesTable.h`
(three), `stream/StreamTable.h` and `wide/CaptionTable.h`. Against them,
`testdata/golden/cpp/`'s packet tag enums each carry
`Count = N, // the declared variant count (SPEC §4.2)`.
`internal/codegen/cpptable/unions.go:50` is the ONE emitter of that comment
outside the nine packet backends, which is what makes this a one-target
change where F-15 was a nine-target one: the table layer is the C++
reference's (docs/SPEC-TABLES.md §11, §15). The Go table backend emits no
tag enum at all, so there is no oracle view to move.

THE EMITTER. `emitTableUnion` now writes `Count` beside `Max` and the
`EnumName` overload beneath the enum, to the character the packet emitter
writes them, so a table arm and a type arm read as one family. Nothing on
the read or write path calls the function and no generated code does.

THE RESERVATION. `Count` moves out of `resolveUnion`'s post-loop block and
into the switch beside `None`, `Max` and `Type`, which is where it belongs
once the member is on every union: the kind no longer has to be known, so
the arm errors and stops rather than resolving on. `ir.Union.TableClosureOnly`
keeps its three other callers.

EVERY ID STAYS PUT. `Count` is a generated member, not a wire fact:
`git diff origin/main -- testdata/wire` is empty and `generated/` is
untouched (the packet corpus declares no table-closure union). The CORPUS
MOVE the reservation forces is a `was` rename, so it moves no id either —
`tables/messages`'s `EditBody` arm `count` becomes
`tally int32 | min = 0, max = 100, was = "count"`, and every one of its ids
in `MessagesTable.h` stays `0xb1e5e28e4479a274`, in the file form's
dispatch, the message form's, the reflection descriptor and the arms table.
`examples-wide/Caption.schema`'s `Body.count` moves the same way. The
baseline records it and says so: `arm tally id=0xb1e5e28e4479a274 kind=4
min=0 max=100 was=count`, "no compatibility-affecting edits".

Four fixtures carry no stored data, so they move by plain rename with no
alias: `test/tables/R1.schema` and `R2.schema` (`count` -> `amount`, and
`tally` was taken there by a keyed array), `test/tables/RT2.schema`,
`ir/buildversion_test.go`, `internal/baseline/baseline_test.go`,
`compiler/wasrows_test.go` and the harness's `rowdemo` unit. No pinned
instance selects any of them.

THE GOLDENS, every moved file and why:

- `tables/arms/{Carry,Gate,Nest,Ring}Table.h`, `tables/lists/SaveTable.h`,
  `tables/maps/DepthTable.h`, `tables/stream/StreamTable.h`: the added
  `Count` line and the added `EnumName` overload, nothing else.
- `tables/messages/MessagesTable.h`: the same two additions on three tag
  enums, plus the arm's rename — `Count` -> `Tally` as a tag member,
  `count` -> `tally` as storage and as the text key, with every id
  unmoved.
- `tables/wide/CaptionTable.h` and `wide/cpp/CaptionTable.h`: the two
  additions plus the `Body` arm's rename.
- `wide/cpp/WideView.cpp`: the view's arm name and its spelling, twice.
  The id beside each is unchanged.
- `tables/messages/tables.baseline`: the `was` row and a dated history
  line.
- `testdata/conformance/tables/json/message_arm_edits.json` and the two
  `json-hostile/arm-scalar-*` texts: an arm's name IS its key in the text
  form (§16.2), so the key follows the rename. The wire pins those texts
  encode to are byte-identical.

THE PAGES, in present tense. SPEC §4.2's tag-enum paragraph, §4.8's
reserved-names bullet and its table-closure bullet, and §4.11's `Count`
bullet each stated the split and now state the shared surface; the four
`#601` links are gone. SPEC-TABLES §2.6 gains a paragraph naming the tag
shape's members and its debug-name function outright, since a different
emitter writes it, and §11 gains the arm-name refusal beside the other arm
refusals. USAGE and TUTORIAL part 5 said "on a packet union" and now say
"on every union". `docs/VERSIONING.md` lists no reserved names, so nothing
there moved.

THE CONTROLS, each turning exactly its own claim red. The `Count` line
deleted from `emitTableUnion`:

    --- FAIL: TestTableClosureUnionTagEnumExportsCountAndItsDebugName (0.00s)
        tagenumsurface_test.go:257: cpp: the table-closure tag enum's surface is short, "    Count = 2, // the declared variant count (SPEC §4.2)" not emitted

The name function deleted, against the body-anchored gate:

    --- FAIL: TestTableClosureTagEnumDebugNameCoversNoneVariantsAndOutOfSet (0.00s)
        tagenumsurface_test.go:272: cpp: the table-closure tag enum has no name function, "inline const char * EnumName( WeaponFireType value )" not emitted

The reservation scoped back to `!un.TableClosureOnly()`:

    --- FAIL: TestATableClosureUnionArmNamedCountIsRefusedByName (0.00s)
        tagenumsurface_test.go:192: a table-closure union's arm named count passed check, and its tag enum defines Count twice
    --- FAIL: TestDiagnostics/union_variant_named_count_on_a_table-closure_union_(exported_spelling) (0.00s)
        diagnostics_test.go:592: compiled clean — the language broke silently (want "carries its declared variant count as the member Count")

Closes #601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`schema fmt` is the one command that writes a schema file (#473) and a
fmt-drift gate holds the corpus canonical. The `was` attribute on
`examples-wide/Caption.schema`'s renamed arm sits at the column the
formatter picks, which `make fmt` supplied and this commit records. No
generated byte moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rowan-claude
rowan-claude merged commit 006390c into main Sep 7, 2026
21 checks passed
@rowan-claude
rowan-claude deleted the fix-601-tag-count branch September 7, 2026 09:10
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.

tool: the table-closure union tag shape gets its own Count, and the reservation reaches it

1 participant