Skip to content

fixed form: the hash pre-pass, so REFUSE is total on a file of many records - #971

Draft
gafferongames wants to merge 3 commits into
fixed-table-formfrom
rowan/refuse-is-total-prepass
Draft

gafferongames wants to merge 3 commits into
fixed-table-formfrom
rowan/refuse-is-total-prepass

Conversation

@gafferongames

Copy link
Copy Markdown
Contributor

The ruling

§5.3's per-record hash check sat inside the landing loop, first thing. For a file of ONE record that is
the same answer. For a file of MANY it is not: a forged record at index 7 of 64 was found only after records
0 through 6 had been prefilled, run and bounded — the caller's storage was written, the counters had moved,
and "REFUSE is total" plus §5.3's joint-answer table were false sentences about the very read they
describe
(C++ reference internal/codegen/cpptable/fixedform.go ~571; every leg mirrored it).

The fix is the order, not the check. A new step 10b walks the whole tail once, comparing each
record's leading eight bytes to the selected hash and refusing no_layout on the first that differs; step
11 now has no hash check at all
. A file is ONE layout by construction, so the second walk only ever
changes the answer on a corrupt or hostile file.

The doc step choice: 10b, and nothing is renumbered

The brief offered 8b or a fold into step 9. It is 10b, for three reasons:

Per leg

leg emitted load gate state
C++ (the reference) step 10b pre-pass added, in-loop check removed make tables-fixedform GREEN — and the new fixture is RED without the change (3 assertions fail), green with it
C (the twin) identical, scan declared C89-style at the top make tables-c-fixedform GREEN
Go pre-pass over the tail slice, in-loop check removed make tables-go-fixed-form GREEN — layout and the whole 80915-byte file still match the C++ reference byte for byte
Rust pre-pass over rest, NoBlock by name make tables-rust-fixedform GREEN
Dart pre-pass over the ByteData view make tables-dart-fixed-form GREEN
JS pre-pass on the two 32-bit halves make tables-js-fixed-form GREEN
Java pre-pass over 8 + bodySize make tables-java-fixedform RED, AND RED AT BASE TOO — see below
C# pre-pass over a ReadOnlySpan walk make tables-cs-leg GREEN — 61s
Elixir unchanged_fixed_split already IS the pre-pass: it walks the whole tail and holds every record's hash before the with that runs the plan can open. Confirmed by reading, left alone, and its comments now name step 10b as what that split is make tables-elixir-fixed-form GREEN — "the Elixir leg matches the C++ reference"

Java is red on a check this PR does not touch

test/java-fixedform/src/Main.java:693, case 2b — "a header that names a layout it does not carry" — fails.
It fails identically with internal/codegen/javatable/fixedform.go reverted to the base commit, so it is
a pre-existing red on fixed-table-form's tip (199fa5c) and not a regression here. The Java emitter's own
change is in; the gate cannot go green until 2b is fixed, which belongs to another PR.

Gates run

  • make tables-fixedform — green, 21s
  • make tables-c-fixedform — green, 4.3s
  • make tables-go-fixed-form / tables-rust-fixedform 9.1s / tables-dart-fixed-form 6.5s /
    tables-js-fixed-form 2.5s / tables-cs-leg 61s / tables-elixir-fixed-form 2.7s — all green
  • make tables-java-fixedform — red on case 2b, red at base too
  • go test ./compiler/ ./internal/codegen/... -run 'Fixed|NoLayout|Prepass|Paired'green, 60s wall
    (every leg's fixedversioning_test.go probe harness runs its leg's real runtime against the regenerated
    load, TestPairedHeadersAgree included)
  • gofmt -l . — empty
  • regen sweep: rm -f bin/schema before every build; goldens re-pinned and the hunks read

One bench note, not a code problem: the child kit's env.sh exports SERIALIZE_RS and SERIALIZE_GO as
absolute paths while make/rust.mk and the Go rules prepend ../../../, which yields a doubled path and a
Cargo/go.mod that cannot resolve. The Rust gate needs SERIALIZE_RS=../serialize.rs; a polluted
generated/bench/paired/go/go.mod was reverted and is not in this PR.

Tests

  • test/tables/versioning_lists.cpp — new refuse_writes_nothing_case(): 64 records from the OLD build,
    record 7's hash word inverted, the header's hash untouched, read by the NEW build so a compiled plan
    with a nonzero fill (Vec.w = 88) is in hand, over storage poisoned 0x5A in every byte of all 64
    rows. Asserts n == -1, reason == no_layout, malformed false, layout_hash untouched, every counter
    0, and every poisoned byte still 0x5A — plus controls that record 0 and the header are good, so the
    row cannot pass vacuously.
  • test/tables/fixedform_dump.cpp — writes the row's bytes as nolayout_nested_append.bin, the same
    64-record, record-7 file, so every leg has one fixture. The manifest is 67 rows and 67 files.

