Skip to content

spec: the wstring table kind, widening, float16, Open reasons, text comments (#523) - #532

Merged
gafferongames merged 9 commits into
mainfrom
spec-kinds-and-rules
Sep 4, 2026
Merged

spec: the wstring table kind, widening, float16, Open reasons, text comments (#523)#532
gafferongames merged 9 commits into
mainfrom
spec-kinds-and-rules

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Rulings 1, 5, 6, 7, 8 and 9 of #523, on the owner's word of 2026-09-04. Specification only, no code. Rebased onto main after #507, #527, #529, #530 and #531.


The blocking ruling from the cold read: ill-formed text is framing-class damage

On the table wire, ill-formed text content is damage and not data. A kind 33 payload carrying an unpaired surrogate or an interior zero unit, and a kind 12 payload carrying malformed UTF-8 or an interior zero byte, each leave the field at its declared default, count one malformed, and let the parent read on past L. Kind 12's silent acceptance was an inherited gap, not a precedent, and it is fixed here rather than deferred: the two text kinds now hold one rule.

That inverts what the first draft of this PR said, and it makes the page simpler rather than more complicated:

  • SPEC.md §4.12, §4.7 and §5 stay true exactly as written. Every generated reader refuses ill-formed text, in all nine targets, in every build mode. Only the SHAPE of the refusal differs by wire: the packet wire stops, because a bit stream that met one has nowhere to continue; the table wire defaults and counts, because L says where the next field begins. Neither accepts it, which is what lets nine targets owe each other one verdict on one payload.
  • The write side needs no rescoping. A tolerant load never produces an instance the reference cannot re-save, because ill-formed content never reaches storage.
  • §16.3's U+FFFD rule becomes the text form's rule for its own storage only — an instance a PROGRAM built, or one a lone surrogate escape introduced — and it now states the two spellings separately because the two failures differ: a code point is escaped and preserved (\^@ for a zero byte or zero unit), and what is not a code point at all is replaced (U+FFFD for an ill-formed UTF-8 sequence or an unpaired surrogate).
  • SECURITY.md's "not a gap" sentence is gone, replaced by the counted rule: a table reader that STORES any of it is an acceptance bug on that list, exactly as a packet reader that accepts one is.
  • Content is checked BEFORE the reader's bound is applied, so no clamp can produce ill-formed storage, and §17.3's UTF-8 carve-out narrows to instances built in code.

Two further rulings landed with it:

  • Reserved kind 34 is a NAME reservation for a later major and nothing else. It is not in this major's set, no writer emits it, no reader has a rule for it, and a conforming later-major writer carries a new kind under the escape kind 31 (Reserve one escape kind on the table wire: length-prefixed opaque, skip me #434) so this major's readers skip it and count unknown. A reader of this major therefore meets 34 only as damage, exactly as it meets 35 — which is the correct answer, because a bare 34 is a writer that ignored the escape.
  • The Open check order is §7's own enumeration order, one order for both accelerators: magic, byte order, build version, reserved words zero, the alignment word validated, the two part lengths against the caller's length, the root's storage inside the data part, the base alignment. So bad_alignment precedes both truncated clauses, and the page says why — the alignment word is the one field the check computes WITH, so the part-length arithmetic below it would otherwise run over a forgery. §19.2's list is reordered to match, and a block's prologue has NO reserved words and no alignment word (§19.1: exactly magic, build_version, byte_order), so reserved_not_zero and bad_alignment never fire for a block and the page says that rather than leaving it ambiguous.

1. wstring on the table wire (folds #522)

wstring(N) gets kind 33 in §3's closed set: L, then L bytes holding L / 2 UTF-16 code units, two bytes each little-endian. An odd L is framing damage. Its unbounded twin is *wstring, a blob node under the third reserved id fnv1a64( "wstring" ), beside bytes and string. The layout row is char16_t[N + 1] then an int32 used length in code units (§7.2), the blob node is a u64 byte length, the units and a zero unit, the text row is a JSON string transcoded at the boundary (§16.2), and the LoadMeasure term is the framed length plus the eight-byte header plus the terminator the reserved type id names. wstring(N) is refused as a map key by name in §2.8, the diagnostic naming string(N). The §11 closure refusal from #510 is deleted, and the arm row, the skip rule, the retain-unknown copy list, the fuzzer strategies and cook-check's companion list all gain the kind.

The table-form goldens take the packet names with -table on them, and the paragraph states the whole verdict table, including which of the corpus's seventeen refusals do not carry over and why.

Cold-reader questions.

  • The blob's length is BYTES and always even, not a code unit count. That keeps one arithmetic for all three blob kinds in the wire, the region and cook-check. Is the parallel worth the half step a reader has to do?
  • Is *wstring earning its place, or should the ruling's reserved id have been reserved and left unbuilt until a field asks for it?
  • The map-key refusal argues from memcmp over little-endian units. Does that argument hold, or is the honest reason simply that no wide order is worth standardizing?
  • TableWStringAt's view carries a length in UNITS where the two narrow views carry bytes, and AllocWString( n ) takes a unit count where AllocBytes/AllocString take bytes. Is the family better with one inconsistency and no call-site doubling, or with one unit and a * 2 at every wide call?

2. Widening on read

A new widened counter, sixth in the read report. The signed ladder is kinds 2, 3, 4, 5, 18 and the unsigned one 6, 7, 8, 9, 19, each kind accepting every kind below it on its own ladder, plus 10 into 11. Everything else stays kind_mismatch, the narrowing direction included. The rule is decided by the kind pair alone, so it holds at a field, an arm, an array element and a map key. §4's report line, §4.1's evolution table, §4.2's fuzzer and VERSIONING.md's read report, "Widen a field" pattern and "no widening path" sharp edge all move. The silent class stays at four.

Cold-reader questions.

  • The counter is not a loss counter, so the never-clobber condition does not name it. Is that right, given a rewrite writes the wider kind and the file changes?
  • Applying the rule at a MAP KEY loads a map that used to read empty. Order is preserved under sign and zero extension, so it is safe, but is it in scope?
  • Including i128/u128 as the top rung of each ladder is a reading of "an integer kind", not a ruling. Should it be there?
  • A flags field rides as kind 9, so it accepts 6 through 9 like the uint64 it is indistinguishable from. Should flags be excepted, or is the special case worse than the pair?
  • The baseline still REFUSES a widening, unchanged, because the tolerance runs one way and a mixed fleet has readers on both sides. Should it warn instead?

3. Flags storage stays uint64

SPEC.md §4.2 states the reason: a mask rides as a raw uint64 on the table wire and a load copies it verbatim, so full-width storage is what lets an older build load bits it cannot name, hold them, and write them back. Derived storage would drop them with nothing able to report it.

Cold-reader question. The property is the TABLE wire's. The packet wire reads W bits and never sees the appended ones. Is the reason stated at the right altitude sitting in SPEC.md?

4. float16

Declined for this major, kind 34 reserved by name in §3's kind table with bits(16) plus application conversion named as the spelling. One bullet in SPEC.md §4.10, one paragraph in §3, one clause in VERSIONING.md's kind space. The fuzzer plants 34 and requires framing damage rather than an escape.

Cold-reader question. Is reserving a number in a closed set the right shape, or does the escape kind 31 already cover a later addition well enough that 34 is a promise the page did not need to make?

5. Open and BlockOpen name their refusal

A TableRefuseReason out-parameter beside the null, filled on every call, with one value per clause of §7's existing check list: ok, not_a_cook, foreign_order, wrong_build_version, truncated, reserved_not_zero, bad_alignment, unaligned_base, plus bad_layout for a block. The check runs in that order and the first failing clause names the reason, so nine languages answer alike on one file. The parameter is optional in every target, so no call site moves. TableRefuseReason joins §11's claimed names. VERSIONING.md's "Open refuses in silence" edge is rewritten.

Cold-reader questions.

  • Two alignment values, bad_alignment for the header word and unaligned_base for the caller's pointer. The ruling said "unaligned", one name. Is the split worth it? The argument is that only one of the two is the caller's own fix.
  • The byte-order clause is the one clause with no value of its own — it answers not_a_cook, because a header whose byte_order word contradicts its own magic describes no cook in either order. Is folding it right, or does it want a ninth value?
  • Should the enum swallow the caller errors JavaScript and Dart throw for? The page says no.

6. Comments in the text form

// and /* */, non-nesting, legal wherever whitespace is, ordinary text inside a string, an unclosed /* is malformed. Neither writer ever emits one. §16.2's "Comments are not JSON and are refused" is replaced, the round-trip guarantee is restated to say what it covers — the form's own output, not a hand-edited text, which loses its comments and trailing commas the same way it loses its line breaks and key order — and §17.3 gains the pack then unpack lap. Held by test in §16.5, including the writer gate that ToJson of every corpus instance contains no / outside a string.

Cold-reader questions.

  • The read then write direction is now explicitly lossy for a hand-authored tree. Is stating that enough, or does unpack need a flag that refuses to overwrite a file it did not write?
  • Is /* */ worth carrying at all, or is // alone the whole want?

Decisions the page made that #523 or the cold read did not

  1. A *wstring blob's length is a byte length, always even, rather than a code unit count, so the three blob kinds share one arithmetic in the wire, the region and cook-check.
  2. Ill-formed CONTENT in a text blob is malformed too, on §3.1's own terms — every slot naming that blob reads null. The ruling named the two field kinds; a blob is where the same bytes ride at a node, and leaving it out would have been the gap again one level down.
  3. The clamp cuts are stated and ordered: content check first, then the bound. A kind 12 clamp cuts at a code point boundary and a kind 33 clamp at N units, dropping a high surrogate whose low half did not fit — the same cuts §16.2 already states for the text form, so both forms land the same bytes.
  4. A zero unit and a zero byte are \^@ in the text form, not U+FFFD. They are U+0000, a code point JSON has an escape for. Only what is not a code point is replaced.
  5. Widening applies at every site a kind is compared — field, arm, array element, map key — rather than at the field alone, and counts once per field or map, matching the kind_mismatch it replaces. The fuzzer strategy now plants it at all four.
  6. The 128-bit kinds are the top rung of each ladder.
  7. flags is not excepted from widening, because the rule is the kind pair and nothing else.
  8. The baseline still refuses a widening, unchanged, so §18 does not move.
  9. widened is not a loss counter — out of the never-clobber condition (stated explicitly in VERSIONING.md now), out of the silent class, and zero in the text form.
  10. A table load does not clamp a flags mask, and the page says so and says why the storage is uint64. And it names the cost: an older build that loaded appended bits out of a table and then puts that mask on a PACKET drops them by width, silently, with no counter on either wire able to say so.
  11. bad_layout is the block's own value, and the two cook values a block cannot produce are named as never firing rather than left ambiguous.
  12. TableRefuseReason is disambiguated from spec: the message form, an id table scoped to the connection (#523) #530's message-form refusal values in one paragraph: different enums, different paths, never returned by one call.
  13. TableWStringAt answers a view whose length is a COUNT OF UNITS and AllocWString( n ) takes a unit count, because every other wstring number on the page is a unit count and a byte argument here would be the one call a caller doubles at the call site. Dart spells them tableWStringAt and allocWString. All three trios are claimed in §11.
  14. An unclosed /* is malformed; a // ends at end of line or end of input, so a hand-edited last line without a trailing newline is accepted.
  15. The round-trip guarantee is restated as the form's own output only. pack then unpack over a hand-authored tree is explicitly not byte-stable: comments and trailing commas go the way line breaks and key order already do.
  16. SECURITY.md's text bullet now covers both wires, with the table wire's storing of ill-formed text named as an acceptance bug.
  17. Counter sweep: eleven SPEC-TABLES sites, VERSIONING's two, PORTING's, USAGE's two and COMPETITION's. VERSIONING's never-clobber sentence stopped carrying a count that drifts and now says which counters, with widened explicitly outside the condition.

One defect found on main and fixed in passing

#530's change (g) did not land. Its commit message says "3's form-byte pin now uses forms 0, 3 and 0xFF, since 2 is a known form with rows of its own", but §4.2's fuzzer strategy on main still plants form byte 2 and requires "a named refusal and never damage" — which is now wrong, because 2 IS a known form with rules of its own (§3.3). Since this PR edits that same list, the pin is corrected to 3 here, with the reason stated inline. Flagging it rather than burying it, in case #530's author intended a different site.

Reconciliation with #530 and #531

#530, the message form.

  • §3.3's vocabulary tail cited "(§3.1, and schema#532 for the third)" for the third blob id. That forward reference is now satisfied and reads "(§3.1, all three)".
  • The reserved-id COUNTS spec: the message form, an id table scoped to the connection (#523) #530 moved from two to three — the node-table id and the announcement's build-version id — count ids the LANGUAGE HOLDS BACK, not the blob type ids, which are ordinary hashes of keywords. They are untouched by this PR, correctly. The blob-spelling counts this PR moves from two to three are a different axis.
  • TableRefuseReason is disambiguated from §3.3's message-form refusal values in one paragraph: a TableRefuseReason means a FILE was refused, one of §3.3's means a MESSAGE on a connection was, and the recoveries have different owners.

#531, unbounded arrays.

The name is TableRefuseReason, ruled. The enum was TableOpenReason in the first draft, and #531's ruling that LoadMeasure's -1 carries the same enum made a name built on Open wrong at half its call sites — nothing is opened by a measure. It is renamed across §6.5, §7, §19.2 and §11's claim, with the Dart spelling stated (TableRefuseReason keeps its PascalCase there, its values take that backend's lowerCamelCase, the shape TableCookRef.outside already takes), and §7 states the reason for the name rather than leaving it to be inferred. #531's §6.5 reference, which could only describe the enum because this PR merges second, now resolves to it by name.

Merge check

git merge-tree against main @ dd39038 (after #530 and #531) is clean, 0 conflicts. Nothing else is open that touches these files.

Two edits are shaped to stay out of #531's way and were kept that way through the rebase: §11's byte-buffers bullet gains *wstring at the END of the bullet, and §2.8's VALUE list carries the wide spellings one line above the *bytes/*string line. No prose was weakened for it.

Known stale, left for the docs pass

docs/COMPARISON-TABLES.md rows 162 and 272 still list five report fields. Those are the D-cells #523's rulings send to the docs pass. testdata/conformance/tables/FORMAT.md and test/conformance/README.md carry the harness's counter tuple and move with the implementation PR, as does the §4 arm-evolution row's manifest pin.

Cold read

Verified at 79176ef. Three named edits landed after it, plus the TableRefuseReason rename the reader's fetch missed:

  • §2.6's arm companion list carries wstring(N) beside string(N) and bytes(N).
  • §3's gate row says three reserved-id refusals, listing the node-table id, the build-version id and another table's id, so it agrees with §3.3. §3.3's row, which read "the two become three", now names the build-version id as the third of §3's three rather than restating a transition from a count §3 no longer carries.
  • §7's refusal table had a stale reason / what the check found header above the clause / reason one, left from the reorder. One table, one header, and the ok row's clause cell now reads "no clause fails".

Ready for review. Do not merge.

@gafferongames

Copy link
Copy Markdown
Contributor Author

Rulings from the cold read, under the owner's word of 2026-09-04:

  1. On the table wire, ill-formed text content is framing-class damage. A kind 33 payload with an unpaired surrogate or an interior zero unit, and a kind 12 payload with malformed UTF-8 or an interior zero byte, each leave the field at its default, count one malformed, and the parent reads on past L. Kind 12's silent acceptance was an inherited gap, fixed in the same PR. SPEC.md §4.12, §4.7 and §5 stand as written; §16.3's U+FFFD rule is the text form's for its own storage; SECURITY.md's "not a gap" sentence is replaced by the counted rule.
  2. Reserved kind 34 is a name reservation for a later major; a conforming later-major writer carries a new kind under the escape kind 31, so a reader of this major meets 34 only as damage.
  3. The Open check order is §7's enumeration order, one order for the cook and the block, so the alignment word is validated before the root-fits arithmetic.

The named page changes from the read land on this branch before it leaves draft.

rowan-claude pushed a commit that referenced this pull request Sep 4, 2026
… constant (#530)

The cold reader's two remaining edits on the second pass, plus the two
statements it asked for.

- The tail, in fixed order after the closure's field, variant and arm names:
  the reserved node-table id, the three blob type ids as bytes, string and
  wstring (§3.1, and #532 for the third), then the name id of EVERY table in
  the closure, not only pointer targets, in the projection's sorted record
  order. The tail is UNCONDITIONAL and the choice is stated: if only pointer
  targets carried a type id, adding a *T to an existing table would move every
  slot after it, and if the node-table and blob ids rode only for a unit with
  a variable class, a unit's first pointer would move the whole tail. A slot
  number is a compile-time constant a generated field header carries, so it
  must not drift under an edit that has nothing to do with it.
- The resolved-form paragraph said "declaration order" where the connection's
  slots are the PROJECTION's order, which sorts records and vocabularies by
  name. Fixed.
- The announcement is a compile-time constant of the unit, so a backend may
  emit Announce and AnnounceMeasure as a constant byte array and its length
  rather than as a walk, and the C++ reference states which it does.
- The past-127-ids vector has a home: test/vocabgen writes tables/vocab, unit
  key vocabdemo, ten tables of thirteen fields, generated on test/cookgen's
  precedent and committed like any other corpus data, because a golden a
  generator has to re-derive is not a golden.

The backenddemo announcement moves with the tail, from twenty-one entries and
188 bytes to twenty-nine and 252: twenty ids in its records, eight in its tail,
and the reserved build-version id at slot 1. Message sizes do not move, every
slot is still one byte, and the payback stays in the second round.
gafferongames added a commit that referenced this pull request Sep 4, 2026
…530)

* spec: the message form, an id table scoped to the connection (#523)

Ruling 10 on #523. A second wire form under the form byte moves the id
table off every message and onto the connection, and the file form does
not change.

SPEC-TABLES.md 3.3, new
- Form byte 2 is the message form: the form byte and the root body, no
  trailer. The body is byte-identical to the file form's, so no kind is
  spent, no payload row moves and no skip rule changes.
- References resolve against the connection's table, per direction. The
  resolution rule itself is unchanged.
- The id table message announces it, and it is an ordinary form-1 file:
  one field, the build version, under the reserved id
  0xFFFFFFFFFFFFFFFE, and a trailer that is the connection's table. The
  most recent announcement from a peer governs that peer's messages.
- The build version is a label and never a gate. 20.5 stands.
- A peer with no table refuses the message by name, no counter moves.
  The two recoveries are the sender's: re-announce, or write the file
  form. A receiver never guesses a table and never falls back on its own.
- A reference past the table is malformed, and in the root body, which
  has no enclosing length, the whole message is.
- Elision, defaults and the read report are unchanged. Retention loads
  unchanged and drops on a form-2 save, with the reason and the two
  answers, a file-form save and a verbatim relay.
- Security: a lying build version buys nothing, a connection table is
  bounded by the receiver at 4096 entries, announcements are capped, a
  reference storm is linear and allocates nothing.
- The packet wire is out of scope, and the page says why: it is
  same-or-refuse on the protocol id, and these peers do not ship
  together.
- The measured effect as the page's own claim, 58 / 225 / 48 against
  106 / 273 / 104, and goldens: the three messages as six named vectors
  in each form, with the unit, the connection and the manifest lines.

SPEC-TABLES.md elsewhere
- 3: the form byte bullet names both forms; the tiny-message paragraph
  names the message form beside the type stream.
- 3, 5, 11: two ids are held back now, not one.

VERSIONING.md
- A section of its own between the wire form and the text form: what the
  form is, what the build version keys and does not gate, the refusal,
  what does not move, retention across the forms, promise 7's owed
  wording, and the two sharp edges.
- Owed before 3.0.0 gains #523.

Nothing is implemented: the subsection states its backend status, and the
form is built in the C++ reference next.

* spec: the announcement is the unit's vocabulary, and SaveRetain refuses (#530)

The two owner rulings from the cold read of #530, and the named changes.

Ruling 1: a peer announces its unit's WHOLE closure vocabulary, once per
connection per direction, in the cook projection's order.
- The table is a pure function of the build version, so "keyed by the build
  version" is literal. The writer's slot numbers are compile-time constants
  and there is no runtime slot lookup on the send path. The receiver
  resolves once.
- The re-announcement state machine is gone: no "most recent governs", no
  amendment on a vocabulary change. A second announcement on a connection
  is refused by name, replaces nothing, and the receiver closes. A build
  change is a new process and a new connection.
- The bound stays 4096 entries. A 500-id unit announces 4 KB once, which is
  the cost of ruling out re-announcement and is stated, not buried.
- The heading says what the table now is.

Ruling 2: SaveRetain under form 2 REFUSES BY NAME and returns -1, on 6.6's
misuse precedent, never a silent drop.

Named changes:
- (a) The body-identity claim was false: a file's first-use slots are not the
  connection's slots. Replaced with identity under RESOLUTION, 6.6's own
  normal form, every reference replaced by the id it names and every length
  recomputed, plus a pinned byte length per vector.
- (b) A refused announcement sets no table, and there is no earlier one to
  keep. The contradiction is gone.
- (c) A reference past the table in the root body follows 3: the body stops,
  malformed counts once, and the fields decoded before it stand. No second
  pass, no message thrown away whole.
- (d) 3.1 now states the rule for BOTH reserved ids, so 3.3 cites a rule that
  exists.
- (e) The surface is named and owed to 11's claimed set on 6.6's precedent:
  TableVocabulary, Announce / AnnounceMeasure / AnnounceRead, LoadMessage /
  MeasureMessage / SaveMessage, four refusal reason values, and Dart's
  spellings. Root dispatch is the application's and message framing is the
  transport's, both stated. The node-table id and every pointer target's type
  id are entries of the announcement, stated.
- (f) A connection is a transport connection, per reliable ordered channel. A
  restart is a new connection with empty tables, a receiver caches nothing
  across connections, and stateless request-response is out of scope by name.
- (g) 3's form-byte pin now uses forms 0, 3 and 0xFF, since 2 is a known form
  with rows of its own. Promise 7 amended in this PR. One word, "key", on
  both pages.
- (h) Four more vectors: a slot at or past 128, per-direction independence, a
  pointered message, and a refused second announcement. The writer's new-id
  question is gone by ruling 1, since an id outside the vocabulary cannot
  occur.

The announcement for the backenddemo unit is 188 bytes, twenty-one entries
(twelve distinct field names, eight variant names, the reserved id), counted
against the compiler's own projection. Message sizes are unchanged at
58 / 225 / 48 / 2 / 27 / 2, and the payback moves into the second round.

* spec: the vocabulary tail is unconditional, and the announcement is a constant (#530)

The cold reader's two remaining edits on the second pass, plus the two
statements it asked for.

- The tail, in fixed order after the closure's field, variant and arm names:
  the reserved node-table id, the three blob type ids as bytes, string and
  wstring (§3.1, and #532 for the third), then the name id of EVERY table in
  the closure, not only pointer targets, in the projection's sorted record
  order. The tail is UNCONDITIONAL and the choice is stated: if only pointer
  targets carried a type id, adding a *T to an existing table would move every
  slot after it, and if the node-table and blob ids rode only for a unit with
  a variable class, a unit's first pointer would move the whole tail. A slot
  number is a compile-time constant a generated field header carries, so it
  must not drift under an edit that has nothing to do with it.
- The resolved-form paragraph said "declaration order" where the connection's
  slots are the PROJECTION's order, which sorts records and vocabularies by
  name. Fixed.
- The announcement is a compile-time constant of the unit, so a backend may
  emit Announce and AnnounceMeasure as a constant byte array and its length
  rather than as a walk, and the C++ reference states which it does.
- The past-127-ids vector has a home: test/vocabgen writes tables/vocab, unit
  key vocabdemo, ten tables of thirteen fields, generated on test/cookgen's
  precedent and committed like any other corpus data, because a golden a
  generator has to re-derive is not a golden.

The backenddemo announcement moves with the tail, from twenty-one entries and
188 bytes to twenty-nine and 252: twenty ids in its records, eight in its tail,
and the reserved build-version id at slot 1. Message sizes do not move, every
slot is still one byte, and the payback stays in the second round.
rowan-claude pushed a commit that referenced this pull request Sep 4, 2026
Ruling 3 on schema#523: the map's storage, walks and measure without the
key and the sort, wire-neutral under the existing array kind and the
wire's 64-bit counts. Plus the four owner rulings and the thirteen named
changes from the cold read of #531.

New §2.9. The declaration is `[]T` and `[]*T`, legal in a table body only,
refused in a `type` body and therefore on the packet wire, and refused as
a union arm. Storage is the map's slot exactly: an `int64` self-relative
reference and an `int32` count, sixteen bytes, with the elements laid by
value in the holder's node extent, lists and maps ordered as one
population pre-order. The wire is kind `14` at the element's own kind with
the live count, so `[]T` and `[..N]T` are the same bytes and a bound is
added or removed without touching a stored file. The text form is a JSON
array with no bound to clamp against.

The owner rulings:

- ERASE EXISTS, by the map's own mechanism, addressed by the element's
  POINTER: dead bit, live count, the four walks skip it. The save-edit
  cycle is the tool's path and a game's inventory removes in slot order,
  which a map by id cannot keep. Retention then restates §6.6's hazard
  honestly: a region's count and elements are ordinary writable memory,
  the hazard applies unchanged, and the builder is not on that path.
- `LoadMeasure`'s `-1` CARRIES A REASON, from the enum ruling 8 gives
  `Open` and `BlockOpen`, one value per clause on §7's own rule, stated
  once in §6.5: `unknown_form`, `count_over_length`,
  `count_over_extent_cap`, `blob_over_size_cap`, `data_cycle`. The name
  is #532's to define, so this page cites it by description and section.
- The index accessor is BOUNDS-CHECKED IN EVERY BUILD, on §2.4's rule:
  the extent came from a file, so there is no undefined-behavior path in
  release.
- A MAP IS NOT A UNION ARM EITHER, refused by name in §2.6 and §11 on the
  same ground `[]T` is: both put their elements in the holder's node
  extent through a placement walk over what the record reaches BY VALUE,
  and an arm is reached by value only when its tag says so, so an arm's
  array would make the extent depend on a discriminant. §2.6's admission
  of a map at an arm predates this PR and is corrected here, because
  §2.9's refusal made it load-bearing. No corpus schema declares one.

The named changes: the two overflow outcomes stated per path, region
against builder, with the agreement claim scoped to what either path
decodes; the `[][]T` fix spelled as a TABLE wrapper, since a `type` body
refuses a list; the four `LoadMeasure` refusals moved to a named unit
test with synthetic counts, because a refusal produces no counters and no
golden can carry two gigabytes; the baseline rule stated as the capacity
fact `bound=` carries when it appears or vanishes, with `elem=`, `type=`
and `kind=` still judging; `array=unbounded` added to §20.2's grammar and
§20.1's fact table, and the arm line's own token set widened to name what
it refuses; the map's descriptor columns documented in §8.1 for the first
time, with `array_bound = 0` as the tell that a field's offset names a
reference rather than the first element, and the list taking them
unchanged; the clamp control pinned above 2^16; `list_migrates` and
`list_erased` added to the corpus; one clause in SPEC.md §1 scoping "no
unbounded collections" to the type wire; the §4.2 and §4.3 cites fixed,
`?[..N]T` distinguished from `[]?T` with `[]*T` and a null slot named as
what serves; `string(N)` and `bytes(N)` dropped from the element-set
sentence, which is now the rule that `[]T` admits exactly what `[..N]T`
admits; and the additions count corrected against the bullets that follow
it.

Specification only, no code. Ports are a named follow-on (§15).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
Ruling 3 on schema#523: the map's storage, walks and measure without the
key and the sort, wire-neutral under the existing array kind and the
wire's 64-bit counts. Plus the four owner rulings and the thirteen named
changes from the cold read of #531.

New §2.9. The declaration is `[]T` and `[]*T`, legal in a table body only,
refused in a `type` body and therefore on the packet wire, and refused as
a union arm. Storage is the map's slot exactly: an `int64` self-relative
reference and an `int32` count, sixteen bytes, with the elements laid by
value in the holder's node extent, lists and maps ordered as one
population pre-order. The wire is kind `14` at the element's own kind with
the live count, so `[]T` and `[..N]T` are the same bytes and a bound is
added or removed without touching a stored file. The text form is a JSON
array with no bound to clamp against.

The owner rulings:

- ERASE EXISTS, by the map's own mechanism, addressed by the element's
  POINTER: dead bit, live count, the four walks skip it. The save-edit
  cycle is the tool's path and a game's inventory removes in slot order,
  which a map by id cannot keep. Retention then restates §6.6's hazard
  honestly: a region's count and elements are ordinary writable memory,
  the hazard applies unchanged, and the builder is not on that path.
- `LoadMeasure`'s `-1` CARRIES A REASON, from the enum ruling 8 gives
  `Open` and `BlockOpen`, one value per clause on §7's own rule, stated
  once in §6.5: `unknown_form`, `count_over_length`,
  `count_over_extent_cap`, `blob_over_size_cap`, `data_cycle`. The name
  is #532's to define, so this page cites it by description and section.
- The index accessor is BOUNDS-CHECKED IN EVERY BUILD, on §2.4's rule:
  the extent came from a file, so there is no undefined-behavior path in
  release.
- A MAP IS NOT A UNION ARM EITHER, refused by name in §2.6 and §11 on the
  same ground `[]T` is: both put their elements in the holder's node
  extent through a placement walk over what the record reaches BY VALUE,
  and an arm is reached by value only when its tag says so, so an arm's
  array would make the extent depend on a discriminant. §2.6's admission
  of a map at an arm predates this PR and is corrected here, because
  §2.9's refusal made it load-bearing. No corpus schema declares one.

The named changes: the two overflow outcomes stated per path, region
against builder, with the agreement claim scoped to what either path
decodes; the `[][]T` fix spelled as a TABLE wrapper, since a `type` body
refuses a list; the four `LoadMeasure` refusals moved to a named unit
test with synthetic counts, because a refusal produces no counters and no
golden can carry two gigabytes; the baseline rule stated as the capacity
fact `bound=` carries when it appears or vanishes, with `elem=`, `type=`
and `kind=` still judging; `array=unbounded` added to §20.2's grammar and
§20.1's fact table, and the arm line's own token set widened to name what
it refuses; the map's descriptor columns documented in §8.1 for the first
time, with `array_bound = 0` as the tell that a field's offset names a
reference rather than the first element, and the list taking them
unchanged; the clamp control pinned above 2^16; `list_migrates` and
`list_erased` added to the corpus; one clause in SPEC.md §1 scoping "no
unbounded collections" to the type wire; the §4.2 and §4.3 cites fixed,
`?[..N]T` distinguished from `[]?T` with `[]*T` and a null slot named as
what serves; `string(N)` and `bytes(N)` dropped from the element-set
sentence, which is now the rule that `[]T` admits exactly what `[..N]T`
admits; and the additions count corrected against the bullets that follow
it.

Specification only, no code. Ports are a named follow-on (§15).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames and others added 7 commits September 5, 2026 02:00
…omments (#523)

Six rulings from #523, specification only.

1. wstring on the table wire: kind 33, `*wstring` under the reserved blob
   id fnv1a64("wstring"), the cooked storage row, the text row, the
   LoadMeasure term, the map-key refusal by name, and the #510 closure
   refusal deleted.
2. Widening on read: an integer kind into a wider one of the same
   signedness, and f32 into f64, decodes exactly under a new `widened`
   counter. Every other pair stays kind_mismatch. The silent class does
   not move.
3. Flags storage stays uint64 in every target, with the reason.
4. float16 declined, kind 34 reserved by name.
5. Open and BlockOpen fill a TableOpenReason beside the null.
6. The text form accepts // and /* */ comments on read and never writes
   them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames
gafferongames marked this pull request as ready for review September 4, 2026 16:06
@gafferongames
gafferongames merged commit dbff2ff into main Sep 4, 2026
1 check passed
@gafferongames
gafferongames deleted the spec-kinds-and-rules branch September 4, 2026 16:07
gafferongames added a commit that referenced this pull request Sep 6, 2026
…mments (#532) (#592)

* spec: widening on read, the refusal reasons, comments and ill-formed text

The `widened` counter is the sixth report member and its status line goes.
`TableRefuseReason` is a NATIVE enum per target, carried on each language's
standard error path, and the C++ reference now carries it on Open, BlockOpen
and LoadMeasure's -1, so the three "OWED, not emitted" lines go with it. The
reason is written on the refusal path only, which is what makes a successful
open cost nothing. Comments are accepted in the text form on read and never
written.

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

* wire: widen on read at every kind-comparison site, and refuse ill-formed text

The oracle counts `widened` where a kind grew since the writer, at a field,
an arm, an array element and a map key, for the two integer ladders and f32
into f64, once per field and once per map. A kind 12 payload that is not
well-formed UTF-8, or carries a zero byte, is damage: the field reads its
declared default, one malformed counts, and a clamp cuts at a code point
boundary. Comments are read in the text form and never written, an unclosed
block comment malformed. Reserved kind 34 is refused as damage by name.

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

* cpp: widening, the refusal reasons and the text rules in the reference

Every unit carries TableRefuseReason, and Open, BlockOpen and LoadMeasure
name the first failing clause in section 7's order, bad_alignment ahead of
both truncated clauses, the base's alignment last because it is the one
clause that reads nothing out of the file. Every block prologue word is read
bytewise so that ordering holds. The widening branch lives inside the
kind-mismatch branch a reader already takes, so a payload under the declared
kind never reaches it. Ill-formed kind 12 text is refused at a field, an arm,
a map key and a *string record, with the clamp at a code point boundary.

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

* conformance: the widened, refusal-reason and comment rows

The report spelling grows a sixth counter, the reports pins move with it, and
the wire fuzzer gains a widening strategy over arms, elements and keys and a
text strategy over every kind 12 position. Two new surfaces, cook-reason and
block-reason, pin one row per reason over the forgery fixtures. The json
hostile tree gains the comment cases, accepted and refused.

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

* tables: the fuzz leg's reason parameter, the bench tree and the reports pins

Every LoadMeasure now takes the reason out-parameter, so the wire fuzzer's
leg carries the unit's own TableRefuseReason as a template parameter. The
length negative control changes what it quotes: the content rule reads a kind
12 payload as it arrives, over the whole of L, so a leg that lost room(len)
walks off the buffer and dies on the mutant where it used to clamp a
neighbour's bytes. The arm evolution row moves from one kind_mismatch to one
widened.

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

* tables: the block's caller clauses in C++, and the zero-cost gate's own prefix

BlockOpen's truncated and unaligned_base are pinned in test/tables, beside
the match that writes nothing, because each is a clause over the buffer the
CALLER passed and no patched word of an image reaches it: the cross-language
battery keeps its eleven rows and every port stays green on them. The
zero-cost scan sanctions TableRefuseReason by its whole spelling, since
TableRef is a prefix of it and the extraction takes the longest alternative.

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

* tables: pin the reason on a variable root's measure and the block's own clauses

A form byte this build does not carry makes SceneLoadMeasure -1 with
unknown_form, and a form it does carry writes nothing at all beside the size.
BlockOpen's own readings are pinned where each is actually reachable: a null
base and a length under the prologue by their values, a length whose arrays
no longer fit as bad_layout, and the base's alignment over a WHOLE image
copied to an unaligned address, which is what it takes to reach the last
clause. The remaining widened lines in USAGE, VERSIONING and COMPETITION go.

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

* tables: re-pin the C++ source goldens, and the Go gates that read the enum

The generated C++ sources gain the refusal enum, the widening helpers and the
text helpers, so testdata/golden/tables is re-pinned deliberately. NOT ONE
WIRE GOLDEN MOVES: this changes what a reader does with bytes it would have
skipped, never what a writer puts on the wire. The Go zero-cost test strips
TableRefuseReason before its scan for the same reason the Makefile's does,
and the pack corpus reads the sixth counter.

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

* harness: one markText for the two kind 12 frames

The arm's own copy of the backward search goes, and the search itself reads
as slices.Backward. Nothing about which lengths the text pass aims at moves.

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

* build: the block maximum control brackets the check where it now sits

The declared-maximum comment moved ahead of its check when BlockOpen started
naming its refusals, so the sabotage's address range ran to the end of the
file. It brackets the comment and the check in the order they are written.

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

* build: the cook open-cost walk sabotage anchors on the refusing return

TableCookOpen's last clause names its reason now, so the sabotage's anchor
follows it. What the control plants and what it proves are unchanged.

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

* text: a byte that is not a code point reads as U+FFFD

The text form could put a raw ill-formed byte into storage, and the wire it
then wrote was one this PR's reader calls malformed. Section 16.3's rule now
runs at the point the defect enters, in the engine and in the C++ walk both:
a byte in a string body that is not part of a well-formed sequence reads as
one U+FFFD, exactly as a lone surrogate escape already did, so storage the
text form built is storage the wire can carry. The str-illformed-at-bound row
moves to one clamped, because three bytes where one stood pass the bound.

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

* text: the tests and goldens follow the replacement to the read

The two tests that pinned a raw ill-formed byte riding into storage now pin
what replaces it, and the text form is byte-stable from the first lap rather
than after one. NOT ONE WIRE GOLDEN MOVES.

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

* wire: §4's widening row on the message form, at the field, the arm, the element and the map key

§3.3 holds §4's evolution rows to the file form's word, and the widening
row was the one the message reader did not carry: an announced kind below
this reader's on the same ladder was met as a kind mismatch and skipped.

Both engines now take it. The payload already rode at the width the
announcement states, so the branch sits inside the mismatch branch the
reader already took and the matching path pays nothing. The two shapes
the announced width does not settle on its own are handled beside it:
f32 into f64 carries a NaN's payload on the bits rather than through the
hardware conversion, and a narrow integer into a 128-bit declaration is
sign or zero extended before this reader's own bound meets it.

A map counts ONE widened at its key however many entries carry it, and an
array ONE for the field however many elements it holds.

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

* docs and tests: the block padding reading, the blob size cap, the clause order and the prose pass

The used extent is DERIVED from the arrays, so BlockOpen reads every triple
before it compares the extent to the caller's bytes. §19.2 listed the two the
other way round in both places it lists them, and the page now says the order
the arithmetic forces and why.

That clause's own reading joins the cross-language surface through the extent
column rather than a patched word: block_extent_padding claims one byte less
than block_render's used extent takes in rounding, on the precedent
cook_claim_one_short set. unaligned_base stays C++-only, because it is a
clause over the ADDRESS a caller passes and a manifest row hands a driver a
path. The C++ BlockOpen gate gains the reading beside it.

blob_over_size_cap is reachable without a four gigabyte input:
<Root>NodeStorage is a pure function of a type id and a length, so the test
hands it one past the cap and one at it.

data_cycle is the one value no call writes today. The ruling is that it gets a
carrier rather than losing its name: Measure and CookMeasure take the same
out-parameter LoadMeasure takes, and threading it through the pack walk is a
named follow-on.

The file measure answers unknown_form for any byte that is not the file form,
form 2 included, and the page now says so beside the value.

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

* docs and harness: the message form's widening row and where its instrument stops

The wire fuzzer's widen pass has no purchase on form 2: a body carries no
kind byte, so the kind a reader compares is the announcement's, which both
engine and leg read from their own unit. A widening mutant would have to hand
both sides a forged announcement and the driver's protocol carries a body.
The row is held by a peer whose declaration has moved instead, and the page
and the harness both say so.

The block padding divergence is filed as schema#598 and the manifest comment
names the five ports and cross-references #387.

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

* conformance: five drivers honor a claim shorter than the image

Rust, C#, Java, JavaScript and Dart raised a short extent claim to the
image's own length, so BlockOpen's truncated clause over the caller's bytes
was unreachable in five of nine languages and block_extent_padding answered
open there against the reference's refuse. Each now allocates the claim and
copies only what fits, which is what the comment in each already said.

C# was copying bytes.Length into a buffer sized by the claim, and Dart was
doing the same through setRange, so both carried a real overrun behind the
clamp.

Every port refuses the row now, and the manifest comment records what
happened rather than a divergence that no longer stands (schema#598).

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

* prose: plain sentences in the round's own comments and page text

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

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant