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

+107
View File
@@ -603,4 +603,111 @@ fn tunion_byte_offset_discriminator_size_lookup() -> Result<(), AlkTypeError> {
assert_eq!(tunion::discriminator_size(u16_union)?, 2);
assert_eq!(tunion::discriminator_size(u32_union)?, 4);
Ok(())
}
// ---------------------------------------------------------------------------
// L6 (review #006): the single roundtrip test through a field-disc union.
// Write with LayoutBuilder → read with SequentialReader → materialize →
// validate_bytes on the engine — one schema, all three packed-mode
// consumers, so the H3 wire-convention split can never reappear silently.
//
// Fixture shape: the union's `fields` carry the discriminator (`type`,
// uint8) *and* a second shared field (`seq`, uint32); the variant
// (`Read`) declares only its own field (`handle`) — it does NOT
// re-declare the discriminator or any shared field (forbidden since the
// ADR-011 addendum). The disc field is a uint8, so the mapping keys are
// stringified integers ("1" = read). The builder lays out
// shared-then-variant:
// type@0 (1B), seq@1 (4B), handle@5 (4B) — total 9.
// ---------------------------------------------------------------------------
#[test]
fn field_disc_union_roundtrip_build_read_materialize_validate() -> Result<(), AlkTypeError> {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"endian": "little",
"fields": [
{ "name": "event", "kind": { "$ref": "#/$defs/Event" } },
{ "name": "trailer", "kind": "uint8" }
]
},
"Event": {
"kind": "union",
"discriminator": { "kind": "field", "name": "type" },
"fields": [
{ "name": "type", "kind": "uint8" },
{ "name": "seq", "kind": "uint32" }
],
"mapping": {
"1": { "$ref": "#/$defs/Read" },
"2": { "$ref": "#/$defs/Write" }
}
},
"Read": {
"kind": "struct",
"fields": [ { "name": "handle", "kind": "uint32" } ]
},
"Write": {
"kind": "struct",
"fields": [ { "name": "handle", "kind": "uint32" } ]
}
}
});
// --- Write side: LayoutBuilder (shared-then-variant layout) --------
let builder = LayoutBuilder::new(&root, "S")?;
let layout = builder.build(&var_sizes(&[("event.__variant", 0)]))?;
assert_eq!(layout.total_size(), 9 + 1, "shared(1+4) + variant(4) + trailer(1)");
let handle_pos = layout.get("event.handle").expect("event.handle");
assert_eq!(handle_pos.offset, 5, "variant fields start after shared (type@0, seq@1)");
let disc_pos = layout.get("event.type").expect("event.type");
assert_eq!(disc_pos.offset, 0);
let seq_pos = layout.get("event.seq").expect("event.seq");
assert_eq!(seq_pos.offset, 1);
let mut buffer = vec![0u8; layout.total_size()];
data_access::write_u8(&mut buffer, 0, 1, "event.type")?; // mapping key "1"
data_access::write_u32(&mut buffer, 1, 77, "event.seq", Endian::Little)?;
data_access::write_u32(&mut buffer, 5, 4242, "event.handle", Endian::Little)?;
data_access::write_u8(&mut buffer, 9, 55, "trailer")?;
// --- Engine (packed) + reader + materializer + validator -----------
let engine = AlkTypeEngine::compile(&root, "S", LayoutMode::Packed, None)?;
// validate_bytes (materializer + ValidationPlan) accepts the buffer.
engine.validate_bytes(&buffer)?;
// SequentialReader: the union field reports the disc value and the
// variant start (after the shared walk).
let mut reader = engine.sequential_reader().expect("packed mode has reader");
let (name, value) = reader.read_next(&buffer)?.expect("event");
assert_eq!(name, "event");
let (disc, variant_start) = match &value {
FieldValue::Union { discriminator, variant_start } => (discriminator.clone(), *variant_start),
other => panic!("expected Union, got {other:?}"),
};
assert_eq!(disc, "1", "uint8 disc value 1 stringifies to the mapping key");
assert_eq!(variant_start, 5, "variant starts after the shared walk");
let (name, value) = reader.read_next(&buffer)?.expect("trailer");
assert_eq!(name, "trailer");
assert_eq!(value, FieldValue::U8(55));
// materialize_packed through the engine's plan: shared fields land
// in the object, the variant's handle flattens in.
let plan = engine_sequential_plan(&root, "S")?;
let value = alktype::materialize::materialize_packed(&plan, &buffer)?;
assert_eq!(value["event"]["type"], json!(1));
assert_eq!(value["event"]["seq"], json!(77));
assert_eq!(value["event"]["handle"], json!(4242));
assert_eq!(value["event"]["__discriminator"], json!("1"));
assert_eq!(value["trailer"], json!(55));
Ok(())
}
fn engine_sequential_plan(root: &serde_json::Value, name: &str) -> Result<std::sync::Arc<ReadPlan>, AlkTypeError> {
Ok(std::sync::Arc::new(ReadPlan::compile(root, name)?))
}