Still owed

Each other leg's probe harness (internal/codegen/*/fixedversioning_test.go) should gain the same
64-record/record-7 case against the new corpus file. Their existing harnesses all pass, and the corpus file
is now there for them; the seven per-language probe bodies did not fit this window.

Docs

FIXED-FORM-ALGORITHM.md §5.3's pseudocode, its condition table, its "REFUSE is total" paragraph and §5.9
row 8; §5.4 gains the row saying no counter moves on any refusal by name; SPEC-TABLES.md §3.4's load
description and its no_layout registry row; FIXED-FORM-VERSIONING-TESTS.md's refuse_writes_nothing row
becomes the 64-record, record-7 file with the poison asserted byte for byte.

🤖 Generated with Claude Code

…ecords

§5.3's per-record hash check sat INSIDE the landing loop, first thing, and for
a file of ONE record that is the same answer. For a file of MANY it is not: a
forged record at index 7 of 64 was found only after records 0 through 6 had
been prefilled, run and bounded — so the caller's storage was written, the
counters had moved, and "REFUSE is total" plus §5.3's joint-answer table were
false sentences about the very read they describe.

The fix is the ORDER, not the check. A new step 10b walks the whole tail once,
comparing each record's leading eight bytes to the selected hash and refusing
`no_layout` on the first that differs; step 11 now has no hash check at all.
Step 10b sits AFTER step 10 so `batch_too_large` still answers first, which is
where every leg already had it. A file is ONE layout by construction, so the
second walk only ever changes the answer on a corrupt or hostile file.

THE STEPS ARE NOT RENUMBERED. PR #957's seams pass pinned the numbers for some
twenty cross-references, so the new step is 10b and 1 through 11 keep theirs.

The C++ reference first, the C twin identically, then every leg's emitted load:
Go, Rust, Dart, JS, Java, C#. Elixir already had the pre-pass by construction —
`_fixed_split` walks the whole tail and holds every hash before the `with` that
runs the plan ever opens — so its code is unchanged and its comments now name
step 10b as what that split is.

The reference fixture is new and it was RED before this change and green after:
a 64-record file from the OLD build with record 7's hash word inverted and the
header's hash untouched, read by the NEW build so a compiled plan with a NONZERO
fill (`Vec.w = 88`) is in hand, over storage poisoned 0x5A in every byte of all
64 rows. n == -1, reason no_layout, malformed false, layout_hash untouched,
every counter 0, and every poisoned byte still 0x5A. Without the pre-pass three
of those assertions fail. The dump tool writes the row's bytes as
`nolayout_nested_append.bin` so every leg has the same 64-record fixture; the
manifest is 67 rows and 67 files.

Docs: §5.3's pseudocode, its condition table, its "REFUSE is total" paragraph
and §5.9 row 8; §5.4 gains the row saying no counter moves on any refusal by
name; SPEC-TABLES §3.4's load description and its `no_layout` registry row;
FIXED-FORM-VERSIONING-TESTS.md's `refuse_writes_nothing` row becomes the
64-record, record-7 file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames and others added 2 commits September 11, 2026 09:21
#954 (Elixir: union prefill, guard width, batch_too_large, the writer's
count bound) landed on the tip while this branch was open, and both
touched `_fixed_run` in internal/codegen/elixirtable/fixedelixir.go.
The conflict is resolved keeping BOTH: #954's code in full — the
`bcap` parameter, the `{census_u, census_k} = census` destructure and
the `R.batch_within` clause that answers `batch_too_large` before a
record is split off — under #971's words, the comment naming §5.3 STEP
10b's HASH PRE-PASS that `_fixed_split` already performed on this leg.
Step 10 stays the batch check and 10b the pre-pass, which is the order
docs/FIXED-FORM-ALGORITHM.md states: `batch_too_large` answers first.

Goldens re-pinned after the merge: the C, C++ and C# fixed-load hunks
are #971's pre-pass walking the tail before the landing loop, and the
paired Elixir hunk is comments only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
javatable/fixedform.go: the tip's #920 (the file's hash selects the lineage
entry, recordSize from the lock, no declaredHash tautology) and this PR's
hash pre-pass (every record held before any lands) are both kept: the
pre-pass now compares against fileHash and steps by recordSize.

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