Skip to content

Commit b313dda

Browse files
avrabeclaude
andauthored
fix(parser): canonical ABI for flags<N> (LS-A-20) (#157)
parser::convert_wp_defined_type mapped Component-Model flags<N> to ComponentValType::Record(N × Bool), so downstream canonical-ABI helpers computed flat=N, size=N bytes, align=1. Spec for flags<N>: - flat_count = ceil(N/32) i32 storage words - size = ceil(N/8) padded to power-of-2 storage class - align ∈ {1, 2, 4} from the storage class Concrete impact: flags<17> as a parameter had flat=17 in meld but flat=1 per spec. Resolver's params-ptr threshold fires at >16, so meld flipped into params-ptr calling convention while producer kept the flat path. Smaller N (flags<9>) produced wrong size (9 vs 2) and wrong alignment. Bug existed since the Flags arm was added (~2026-03-09), ~2 months multiple releases. Wasm validator doesn't catch it because the truncated layout is internally consistent within meld's pipeline. Fix: - New ComponentValType::Flags(Vec<String>) variant - Parser's Flags arm produces it directly - Explicit Flags arms in flat_count / canonical_abi_align / canonical_abi_size_unpadded / flat_byte_size / collect_return_area_type_slots / cabi_size_align / flat_component_val_type_resolved / resolve_component_val_type - u32::div_ceil(32) for word count (overflow-safe, Rust 1.73+ stable API; replaces (n + 31) / 32 which clippy now flags) Tests (2 new): - ls_a_20_flags_canonical_abi_matches_spec (N=1/8/9/17/32/33) - ls_a_20_flags_parser_produces_flags_variant (wat round-trip) LS-A-20 added to safety/stpa/loss-scenarios.yaml under UCA-P-10 with approved status. Discovered by the post-v0.8.0 Mythos delta-pass on parser.rs. Refs: LS-A-20 (UCA-P-10, H-4, H-4.1) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b6a6439 commit b313dda

5 files changed

Lines changed: 275 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ All notable changes to this project will be documented in this file.
4949
is the right structural mitigation), but the output is now
5050
reproducible across runs.
5151

52+
- **`flags<N>` canonical ABI silently modeled as `Record<N × Bool>`**
53+
(LS-A-20, UCA-P-10, H-4 / H-4.1). `parser.rs::convert_wp_defined_type`
54+
mapped a Component-Model `flags<N>` to `Record(N × Bool)`, so the
55+
downstream canonical-ABI helpers (`flat_count`,
56+
`canonical_abi_size_unpadded`, `canonical_abi_align`, etc.) computed
57+
flat=N, size=N bytes, align=1. The spec requires flat=ceil(N/32) i32
58+
words, size=ceil(N/8) padded to power-of-2 storage class, align ∈
59+
{1, 2, 4}. A function taking `flags<17>` crossed meld's params-ptr
60+
threshold (`total_flat_params > 16`) while the producer kept on the
61+
flat path — silent calling-convention mismatch between fused and
62+
composed paths. Bug shipped since March 2026 (~2 months, multiple
63+
releases). Fix adds `ComponentValType::Flags(Vec<String>)` variant,
64+
has the parser produce it directly, and adds explicit Flags arms to
65+
every canonical-ABI function that walks ComponentValType. Regression
66+
pinned by `ls_a_20_flags_canonical_abi_matches_spec` (covers
67+
N=1/8/9/17/32/33) and `ls_a_20_flags_parser_produces_flags_variant`.
68+
5269
### Added
5370

5471
- **Mythos delta-pass CI gate** (`.github/workflows/mythos-gate.yml`,

meld-core/src/adapter/fact.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ pub(crate) fn cabi_size_align(ty: &crate::parser::ComponentValType) -> (u32, u32
178178
let body = align_up(1, align) + max_size;
179179
(align_up(body, align), align)
180180
}
181+
CVT::Flags(names) => {
182+
// flags<N>: ceil(N/8) bytes padded to power-of-2 storage
183+
// class; align ∈ {1, 2, 4} per LS-A-20.
184+
let n = names.len() as u32;
185+
if n <= 8 {
186+
(1, 1)
187+
} else if n <= 16 {
188+
(2, 2)
189+
} else {
190+
(4u32.saturating_mul(n.div_ceil(32)), 4)
191+
}
192+
}
181193
CVT::Own(_) | CVT::Borrow(_) | CVT::Type(_) => (4, 4),
182194
}
183195
}

