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

@@ -24,6 +24,31 @@ are pre-implementation refinements to types that do not yet exist on
`main`; the ADR-011 decision (a compiled `ReadPlan` for packed reads)
is unchanged.
**Addendum — field-disc union wire convention (2026-09-02, review #006
H3):** the packed-mode wire layout for a field-name-discriminator
TUnion is **shared-then-variant**: the union's declared `fields` (the
discriminator field + any shared fields) occupy the union's start
offset in declaration order, and the selected variant's fields follow
immediately after all shared fields. All three packed-mode consumers
now implement this one convention: the reader and materializer already
walked `shared` then the variant (the `shared` sub-plan shape above);
`LayoutBuilder` was corrected in the same pass — it previously laid out
only the selected variant, disagreeing with the read side on span and
field positions (review #006 H3 item 1). The convention requires that
a variant **must not re-declare** the discriminator field or any
shared field — `BastUnion::parse` enforces this at parse time (also:
the discriminator field must be declared in `fields`, and `fields`
must not contain duplicate names), so the shared walk and the variant
walk cover disjoint fields and the wire has exactly one copy of each
shared byte. Schemas whose variants redeclared shared fields were
ambiguous under the old split-convention behavior and are rejected
rather than given a silent meaning; this is a **breaking wire-format
constraint** for any 0.2.0-era schema that relied on re-declaration,
announced with the 0.3.x series. `DiscriminatorPlan::Field`'s disc
read is at the disc field's position within the shared walk (the
materializer's position-correct behavior, review #006 H3 item 2); the
reader's plan-walk reads it there too.
## Context
Review #004 (`docs/reviews/004-performance-review.md`) measured the
+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