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
Draft
gafferongames wants to merge 3 commits into
gafferongames wants to merge 3 commits into
Conversation
…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>
#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_layouton the first that differs; step11 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
8bor a fold into step 9. It is10b, for three reasons:n, which step 10 computes;batch_too_largestill answers first — that is the precedence every legalready shipped, and moving it would be a second, unasked-for behaviour change;
10bavoids renumbering exactly as8bwould. Steps 1 through 11 keep their names, so PR seams: the form byte first, the hash as computed, the reserved bytes, the bound record #957'sseams pass and its ~20 cross-references are untouched.
Per leg
make tables-fixedformscandeclared C89-style at the topmake tables-c-fixedformmake tables-go-fixed-formrest,NoBlockby namemake tables-rust-fixedformByteDataviewmake tables-dart-fixed-formmake tables-js-fixed-form8 + bodySizemake tables-java-fixedformReadOnlySpanwalkmake tables-cs-leg_fixed_splitalready IS the pre-pass: it walks the whole tail and holds every record's hash before thewiththat runs the plan can open. Confirmed by reading, left alone, and its comments now name step 10b as what that split ismake tables-elixir-fixed-formJava 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.goreverted to the base commit, so it isa pre-existing red on
fixed-table-form's tip (199fa5c) and not a regression here. The Java emitter's ownchange is in; the gate cannot go green until 2b is fixed, which belongs to another PR.
Gates run
make tables-fixedform— green, 21smake tables-c-fixedform— green, 4.3smake tables-go-fixed-form/tables-rust-fixedform9.1s /tables-dart-fixed-form6.5s /tables-js-fixed-form2.5s /tables-cs-leg61s /tables-elixir-fixed-form2.7s — all greenmake tables-java-fixedform— red on case 2b, red at base toogo test ./compiler/ ./internal/codegen/... -run 'Fixed|NoLayout|Prepass|Paired'— green, 60s wall(every leg's
fixedversioning_test.goprobe harness runs its leg's real runtime against the regeneratedload,
TestPairedHeadersAgreeincluded)gofmt -l .— emptyrm -f bin/schemabefore every build; goldens re-pinned and the hunks readOne bench note, not a code problem: the child kit's
env.shexportsSERIALIZE_RSandSERIALIZE_GOasabsolute paths while
make/rust.mkand the Go rules prepend../../../, which yields a doubled path and aCargo/go.mod that cannot resolve. The Rust gate needs
SERIALIZE_RS=../serialize.rs; a pollutedgenerated/bench/paired/go/go.modwas reverted and is not in this PR.Tests
test/tables/versioning_lists.cpp— newrefuse_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 poisoned0x5Ain every byte of all 64rows. Asserts
n == -1,reason == no_layout,malformedfalse,layout_hashuntouched, every counter0, and every poisoned byte still0x5A— plus controls that record 0 and the header are good, so therow cannot pass vacuously.
test/tables/fixedform_dump.cpp— writes the row's bytes asnolayout_nested_append.bin, the same64-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 same64-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.9row 8; §5.4 gains the row saying no counter moves on any refusal by name;
SPEC-TABLES.md§3.4's loaddescription and its
no_layoutregistry row;FIXED-FORM-VERSIONING-TESTS.md'srefuse_writes_nothingrowbecomes the 64-record, record-7 file with the poison asserted byte for byte.
🤖 Generated with Claude Code