meld-core/src/component_wrap.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2342,7 +2342,9 @@ pub(crate) fn resolve_component_val_type(
23422342
})
23432343
.collect(),
23442344
),
2345-
CVT::Primitive(_) | CVT::String | CVT::Own(_) | CVT::Borrow(_) => ty.clone(),
2345+
CVT::Primitive(_) | CVT::String | CVT::Own(_) | CVT::Borrow(_) | CVT::Flags(_) => {
2346+
ty.clone()
2347+
}
23462348
}
23472349
}
23482350

@@ -2454,6 +2456,13 @@ fn flat_component_val_type_resolved(
24542456
parser::ComponentValType::Own(_) | parser::ComponentValType::Borrow(_) => {
24552457
vec![wasm_encoder::ValType::I32]
24562458
}
2459+
parser::ComponentValType::Flags(names) => {
2460+
// flags<N> flattens to ceil(N/32) i32 storage words per
2461+
// canonical ABI (LS-A-20).
2462+
let n = names.len() as u32;
2463+
let words = n.div_ceil(32);
2464+
vec![ValType::I32; words as usize]
2465+
}
24572466
}
24582467
}
24592468

meld-core/src/parser.rs

Lines changed: 181 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,17 @@ pub enum ComponentValType {
405405
List(Box<ComponentValType>),
406406
FixedSizeList(Box<ComponentValType>, u32),
407407
Record(Vec<(String, ComponentValType)>),
408+
/// Component-Model `flags<N>`. The vector stores the flag names (for
409+
/// diagnostics); only the count `N = names.len()` participates in the
410+
/// canonical-ABI layout calculations (`flat_count = ceil(N/32)`,
411+
/// `size = ceil(N/8)` padded, `align` from storage class).
412+
///
413+
/// Prior to LS-A-20, flags<N> was modelled as `Record<N × Bool>`,
414+
/// which made the canonical-ABI layout calculations return N for
415+
/// flat count, N bytes for size, and 1 for align — a silent
416+
/// divergence from the spec that crossed the `total_flat_params > 16`
417+
/// params-ptr threshold for any flags<17+> argument.
418+
Flags(Vec<String>),
408419
Variant(Vec<(String, Option<ComponentValType>)>),
409420
Tuple(Vec<ComponentValType>),
410421
Option(Box<ComponentValType>),
@@ -1455,6 +1466,11 @@ impl ParsedComponent {
14551466
ComponentValType::FixedSizeList(elem, len) => {
14561467
self.flat_byte_size(elem).saturating_mul(*len)
14571468
}
1469+
ComponentValType::Flags(names) => {
1470+
// flats[ceil(N/32)] i32 storage words = 4 bytes each.
1471+
let n = names.len() as u32;
1472+
4u32.saturating_mul(n.div_ceil(32))
1473+
}
14581474
ComponentValType::Own(_) | ComponentValType::Borrow(_) => 4,
14591475
}
14601476
}
@@ -1792,6 +1808,22 @@ impl ParsedComponent {
17921808
is_pointer_pair: false,
17931809
});
17941810
}
1811+
ComponentValType::Flags(names) => {
1812+
// flags<N>: ceil(N/8) bytes padded to its storage class.
1813+
let n = names.len() as u32;
1814+
let size = if n <= 8 {
1815+
1
1816+
} else if n <= 16 {
1817+
2
1818+
} else {
1819+
4u32.saturating_mul(n.div_ceil(32))
1820+
};
1821+
out.push(ReturnAreaSlot {
1822+
byte_offset: base,
1823+
byte_size: size,
1824+
is_pointer_pair: false,
1825+
});
1826+
}
17951827
}
17961828
}
17971829

