Fix H3: field-disc union wire convention — shared-then-variant (review #006)

Decision (recorded as an ADR-011 addendum): the packed-mode wire layout
for a field-name-discriminator TUnion is shared-then-variant — the
union's declared `fields` (disc + shared fields) first, then the
variant's own fields. Reader and materializer already implemented this;
LayoutBuilder was corrected from variant-only layout.

Enforcement in BastUnion::parse (the choke point every consumer
inherits — union roots at BastDoc::new, referenced unions at
resolve_ref):
- discriminator field must be declared in `fields`
- `fields` must not contain duplicate names
- variants must not re-declare shared fields (checked inline and
  through $ref resolution — parse chain now threads the doc root)
- the discriminator field must be the FIRST entry in `fields` (the
  reader reads the disc at the union start; a later position made it
  dispatch on the wrong bytes — H3 item 2, probe-verified)

Schemas relying on the old variant-only builder convention (variants
re-declaring shared fields) are rejected with a clean Schema error
naming the convention. Breaking for 0.2.0-era re-declaring schemas;
announced with 0.3.x.

- L5: FieldValue::Union::variant_start doc now states per-kind
  semantics (byte-disc: union_start + disc.offset + disc.size;
  field-disc: after the shared walk).
- L6: roundtrip test added (poc_roundtrip.rs) — LayoutBuilder write →
  SequentialReader read → materialize_packed → validate_bytes over a
  field-disc union with a second shared field and non-redeclaring
  variant; pins event.type@0/seq@1/handle@5, total 10.
- ADR-011: Status-block addendum recording the convention decision,
  the no-re-declare rule, and the breaking-constraint note.
- Review #006 updated: H3/L5/L6 resolution blocks, resolution log,
  recommended order.

Verified: 488 tests green (410+17+34+15+12, 2 pre-existing ignored),
clippy -D warnings clean, wasm32 build green, cargo doc zero warnings.
This commit is contained in:
glm-5.3-flash committed 2026-09-02 18:37:51 +00:00
1 parent 2d166f567b
commit 05a2a42983
7 files changed
+561 -44

No files matched your search

+110 -7
View File
@@ -1,5 +1,5 @@
---
status: in-progress (H1, L1 resolved 2026-09-02)
status: in-progress (H1, L1, H3, L5, L6 resolved 2026-09-02)
last_updated: 2026-09-02
reviewed_artifacts:
- src/read_plan.rs
@@ -95,9 +95,9 @@ sub-types, `fingerprint()` methods; `Hash` on `Endian`/
| Severity | Count | Status |
|----------|------:|--------|
| High | 3 (H1, H2, H3) | H1 resolved 2026-09-02 |
| High | 3 (H1, H2, H3) | H1, H3 resolved 2026-09-02 |
| Medium | 4 (M1, M2, M3, M4) | open |
| Low | 6 (L1–L6) | L1 resolved 2026-09-02 |
| Low | 6 (L1–L6) | L1, L5, L6 resolved 2026-09-02 |
| Nit | 2 (N1, N2) | open |
The three Highs are adversarial-input crashes (H1, H2) and a
@@ -117,6 +117,13 @@ them became materially easier to hit with the 0.3.0 surface.
pre-existing ignored), clippy `-D warnings` clean, wasm build green.
The fix session also surfaced a new Nit (N2, unbounded `align`
annotations) — added below.
- **H3 + L5 + L6 (2026-09-02):** resolved in one commit — see the
"Resolution (2026-09-02)" block at the end of the H3 finding. Wire
convention decided (shared-then-variant, re-declaration forbidden,
disc field must be first), enforced at parse, ADR-011 addendum
written, L5 doc added, L6 roundtrip test added. 488 tests green,
clippy `-D warnings` clean, wasm build green, `cargo doc --no-deps`
zero warnings.
---
@@ -407,6 +414,78 @@ undocumented and untested end-to-end:
ADR-003 §4, and add the write→read→validate roundtrip test through a
field-disc union (packed mode) — see L6.
**Resolution (2026-09-02) — decision taken: shared-then-variant,
re-declaration forbidden.** Both recommendations accepted as
recommended:
1. **Builder now lays out `shared` then the variant**
(`walk_field_discriminator_union`): it walks the union's declared
`fields` first (discriminator field + shared fields), then the
variant struct's own fields — matching the reader/materializer
walk and ADR-011's `shared` plan design. The reader and
materializer needed no changes: they already implemented the
chosen convention.
2. **Enforcement at parse** (`BastUnion::parse`, the choke point every
consumer inherits — a `$defs` union is parsed at `BastDoc::new` for
a union root and at `resolve_ref` for a referenced union):
- the discriminator field must be declared in `fields` (previously
caught only by `compile_union`/tunion at compile/dispatch);
- `fields` must not contain duplicate names;
- no variant may re-declare the discriminator field or any shared
field (variant structs are checked inline and through `$ref`
resolution — the parse chain now threads the doc root so the
variant def's fields are visible at parse time).
Schemas that relied on the old variant-only builder convention
(variants re-declaring shared fields) are rejected with a clean
`Schema` error naming the convention and the offending field. This
is a breaking constraint for 0.2.0-era re-declaring schemas,
announced with the 0.3.x series (breaking changes are confined to
rejection of previously-ambiguous schemas; no accepted schema's
layout changes — the builder's *output* changes only for schemas
that previously produced reader↔builder-disagreeing bytes).
3. **Recorded**: ADR-011 Status block gained the "field-disc union
wire convention" addendum (shared-then-variant, no-re-declare,
breaking-constraint note). `FieldValue::Union.variant_start`'s doc
now states the per-kind semantics (L5): byte-disc = `union_start +
disc.offset + disc.size` (honors the declared displacement),
field-disc = after the whole `shared` walk.
4. **L6 roundtrip test added**
(`tests/poc_roundtrip.rs::field_disc_union_roundtrip_build_read_materialize_validate`):
one schema — union `fields: [type: uint8, seq: uint32]`, variant
`Read { handle: uint32 }` (non-redeclaring) — driven write
(LayoutBuilder + data_access) → read (`SequentialReader`: asserts
`discriminator == "1"`, `variant_start == 5`) → materialize
(`materialize_packed`: asserts `type`, `seq`, `handle`,
`__discriminator` in the object) → validate (`validate_bytes` ok).
The builder-side assertions pin `event.type@0, event.seq@1,
event.handle@5, total 10` — the exact cross-consumer positions H3
showed were unguarded.
5. **H3 item 2 (reader vs materializer disc position) resolves
structurally**: the reader reads the disc field at `offset` — the
union start — via `plan_discriminator_string_value(disc_field,
buffer, offset, …)`. With re-declaration forbidden and the disc
read at the union start, the two agree *only while the disc field
is the first shared field*. The materializer walks all shared
fields and captures the disc at its real position. For a non-first
disc field the reader would still dispatch on the wrong bytes —
so the same parse rule now also requires (via the existing
`field_index` lookup in `compile_union`) that non-first disc
fields are rejected: `compile_union` finds the disc at its index;
the reader's position-blind read makes index ≠ 0 unsupportable.
Enforced in `BastUnion::parse` as "discriminator field must be the
first entry in `fields`" (a `Schema` error otherwise), which keeps
the reader's fast path (disc at union start) correct by
construction and preserves the materializer's order-walk as the
general form.
Verified: 487 tests green (409 + 15 + 34 + 14 + 12 + 3 error-path
suites), clippy `-D warnings` clean, wasm build green, `cargo doc
--no-deps` zero warnings.
### M1. The ADR-006 check misses non-final inline `Record` fields in aligned mode
**Files**: `src/offset_map.rs:235-255` (the check),
@@ -638,6 +717,8 @@ pattern to copy.
**File**: `src/sequential_reader.rs:84-91`
**Status**: resolved 2026-09-02 (with H3).
**Problem**: for byte-offset discriminators, `variant_start` is
`abs_offset + disc_size` — an absolute offset computed from the union
start plus the *declared* `disc.offset` (which may be nonzero, so the
@@ -653,12 +734,21 @@ against `FieldValue::Union` alone can get this wrong silently. One
paragraph on the variant fixing the per-kind semantics (and the H3
convention once decided) closes it.
**Resolution (2026-09-02):** the `variant_start` doc now states both
per-kind formulas explicitly (byte-disc: `union_start + disc.offset +
disc.size`, honoring the declared displacement; field-disc: after the
whole `shared` walk, which under the shared-then-variant convention is
exactly where the variant's own fields begin). See the H3 resolution
block for the convention decision.
### L6. No write→read→validate roundtrip test for field-disc unions
**Files**: `tests/poc_roundtrip.rs` (no field-disc case at all),
`tests/tunion_dispatch.rs` (tunion-level only), the builder tests
(write-side only), the reader tests (read-side only)
**Status**: resolved 2026-09-02 (with H3).
**Problem**: every field-disc union test lives on one side of the
wire. The builder tests assume the variant-only layout
(`layout_builder.rs:1221-1334`); the reader/materializer tests assume
@@ -672,6 +762,19 @@ roundtrip test (build with `LayoutBuilder` → feed the buffer to
H3 immediately. This is the highest-value single test in the review —
add it with (or before) the H3 fix.
**Resolution (2026-09-02):**
`tests/poc_roundtrip.rs::field_disc_union_roundtrip_build_read_materialize_validate`
— exactly the test this finding asked for: union `fields: [type:
uint8, seq: uint32]` (disc + a second shared field), variant `Read {
handle: uint32 }` (non-redeclaring, numeric mapping keys `"1"`/`"2"`
matching the uint8 disc). Write with `LayoutBuilder` +
`data_access` (asserting `event.type@0, event.seq@1, event.handle@5`,
total 10), read with `SequentialReader::read_next` (asserts
`discriminator == "1"`, `variant_start == 5`), materialize with
`materialize_packed` (asserts `type`/`seq`/`handle`/`__discriminator`
in the object), and `engine.validate_bytes` on the same buffer. This
is the test that would have caught every part of H3.
### N1. Field-discriminator kind support differs between `tunion` and the reader
**Files**: `src/tunion.rs:136-164` (supports `String`/`Uint8`/`Enum`),
@@ -756,8 +859,8 @@ Worth recording, because the findings shouldn't eclipse it:
1. ~~**H1** — release-blocking~~ **resolved 2026-09-02** (with L1;
see the resolution block on the finding).
2. **H3** — needs a convention *decision* before code: write the
addendum, enforce it, add L6's roundtrip test in the same session.
2. ~~**H3**~~ **resolved 2026-09-02** (with L5 + L6; see the
resolution block on the finding).
3. **H2** — shared walk guard (or the minimum doc note if the full
guard is judged too invasive for 0.3.x), plus the cyclic-schema
tests for all three walkers.
@@ -768,8 +871,8 @@ Worth recording, because the findings shouldn't eclipse it:
and deserves its own session.
7. **L1–L6, N1, N2** — opportunistic, folded into whichever session
touches the relevant file (L6 is the exception — it belongs with
H3; N2 pairs naturally with any bast/bast_meta session, L1 already
done with H1).
H3; N2 pairs naturally with any bast/bast_meta session; L1 done
with H1; L5/L6 done with H3).
## Notes