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

+95 -25
View File
@@ -75,7 +75,7 @@ impl BastDoc {
/// access time.
pub fn new(root: &Value, root_name: &str) -> Result<Self, AlkTypeError> {
let raw_root_def = Self::lookup_def_raw(root, root_name)?;
let root_def = BastDef::parse(raw_root_def, root_name, "")?;
let root_def = BastDef::parse(raw_root_def, root_name, "", root)?;
let doc = Self {
root: root.clone(),
root_name: root_name.to_string(),
@@ -111,7 +111,7 @@ impl BastDoc {
/// [`BastRef`]s and resolve on demand.
pub fn resolve_ref(&self, r: &BastRef) -> Result<BastDef, AlkTypeError> {
let raw = self.lookup_def(r.name())?;
BastDef::parse(raw, r.name(), "")
BastDef::parse(raw, r.name(), "", &self.root)
}
/// Resolve a [`BastType`] that may be a [`Ref`](BastType::Ref) into
@@ -213,7 +213,7 @@ impl BastDef {
&self.source
}
fn parse(node: &Value, name: &str, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, name: &str, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let kind_str = node
.get("kind")
.and_then(Value::as_str)
@@ -225,13 +225,13 @@ impl BastDef {
let alk_kind = AlkTypeKind::from_bast_str(kind_str)?;
let kind = match alk_kind {
AlkTypeKind::Struct => {
BastDefKind::Struct(BastStruct::parse(node, path)?)
BastDefKind::Struct(BastStruct::parse(node, path, doc_root)?)
}
AlkTypeKind::Union => {
BastDefKind::Union(BastUnion::parse(node, path)?)
BastDefKind::Union(BastUnion::parse(node, path, doc_root)?)
}
AlkTypeKind::Enum => {
BastDefKind::Enum(BastEnum::parse(node, path)?)
BastDefKind::Enum(BastEnum::parse(node, path, doc_root)?)
}
other => {
return Err(AlkTypeError::Schema(format!(
@@ -306,7 +306,7 @@ impl BastStruct {
&self.source
}
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let endian = parse_endian_opt(node).unwrap_or(Endian::Little);
let align = parse_align(node);
let raw_fields = node
@@ -320,7 +320,7 @@ impl BastStruct {
let mut fields = Vec::new();
for (i, raw_field) in raw_fields.iter().enumerate() {
let field_path = format!("{path}.fields[{i}]");
fields.push(BastField::parse(raw_field, &field_path)?);
fields.push(BastField::parse(raw_field, &field_path, doc_root)?);
}
Ok(Self {
endian,
@@ -409,7 +409,7 @@ impl BastField {
self.endian.unwrap_or(default)
}
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let name = node
.get("name")
.and_then(Value::as_str)
@@ -425,7 +425,7 @@ impl BastField {
"bast: field {name:?} at {path} has no `kind`"
))
})?;
let ty = BastType::parse(raw_kind, path)?;
let ty = BastType::parse(raw_kind, path, doc_root)?;
let endian = parse_endian_opt(node);
let align = parse_align(node);
let encoding = parse_encoding(node);
@@ -493,7 +493,7 @@ impl BastUnion {
&self.source
}
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let endian = parse_endian_opt(node).unwrap_or(Endian::Little);
let discriminator = BastDiscriminator::parse(node, path)?;
let raw_fields = node.get("fields").and_then(Value::as_array);
@@ -507,7 +507,7 @@ impl BastUnion {
let mut out = Vec::new();
for (i, raw_field) in arr.iter().enumerate() {
let field_path = format!("{path}.fields[{i}]");
out.push(BastField::parse(raw_field, &field_path)?);
out.push(BastField::parse(raw_field, &field_path, doc_root)?);
}
out
}
@@ -531,7 +531,7 @@ impl BastUnion {
let mut mapping = Vec::new();
for (key, value) in raw_mapping.iter() {
let entry_path = format!("{path}.mapping[{key}]");
let ty = BastType::parse(value, &entry_path)?;
let ty = BastType::parse(value, &entry_path, doc_root)?;
mapping.push((key.clone(), ty));
}
if mapping.is_empty() {
@@ -539,6 +539,75 @@ impl BastUnion {
"bast: union at {path} has an empty `mapping`"
)));
}
if let BastDiscriminator::Field { name } = &discriminator {
if !fields.iter().any(|f| f.name() == name.as_str()) {
return Err(AlkTypeError::Schema(format!(
"bast: field-name union at {path} has no field {name:?} in `fields` (the discriminator field must be declared)"
)));
}
let mut names: Vec<&str> = fields.iter().map(|f| f.name()).collect();
names.sort_unstable();
let dup = names.windows(2).find(|pair| pair[0] == pair[1]);
if let Some([a, _]) = dup {
return Err(AlkTypeError::Schema(format!(
"bast: field-name union at {path} declares field {a:?} more than once in \
`fields` (the shared-then-variant wire convention lays the shared fields \
out once; a variant must not re-declare the discriminator or any shared \
field)"
)));
}
if fields[0].name() != name.as_str() {
return Err(AlkTypeError::Schema(format!(
"bast: field-name union at {path} has the discriminator field {name:?} at \
position {}, but it must be the first entry in `fields` (the packed reader \
reads the discriminator at the union's start offset; a later position would \
make it read the wrong field's bytes — review #006 H3 item 2)",
fields
.iter()
.position(|f| f.name() == name.as_str())
.expect("checked above")
)));
}
for (key, variant_ty) in &mapping {
let variant_path = format!("{path}.mapping[{key}]");
let clash = match variant_ty {
BastType::Struct(s) => s
.fields()
.iter()
.find(|vf| fields.iter().any(|sf| sf.name() == vf.name()))
.map(|vf| vf.name().to_string()),
BastType::Ref(r) => {
let variant_node = BastDoc::lookup_def_raw(doc_root, r.name())?;
match BastType::parse(variant_node, &variant_path, doc_root)? {
BastType::Struct(s) => s
.fields()
.iter()
.find(|vf| fields.iter().any(|sf| sf.name() == vf.name()))
.map(|vf| vf.name().to_string()),
other => {
return Err(AlkTypeError::Schema(format!(
"bast: union at {path} variant '{key}' must be a struct, got {}",
other.alk_kind()
)));
}
}
}
other => {
return Err(AlkTypeError::Schema(format!(
"bast: union at {path} variant '{key}' must be a struct, got {}",
other.alk_kind()
)));
}
};
if let Some(field_name) = clash {
return Err(AlkTypeError::Schema(format!(
"bast: union at {path} variant '{key}' re-declares field {field_name:?} \
which is already in the union's `fields` (the shared-then-variant wire \
convention forbids variants from re-declaring shared fields)"
)));
}
}
}
Ok(Self {
endian,
discriminator,
@@ -562,7 +631,8 @@ pub enum BastDiscriminator {
},
/// A length-prefixed string field within the union. Mapping keys are
/// string values matching the field's value. The `fields` array
/// declares the discriminator field alongside any shared fields.
/// declares the discriminator field (which must be the first entry)
/// alongside any shared fields.
Field {
/// The field name that holds the discriminator value.
name: String,
@@ -658,7 +728,7 @@ impl BastEnum {
&self.source
}
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, path: &str, _doc_root: &Value) -> Result<Self, AlkTypeError> {
let raw_values = node
.get("values")
.and_then(Value::as_array)
@@ -734,7 +804,7 @@ impl BastType {
}
}
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
if let Some(s) = node.as_str() {
let k = AlkTypeKind::from_bast_str(s)?;
return Ok(BastType::Primitive(k));
@@ -758,11 +828,11 @@ impl BastType {
})?;
let k = AlkTypeKind::from_bast_str(kind_str)?;
match k {
AlkTypeKind::Array => Ok(BastType::Array(BastArray::parse(node, path)?)),
AlkTypeKind::Record => Ok(BastType::Record(BastRecord::parse(node, path)?)),
AlkTypeKind::Struct => Ok(BastType::Struct(BastStruct::parse(node, path)?)),
AlkTypeKind::Union => Ok(BastType::Union(BastUnion::parse(node, path)?)),
AlkTypeKind::Enum => Ok(BastType::Enum(BastEnum::parse(node, path)?)),
AlkTypeKind::Array => Ok(BastType::Array(BastArray::parse(node, path, doc_root)?)),
AlkTypeKind::Record => Ok(BastType::Record(BastRecord::parse(node, path, doc_root)?)),
AlkTypeKind::Struct => Ok(BastType::Struct(BastStruct::parse(node, path, doc_root)?)),
AlkTypeKind::Union => Ok(BastType::Union(BastUnion::parse(node, path, doc_root)?)),
AlkTypeKind::Enum => Ok(BastType::Enum(BastEnum::parse(node, path, doc_root)?)),
other => Err(AlkTypeError::Schema(format!(
"bast: type at {path} has inline kind {other}, which is only valid as a primitive string or a $defs entry"
))),
@@ -841,13 +911,13 @@ impl BastArray {
&self.source
}
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let element_node = node.get("element").ok_or_else(|| {
AlkTypeError::Schema(format!(
"bast: array at {path} has no `element`"
))
})?;
let element = BastType::parse(element_node, &format!("{path}.element"))?;
let element = BastType::parse(element_node, &format!("{path}.element"), doc_root)?;
let count = parse_usize_field(node, "count", path, "array count")?.ok_or_else(|| {
AlkTypeError::Schema(format!(
"bast: array at {path} has no `count` (variable-length arrays are not supported in v1, D-BAST-004)"
@@ -890,13 +960,13 @@ impl BastRecord {
&self.source
}
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let values_node = node.get("values").ok_or_else(|| {
AlkTypeError::Schema(format!(
"bast: record at {path} has no `values`"
))
})?;
let values = BastType::parse(values_node, &format!("{path}.values"))?;
let values = BastType::parse(values_node, &format!("{path}.values"), doc_root)?;
Ok(Self {
values: Box::new(values),
source: node.clone(),