@@ -2569,6 +2601,11 @@ impl ParsedComponent {
25692601
ComponentValType::FixedSizeList(elem, len) => {
25702602
self.flat_count(elem).saturating_mul(*len)
25712603
}
2604+
ComponentValType::Flags(names) => {
2605+
// flags<N> flattens to ceil(N/32) i32 storage words per
2606+
// the Component Model canonical ABI.
2607+
(names.len() as u32).div_ceil(32)
2608+
}
25722609
ComponentValType::Own(_) | ComponentValType::Borrow(_) => 1,
25732610
}
25742611
}
@@ -2630,6 +2667,21 @@ impl ParsedComponent {
26302667
}
26312668
4
26322669
}
2670+
ComponentValType::Flags(names) => {
2671+
// flags<N>: align to the smallest power-of-two storage
2672+
// class that holds N bits. Per the Component Model
2673+
// canonical ABI: N≤8 → 1, N≤16 → 2, else 4 (32-bit
2674+
// storage classes; flags<33+> uses an array of i32
2675+
// which still aligns to 4).
2676+
let n = names.len() as u32;
2677+
if n <= 8 {
2678+
1
2679+
} else if n <= 16 {
2680+
2
2681+
} else {
2682+
4
2683+
}
2684+
}
26332685
ComponentValType::Own(_) | ComponentValType::Borrow(_) => 4,
26342686
}
26352687
}
@@ -2717,6 +2769,25 @@ impl ParsedComponent {
27172769
.unwrap_or(0);
27182770
align_up(ds, max_case_align).saturating_add(ok_s.max(err_s))
27192771
}
2772+
ComponentValType::Flags(names) => {
2773+
// flags<N>: ceil(N/8) bytes, packed in the smallest
2774+
// storage class. Per Component Model canonical ABI:
2775+
// N≤8: 1 byte
2776+
// N≤16: 2 bytes (1 u16)
2777+
// N≤32: 4 bytes (1 u32)
2778+
// N≤64: 8 bytes (2 u32s)
2779+
// etc.
2780+
// Storage scales as ceil(N/32) i32 words past N=32.
2781+
let n = names.len() as u32;
2782+
if n <= 8 {
2783+
1
2784+
} else if n <= 16 {
2785+
2
2786+
} else {
2787+
// 4 bytes per i32 word; ceil(N/32) words.
2788+
4u32.saturating_mul(n.div_ceil(32))
2789+
}
2790+
}
27202791
ComponentValType::Type(idx) => {
27212792
if let Some(ct) = self.get_type_definition(*idx)
27222793
&& let ComponentTypeKind::Defined(inner) = &ct.kind
@@ -3000,18 +3071,13 @@ fn convert_wp_defined_type(dt: &wasmparser::ComponentDefinedType) -> ComponentTy
30003071
))
30013072
}
30023073
wasmparser::ComponentDefinedType::Flags(names) => {
3003-
// Flags are represented as a record of bools in the canonical ABI.
3004-
// For flat counting and pointer analysis, we use a record representation.
3005-
ComponentTypeKind::Defined(ComponentValType::Record(
3006-
names
3007-
.iter()
3008-
.map(|name| {
3009-
(
3010-
name.to_string(),
3011-
ComponentValType::Primitive(PrimitiveValType::Bool),
3012-
)
3013-
})
3014-
.collect(),
3074+
// flags<N> has its own canonical-ABI lowering — packed into
3075+
// `ceil(N/32)` i32 storage words, NOT a record of N bools.
3076+
// Use the dedicated `ComponentValType::Flags` variant so the
3077+
// canonical-ABI helpers compute the correct flat count,
3078+
// size, and alignment (LS-A-20).
3079+
ComponentTypeKind::Defined(ComponentValType::Flags(
3080+
names.iter().map(|n| n.to_string()).collect(),
30153081
))
30163082
}
30173083
wasmparser::ComponentDefinedType::FixedLengthList(ty, len) => ComponentTypeKind::Defined(
@@ -4258,4 +4324,107 @@ mod tests {
42584324
assert_eq!(super::align_up(u32::MAX, 8), !7u32);
42594325
assert_eq!(super::align_up(u32::MAX - 3, 8), !7u32);
42604326
}
4327+
4328+
// ---------------------------------------------------------------
4329+
// LS-A-20 — flags<N> canonical ABI silently modeled as Record<Bool>
4330+
//
4331+
// Prior to the fix, convert_wp_defined_type mapped Flags(names) to
4332+
// ComponentValType::Record(names × Bool), so flat_count = N,
4333+
// canonical_abi_size_unpadded = N, canonical_abi_align = 1 — wrong
4334+
// for any N where ceil(N/32) ≠ N or N ≥ 9 (storage alignment).
4335+
// ---------------------------------------------------------------
4336+
4337+
fn empty_parsed_for_flags() -> ParsedComponent {
4338+
ParsedComponent {
4339+
name: None,
4340+
core_modules: vec![],
4341+
imports: vec![],
4342+
exports: vec![],
4343+
types: vec![],
4344+
instances: vec![],
4345+
canonical_functions: vec![],
4346+
sub_components: vec![],
4347+
component_aliases: vec![],
4348+
component_instances: vec![],
4349+
core_entity_order: vec![],
4350+
component_func_defs: vec![],
4351+
component_instance_defs: vec![],
4352+
component_type_defs: vec![],
4353+
original_size: 0,
4354+
original_hash: String::new(),
4355+
depth_0_sections: vec![],
4356+
p3_async_features: vec![],
4357+
}
4358+
}
4359+
4360+
fn flags_ty(n: usize) -> ComponentValType {
4361+
ComponentValType::Flags((0..n).map(|i| format!("f{i}")).collect())
4362+
}
4363+
4364+
#[test]
4365+
fn ls_a_20_flags_canonical_abi_matches_spec() {
4366+
let pc = empty_parsed_for_flags();
4367+
// flags<1>: flat=1 size=1 align=1
4368+
assert_eq!(pc.flat_count(&flags_ty(1)), 1);
4369+
assert_eq!(pc.canonical_abi_size_unpadded(&flags_ty(1)), 1);
4370+
assert_eq!(pc.canonical_abi_align(&flags_ty(1)), 1);
4371+
// flags<8>: flat=1 size=1 align=1
4372+
assert_eq!(pc.flat_count(&flags_ty(8)), 1);
4373+
assert_eq!(pc.canonical_abi_size_unpadded(&flags_ty(8)), 1);
4374+
assert_eq!(pc.canonical_abi_align(&flags_ty(8)), 1);
4375+
// flags<9>: flat=1 size=2 align=2 (crosses byte boundary)
4376+
assert_eq!(pc.flat_count(&flags_ty(9)), 1);
4377+
assert_eq!(pc.canonical_abi_size_unpadded(&flags_ty(9)), 2);
4378+
assert_eq!(pc.canonical_abi_align(&flags_ty(9)), 2);
4379+
// flags<17>: flat=1 size=4 align=4 (THE bug-trigger case;
4380+
// pre-fix returned flat=17 which crosses the params-ptr
4381+
// threshold at >16 in resolver.rs)
4382+
assert_eq!(pc.flat_count(&flags_ty(17)), 1);
4383+
assert_eq!(pc.canonical_abi_size_unpadded(&flags_ty(17)), 4);
4384+
assert_eq!(pc.canonical_abi_align(&flags_ty(17)), 4);
4385+
// flags<32>: flat=1 size=4 align=4
4386+
assert_eq!(pc.flat_count(&flags_ty(32)), 1);
4387+
assert_eq!(pc.canonical_abi_size_unpadded(&flags_ty(32)), 4);
4388+
// flags<33>: flat=2 size=8 align=4 (spills to 2 i32 words)
4389+
assert_eq!(pc.flat_count(&flags_ty(33)), 2);
4390+
assert_eq!(pc.canonical_abi_size_unpadded(&flags_ty(33)), 8);
4391+
assert_eq!(pc.canonical_abi_align(&flags_ty(33)), 4);
4392+
}
4393+
4394+
#[test]
4395+
fn ls_a_20_flags_parser_produces_flags_variant() {
4396+
// The wasmparser-bridge convert_wp_defined_type must now produce
4397+
// ComponentValType::Flags (not Record<Bool>) for `(flags ...)`
4398+
// declarations. We exercise via the wat round-trip pattern used
4399+
// elsewhere in this file.
4400+
let wat = r#"
4401+
(component
4402+
(type $f (flags "a" "b" "c" "d" "e" "f" "g" "h" "i"
4403+
"j" "k" "l" "m" "n" "o" "p" "q"))
4404+
(core module $m (func (export "run") (param i32)))
4405+
(core instance (instantiate $m))
4406+
)
4407+
"#;
4408+
let bytes = match wat::parse_str(wat) {
4409+
Ok(b) => b,
4410+
Err(e) => {
4411+
eprintln!("skipping: wat crate cannot parse flags syntax: {e}");
4412+
return;
4413+
}
4414+
};
4415+
let comp = ComponentParser::new()
4416+
.parse(&bytes)
4417+
.expect("parse should succeed");
4418+
let flags_ty = comp.types.iter().find_map(|t| match &t.kind {
4419+
ComponentTypeKind::Defined(v @ ComponentValType::Flags(_)) => Some(v.clone()),
4420+
_ => None,
4421+
});
4422+
assert!(
4423+
flags_ty.is_some(),
4424+
"(flags ...) must parse to ComponentValType::Flags, not \
4425+
Record<Bool>"
4426+
);
4427+
// And verify flat_count is 1, not 17.
4428+
assert_eq!(comp.flat_count(&flags_ty.unwrap()), 1);
4429+
}
42614430
}

safety/stpa/loss-scenarios.yaml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,61 @@ loss-scenarios:
10011001
status: approved
10021002
priority: high
10031003

1004+
- id: LS-A-20
1005+
title: flags<N> canonical ABI silently modeled as Record<N × Bool>
1006+
uca: UCA-P-10
1007+
hazards: [H-4, H-4.1]
1008+
type: inadequate-control-algorithm
1009+
scenario: >
1010+
`meld-core/src/parser.rs::convert_wp_defined_type` (the
1011+
`Flags(names) =>` arm at lines 3002-3015) mapped a Component-
1012+
Model `flags<N>` to `ComponentValType::Record(N × Bool)`. The
1013+
downstream canonical-ABI helpers (`flat_count`,
1014+
`canonical_abi_size_unpadded`, `canonical_abi_align`,
1015+
`flat_byte_size`, `collect_return_area_type_slots`,
1016+
`cabi_size_align` in adapter/fact.rs, and the wrapper's
1017+
`flat_component_val_type_resolved`) then computed
1018+
record-of-bool layouts: flat=N, size=N bytes, align=1.
1019+
1020+
The Component-Model canonical ABI for `flags<N>` is
1021+
fundamentally different: `flat = ceil(N/32)` i32 storage words,
1022+
`size = ceil(N/8)` padded to its storage class (1/2/4/8/...
1023+
bytes), `align ∈ {1, 2, 4}` from the power-of-2 storage class.
1024+
1025+
Concrete impact: a function taking `flags<17>` as a parameter
1026+
has flat_count=1 (one i32) per spec, but meld computed 17
1027+
flat values. The resolver's "params-ptr threshold" check fires
1028+
at `total_flat_params > 16` — meld would flip into the
1029+
params-ptr calling convention for the same function the
1030+
producer kept on the flat path, mismatching call conventions
1031+
between fused and composed paths. Smaller N values (e.g.
1032+
flags<9>) produced wrong size (9 bytes vs spec's 2) and wrong
1033+
align (1 vs spec's 2), so adjacent fields in records
1034+
mis-aligned.
1035+
1036+
The bug existed since the Flags arm was added in commit
1037+
d7a5ddb (2026-03-09, ~2 months and multiple releases). Wasm
1038+
validator does not catch it because the truncated layout is
1039+
internally consistent within meld's own pipeline; the
1040+
divergence only surfaces when the fused output is called by a
1041+
caller that lowered against the spec.
1042+
1043+
Fix: add a `ComponentValType::Flags(Vec<String>)` variant
1044+
(preserving names for diagnostics), have the parser produce it
1045+
directly, and add explicit Flags arms to every canonical-ABI
1046+
function that walks ComponentValType. The arms compute the
1047+
spec-correct flat / size / align values directly from the
1048+
flag count.
1049+
causal-factors:
1050+
- "Flags was modelled as a Record of Bools as a simplification \
1051+
at parser bridge time, no helper recognised the special-case canonical-ABI layout"
1052+
- "No upstream test exercised flags<N> for any N that exposed the divergence"
1053+
- "Bug shape matches UCA-P-10 ('unrecognized types mapped to Other'): \
1054+
a structurally valid type misclassified at the parse boundary, propagating \
1055+
wrong layout decisions to downstream code generators"
1056+
status: approved
1057+
priority: high
1058+
10041059
# ==========================================================================
10051060
# Merger scenario (discovered during gap analysis)
10061061
# ==========================================================================

0 commit comments

Comments
 (0)