diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5f5de..8d39e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.51.0] - 2026-08-19 + +### Fixed +- **Shipped artifacts recorded almost no build configuration (SR-28 / SR-69)** — + a traceability defect. SR-28 requires every `FuserConfig` field to be recorded + in the fusion attestation so an auditor holding only the artifact can + reconstruct how it was fused. The parameter-recording code, however, sat behind + the optional `attestation` (wsc) Cargo feature, which **release builds do not + enable** — so every published meld artifact recorded nothing beyond the memory + strategy. In particular `--share-stack` (which carries a documented soundness + envelope), `--pack-rebase`, `--address-rebase` and `--profile` were all + **unattested**: an artifact could not be shown to have been built with, or + without, them. + + **Artifacts produced by v0.50.0 and earlier therefore carry no configuration + attestation.** If you rely on meld attestations as audit evidence, artifacts + must be re-fused with v0.51.0 or later to record the build configuration. + + The build configuration is now recorded on the default (shipped) path as a + typed `parameters` object — deliberately not a map, so its key order is fixed + by declaration and stays byte-stable under `--reproducible`. SR-28 completeness + is additionally enforced **at compile time**: the parameter-building function + destructures `FuserConfig` exhaustively, so adding a config field fails the + build until it is recorded or explicitly acknowledged as not a build parameter. + The previous SR-28 sentinel asserted against a map it built inline in the test, + which is why three fields had already slipped past it; it now asserts against + the real serialized attestation. + +### Added +- **Per-boundary strategy is attested and observable (SR-69, ADR-7)** — meld now + records one entry per fused cross-component call: the caller/callee component + and module, the callee function and interface, the call-lowering class chosen + (`direct` / `memory-copy` / `transcode` / `async-lift`) and how the call was + **actually wired** (`inlined-direct` / `widening-wrapper` / `thunk`). This + satisfies ADR-7's requirement that each boundary's strategy be declared, + attested and observable rather than inferable only from aggregate counts. + + The wiring field is captured at the wiring step rather than from the + call-lowering seam's inline *eligibility*, because a widening wrapper takes + precedence over inlining — eligibility alone would misreport what shipped. + +- **`meld fuse --explain`** — prints those per-boundary decisions, with a tally of + how many boundaries are direct / memory-copy / transcode / async-lift and how + many were wired with nothing interposed. The same records are embedded in the + attestation, so a shipped artifact can be audited after the fact. + +Neither addition perturbs artifact hashes: the attestation hash is computed over +the module with the attestation and provenance sections stripped. + ## [0.50.0] - 2026-08-19 ### Added diff --git a/Cargo.lock b/Cargo.lock index 12f6df5..759ea93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1381,7 +1381,7 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "meld-cli" -version = "0.50.0" +version = "0.51.0" dependencies = [ "anyhow", "clap", @@ -1396,7 +1396,7 @@ dependencies = [ [[package]] name = "meld-core" -version = "0.50.0" +version = "0.51.0" dependencies = [ "anyhow", "bitflags", diff --git a/Cargo.toml b/Cargo.toml index aa6ed75..c0055d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ exclude = [ ] [workspace.package] -version = "0.50.0" +version = "0.51.0" authors = ["PulseEngine "] edition = "2024" license = "Apache-2.0" diff --git a/meld-cli/src/main.rs b/meld-cli/src/main.rs index 4e19533..28f38d0 100644 --- a/meld-cli/src/main.rs +++ b/meld-cli/src/main.rs @@ -112,6 +112,15 @@ enum Commands { #[arg(long)] stats: bool, + /// Explain the per-boundary strategy: for every fused cross-component + /// call, the call-lowering class chosen for it (direct / memory-copy / + /// transcode / async-lift) and how it was wired (inlined-direct / + /// widening-wrapper / thunk). The same records are embedded in the + /// fusion attestation, so a shipped artifact can be audited after the + /// fact (ADR-7: per-boundary strategy declared, attested, observable). + #[arg(long)] + explain: bool, + /// Disable attestation in output #[arg(long)] no_attestation: bool, @@ -254,12 +263,14 @@ fn main() -> Result<()> { pack_rebase, share_stack, profile, + explain, }) => { fuse_command( inputs, output, memory, profile, + explain, address_rebase, pack_rebase, share_stack, @@ -340,6 +351,7 @@ fn fuse_command( output: String, memory: String, profile: String, + explain: bool, address_rebase: bool, pack_rebase: bool, share_stack: bool, @@ -578,6 +590,10 @@ fn fuse_command( println!("Output: {} ({} bytes)", output, fused_bytes.len()); // Show statistics + if explain { + print_boundaries(&stats); + } + if show_stats { print_stats(&stats, total_input_size, elapsed); } else { @@ -603,6 +619,66 @@ fn fuse_command( } /// Print detailed statistics +/// ADR-7 `--explain`: report the strategy chosen for every fused +/// cross-component call boundary. The same records are embedded in the fusion +/// attestation, so this is the live view of an artifact-auditable fact. +fn print_boundaries(stats: &FusionStats) { + println!(); + println!("Boundary strategies"); + println!("{}", "=".repeat(50)); + + if stats.boundaries.is_empty() { + println!(); + println!(" (no fused cross-component calls — nothing to report)"); + return; + } + + println!(); + for b in &stats.boundaries { + let memory = if b.crosses_memory { + "cross-memory" + } else { + "same-memory" + }; + println!( + " c{}m{} -> c{}m{} {}::{}", + b.from_component, b.from_module, b.to_component, b.to_module, b.interface, b.function + ); + println!( + " lowering: {:<12} wiring: {:<16} {}", + b.lowering, b.wiring, memory + ); + } + + // A short tally so the shape is visible without reading every line. + let mut direct = 0usize; + let mut copy = 0usize; + let mut transcode = 0usize; + let mut asynch = 0usize; + let mut inlined = 0usize; + for b in &stats.boundaries { + match b.lowering.as_str() { + "direct" => direct += 1, + "memory-copy" => copy += 1, + "transcode" => transcode += 1, + _ => asynch += 1, + } + if b.wiring == "inlined-direct" { + inlined += 1; + } + } + println!(); + println!( + " {} boundaries: {} direct, {} memory-copy, {} transcode, {} async-lift ({} wired with nothing interposed)", + stats.boundaries.len(), + direct, + copy, + transcode, + asynch, + inlined + ); +} + fn print_stats(stats: &FusionStats, total_input_size: usize, elapsed: std::time::Duration) { println!(); println!("Fusion Statistics"); diff --git a/meld-core/src/attestation.rs b/meld-core/src/attestation.rs index d53c5a6..2cb2fa9 100644 --- a/meld-core/src/attestation.rs +++ b/meld-core/src/attestation.rs @@ -94,6 +94,36 @@ pub struct ToolInfo { pub tool_hash: Option, } +/// SR-28: the `FuserConfig` switches that shaped this artifact. +/// +/// Typed rather than a map so the JSON key order is fixed by declaration — +/// deterministic under `--reproducible` by construction. **When you add a field +/// to `FuserConfig`, add it here too**; `test_sr28_config_completeness` asserts +/// against the REAL builder output, so it will fail until you do. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct FusionParameters { + /// ADR-7 build profile: `ecosystem` or `safety`. + pub profile: String, + /// Inter-component isolation model: `shared` or `multi`. + pub memory_strategy: String, + /// Whether per-module addresses were rebased into one memory. + pub address_rebasing: bool, + /// SR-57: compact used-extent rebasing. + pub pack_rebase: bool, + /// SR-66: one shared shadow-stack region (carries a soundness envelope). + pub share_stack: bool, + /// Whether debug names were preserved. + pub preserve_names: bool, + /// Custom-section handling mode. + pub custom_sections: String, + /// DWARF handling mode. + pub dwarf_handling: String, + /// Output format: `core-module` or `component`. + pub output_format: String, + /// Whether the build was byte-reproducible (#325). + pub reproducible: bool, +} + /// Fusion-specific metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FusionMetadata { @@ -114,6 +144,42 @@ pub struct FusionMetadata { /// Size reduction (percentage) pub size_reduction_percent: f64, + + /// SR-28: the build configuration, so an auditor holding only the artifact + /// can reconstruct how it was fused. + /// + /// Previously the shipped (default) attestation path recorded no + /// configuration beyond `memory_strategy` — the parameter set existed only + /// on the optional wsc path, which is not what releases build. That left + /// safety-relevant switches unattested: an artifact could not be shown to + /// have been built with (or without) `--share-stack`, whose soundness + /// envelope is a documented precondition, or `--profile safety`, whose whole + /// purpose is "declared and attested". + /// + /// A typed struct, deliberately not a map: serialization order is fixed by + /// declaration, so this is deterministic under `--reproducible` by + /// construction (the wsc path's `HashMap` parameters are exactly what makes + /// that path non-reproducible). + #[serde(default)] + pub parameters: FusionParameters, + + /// ADR-7: one record per fused cross-component call boundary — the + /// call-lowering strategy chosen for it and how it was ultimately wired. + /// + /// This is the *attested* half of ADR-7's "per-boundary strategy declared, + /// attested, observable": an auditor reading a shipped artifact can see what + /// every cross-component call actually costs, rather than inferring it from + /// the aggregate counts. Emitted in `adapter_sites` order, which the resolver + /// sorts into a total order, so it is stable under `--reproducible`. + /// + /// Note this section sits OUTSIDE the bytes covered by `output.hash` (the + /// hash is taken over the module with the attestation and provenance + /// sections stripped), so enriching it does not perturb artifact hashes. + /// + /// `#[serde(default)]` so attestations written by older meld versions still + /// deserialize. + #[serde(default)] + pub boundaries: Vec, } /// Builder for creating fusion attestations @@ -124,6 +190,7 @@ pub struct FusionAttestationBuilder { tool_hash: Option, memory_strategy: String, reproducible: bool, + parameters: FusionParameters, } impl FusionAttestationBuilder { @@ -136,6 +203,7 @@ impl FusionAttestationBuilder { tool_hash: None, memory_strategy: "shared".to_string(), reproducible: false, + parameters: FusionParameters::default(), } } @@ -147,6 +215,13 @@ impl FusionAttestationBuilder { self } + /// SR-28: record the build configuration so an auditor holding only the + /// artifact can reconstruct how it was fused. + pub fn parameters(mut self, parameters: FusionParameters) -> Self { + self.parameters = parameters; + self + } + /// Set the tool hash for reproducibility pub fn tool_hash(mut self, hash: impl Into) -> Self { self.tool_hash = Some(hash.into()); @@ -262,6 +337,8 @@ impl FusionAttestationBuilder { adapters_generated: stats.adapter_functions, imports_resolved: stats.imports_resolved, size_reduction_percent: size_reduction, + parameters: self.parameters, + boundaries: stats.boundaries.clone(), }, } } @@ -601,50 +678,68 @@ mod tests { ); } - /// SR-28: Config completeness — every FuserConfig field must be recorded - /// in the attestation metadata so auditors can reconstruct the exact - /// configuration used for fusion. - /// - /// This test builds an attestation via the builder (which mirrors the - /// non-wsc path) and separately checks that the metadata struct captures - /// the memory_strategy field. For the wsc-attestation path, the test - /// verifies that all expected config keys are present in a tool_parameters - /// map built inline (mirroring the pattern from `build_wsc_attestation`). + /// SR-28: Config completeness — every `FuserConfig` field must be recorded + /// in the attestation so auditors can reconstruct the exact configuration. /// - /// If a new field is added to FuserConfig but not recorded here, this - /// test must be updated — acting as a sentinel for config completeness. + /// **The primary guarantee is now compile-time**, not this test: + /// `Fuser::attestation_parameters` destructures `FuserConfig` + /// EXHAUSTIVELY, so adding a field there fails the build until it is + /// recorded or explicitly acknowledged. This test complements it by + /// asserting against the REAL serialized attestation — the previous version + /// asserted against a map it built inline, which is why it never noticed + /// that `pack_rebase`, `share_stack` and `profile` went unrecorded, and that + /// the shipped (non-wsc) path emitted no parameters at all. #[test] fn test_sr28_config_completeness() { - // All FuserConfig fields that must appear in attestation metadata. - // If you add a new field to FuserConfig, add it here too. - let required_keys = [ + let params = FusionParameters { + profile: "safety".to_string(), + memory_strategy: "multi".to_string(), + address_rebasing: false, + pack_rebase: true, + share_stack: true, + preserve_names: false, + custom_sections: "merge".to_string(), + dwarf_handling: "strip".to_string(), + output_format: "core-module".to_string(), + reproducible: true, + }; + let stats = FusionStats::default(); + let attestation = FusionAttestationBuilder::new("meld", "0.1.0") + .memory_strategy("multi") + .parameters(params) + .add_input(b"test", "test.wasm", 1) + .build(b"output", &stats); + + // Assert on the SERIALIZED form — what an auditor actually reads. + let json = attestation.to_json().expect("attestation serializes"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + let recorded = parsed + .get("metadata") + .and_then(|m| m.get("parameters")) + .expect("shipped attestation records the build parameters"); + + for key in [ + "profile", "memory_strategy", "address_rebasing", + "pack_rebase", + "share_stack", "preserve_names", "custom_sections", "dwarf_handling", "output_format", - ]; - - // Build a tool_parameters map the same way build_wsc_attestation does. - let mut tool_parameters = std::collections::HashMap::new(); - tool_parameters.insert("memory_strategy".to_string(), serde_json::json!("multi")); - tool_parameters.insert("address_rebasing".to_string(), serde_json::json!(false)); - tool_parameters.insert("preserve_names".to_string(), serde_json::json!(false)); - tool_parameters.insert("custom_sections".to_string(), serde_json::json!("merge")); - tool_parameters.insert("dwarf_handling".to_string(), serde_json::json!("strip")); - tool_parameters.insert( - "output_format".to_string(), - serde_json::json!("core-module"), - ); - - for key in &required_keys { + "reproducible", + ] { assert!( - tool_parameters.contains_key(*key), - "Missing FuserConfig field in attestation tool_parameters: '{key}'. \ - If you added a new config field, record it in build_wsc_attestation too." + recorded.get(key).is_some(), + "attestation parameters must record `{key}` (add it to \ + FusionParameters + Fuser::attestation_parameters): {recorded}" ); } + // Values must round-trip, not merely be present. + assert_eq!(recorded.get("profile").unwrap(), "safety"); + assert_eq!(recorded.get("share_stack").unwrap(), true); + assert_eq!(recorded.get("pack_rebase").unwrap(), true); // Also verify via the built-in metadata struct (non-wsc path). let stats = FusionStats::default(); diff --git a/meld-core/src/lib.rs b/meld-core/src/lib.rs index 0387924..019b9e9 100644 --- a/meld-core/src/lib.rs +++ b/meld-core/src/lib.rs @@ -341,6 +341,43 @@ pub enum DwarfHandling { Remap, } +/// What meld chose for ONE fused cross-component call boundary, and how it +/// wired it (ADR-7: per-boundary strategy **declared, attested, observable**). +/// +/// One record per entry in `DependencyGraph::adapter_sites`, emitted in that +/// vector's order — which the resolver sorts into a total order +/// (`sort_adapter_sites_for_determinism`), so the records are deterministic and +/// safe to serialize under `--reproducible` without further sorting. +/// +/// Names here are *content* (WIT interface / function names), never filesystem +/// paths, so they carry no build-environment nondeterminism. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BoundaryRecord { + /// Caller side. + pub from_component: usize, + pub from_module: usize, + /// Callee side. + pub to_component: usize, + pub to_module: usize, + /// The callee export this boundary calls. + pub function: String, + /// The caller's import module name (disambiguates two interfaces exporting + /// the same function name). + pub interface: String, + /// The call-lowering strategy: `direct`, `memory-copy`, `transcode` or + /// `async-lift`. This is the ABI decision — what the boundary costs. + pub lowering: String, + /// How the call was ultimately wired: `inlined-direct` (the caller calls the + /// callee with nothing interposed, #304), `widening-wrapper`, or `thunk`. + /// + /// Recorded at the WIRING step, not from the lowering seam's + /// `inline_eligible`: a widening wrapper takes precedence over inlining, so + /// eligibility alone would misreport what actually shipped. + pub wiring: String, + /// Whether the boundary crosses a memory (the fact that forces a copy). + pub crosses_memory: bool, +} + /// Statistics about the fusion process #[derive(Debug, Clone, Default)] pub struct FusionStats { @@ -378,6 +415,15 @@ pub struct FusionStats { /// "multi"). With `MemoryStrategy::Auto` this reports the resolution /// outcome (#172), so callers can tell the user what was selected. pub memory_strategy: String, + + /// ADR-7: one record per fused cross-component call boundary, in + /// `adapter_sites` order (deterministic — the resolver sorts it). Populated + /// at the wiring step, so `wiring` reports what actually shipped rather than + /// what was merely eligible. Surfaced two ways: embedded in the fusion + /// attestation (auditable after the fact — and outside the hashed bytes, so + /// it does not perturb the artifact hash) and printed by `meld fuse + /// --explain`. + pub boundaries: Vec, } /// Main fuser interface for static component fusion @@ -983,7 +1029,10 @@ impl Fuser { // re-rewrite the affected function bodies so that call sites go through // the adapter trampolines instead of calling the target directly. if !adapters.is_empty() { - stats.adapters_inlined = self.wire_adapter_indices(&mut merged, &adapters, &graph)?; + let (inlined, boundaries) = + self.wire_adapter_indices(&mut merged, &adapters, &graph)?; + stats.adapters_inlined = inlined; + stats.boundaries = boundaries; } // Step 3.6: #334 MCU-dissolve fixups (SR-49, `--memory shared` only). @@ -1131,10 +1180,12 @@ impl Fuser { merged: &mut merger::MergedModule, adapters: &[adapter::AdapterFunction], graph: &resolver::DependencyGraph, - ) -> Result { + ) -> Result<(usize, Vec)> { // #304: count of identity trampolines inlined away (caller wired // straight to the target instead of through the thunk). let mut inlined_count = 0usize; + // ADR-7: per-boundary strategy records, in `adapter_sites` order. + let mut boundaries: Vec = Vec::with_capacity(adapters.len()); use std::collections::{HashMap, HashSet}; use wasm_encoder::{Function, Instruction, ValType}; @@ -1212,14 +1263,40 @@ impl Fuser { // precedence; the two are mutually exclusive since an identity // forward has no result widening, but order defensively.) The // bypassed thunk is left unreferenced for loom to DCE. - let target_idx = if let Some(&wrapper_idx) = adapter_to_wrapper.get(&adapter_offset) { - wrapper_idx - } else if let Some(inline_target) = adapter.inline_target { - inlined_count += 1; - inline_target - } else { - adapter_base + adapter_offset as u32 - }; + let (target_idx, wiring) = + if let Some(&wrapper_idx) = adapter_to_wrapper.get(&adapter_offset) { + (wrapper_idx, "widening-wrapper") + } else if let Some(inline_target) = adapter.inline_target { + inlined_count += 1; + (inline_target, "inlined-direct") + } else { + (adapter_base + adapter_offset as u32, "thunk") + }; + + // ADR-7 per-boundary record. Built HERE because this is the only + // point that knows the wiring OUTCOME (a widening wrapper outranks + // inlining, so the seam's `inline_eligible` would misreport it). + boundaries.push(BoundaryRecord { + from_component: adapter.source_component, + from_module: adapter.source_module, + to_component: adapter.target_component, + to_module: adapter.target_module, + function: site.export_name.clone(), + interface: site.import_module.clone(), + lowering: match adapter.class { + adapter::AdapterClass::Direct => "direct", + adapter::AdapterClass::MemoryCopy => "memory-copy", + adapter::AdapterClass::Transcode => "transcode", + // Async sites bypass `resolve_call_lowering_plan` entirely + // (they branch earlier in the generator), so this is the + // honest label rather than a lowering-plan class. + adapter::AdapterClass::Async => "async-lift", + } + .to_string(), + wiring: wiring.to_string(), + crosses_memory: site.crosses_memory, + }); + let comp_idx = adapter.source_component; let mod_idx = adapter.source_module; let module = &self.components[comp_idx].core_modules[mod_idx]; @@ -1365,7 +1442,7 @@ impl Fuser { inlined_count ); - Ok(inlined_count) + Ok((inlined_count, boundaries)) } /// Generate task.return shim functions for internal fused async calls. @@ -2203,7 +2280,8 @@ impl Fuser { ) -> attestation::FusionAttestation { let mut builder = FusionAttestationBuilder::new("meld", env!("CARGO_PKG_VERSION")) .memory_strategy(self.memory_strategy_label()) - .reproducible(self.config.reproducible); + .reproducible(self.config.reproducible) + .parameters(self.attestation_parameters()); for (index, component) in self.components.iter().enumerate() { // #341: under `--reproducible` the input name must not carry the @@ -2330,6 +2408,16 @@ impl Fuser { "imports_resolved".to_string(), serde_json::json!(stats.imports_resolved), ); + // ADR-7 per-boundary records — kept in step with the default + // `build_attestation` path (the two are hand-duplicated, so a field + // added to one must be added to the other or the wsc build silently + // drops it). The records themselves serialize deterministically (a Vec + // in sorted `adapter_sites` order); the surrounding wsc `metadata` map + // is the already-documented non-reproducible part of this path. + metadata.insert( + "boundaries".to_string(), + serde_json::json!(stats.boundaries), + ); let size_reduction = if stats.input_size > 0 { ((stats.input_size as f64 - stats.output_size as f64) / stats.input_size as f64) * 100.0 } else { @@ -2371,6 +2459,64 @@ impl Fuser { } } + /// SR-28: the ADR-7 profile as an attestation label. + fn profile_label(&self) -> &'static str { + match self.config.profile { + Profile::Ecosystem => "ecosystem", + Profile::Safety => "safety", + } + } + + /// SR-28: the build configuration, recorded so an auditor holding only the + /// artifact can reconstruct how it was fused. Typed (not a map) so the JSON + /// key order is deterministic under `--reproducible`. + fn attestation_parameters(&self) -> attestation::FusionParameters { + // SR-28 completeness, enforced by the COMPILER rather than by a test + // that can drift: this destructure is exhaustive (no `..`), so adding a + // field to `FuserConfig` fails the build here until the author either + // records it below or explicitly acknowledges why it is not a build + // parameter. The previous sentinel asserted against a map it built + // itself, so it could not catch a field going unrecorded — and several + // had (`pack_rebase`, `share_stack`, `profile`). + let FuserConfig { + // Recorded via label helpers (they normalise the enum spelling). + profile: _, + memory_strategy: _, + custom_sections: _, + dwarf_handling: _, + output_format: _, + // Recorded directly. + reproducible, + address_rebasing, + pack_rebase, + share_stack, + preserve_names, + // NOT build parameters, deliberately: + // `attestation` decides whether this record exists at all — a + // record cannot meaningfully attest its own absence; + // `component_provenance` selects a *separate* custom section that + // is self-describing when present; + // `opaque_resources` is per-resource routing input, not a + // whole-build switch (and can carry user-supplied names). + attestation: _, + component_provenance: _, + opaque_resources: _, + } = &self.config; + + attestation::FusionParameters { + profile: self.profile_label().to_string(), + memory_strategy: self.memory_strategy_label().to_string(), + address_rebasing: *address_rebasing, + pack_rebase: *pack_rebase, + share_stack: *share_stack, + preserve_names: *preserve_names, + custom_sections: self.custom_sections_label().to_string(), + dwarf_handling: self.dwarf_handling_label().to_string(), + output_format: self.output_format_label().to_string(), + reproducible: *reproducible, + } + } + fn memory_strategy_label(&self) -> &'static str { match self.config.memory_strategy { MemoryStrategy::SharedMemory => "shared", @@ -2381,7 +2527,6 @@ impl Fuser { } } - #[cfg(feature = "attestation")] fn custom_sections_label(&self) -> &'static str { match self.config.custom_sections { CustomSectionHandling::Merge => "merge", @@ -2390,7 +2535,6 @@ impl Fuser { } } - #[cfg(feature = "attestation")] fn dwarf_handling_label(&self) -> &'static str { match self.config.dwarf_handling { DwarfHandling::Strip => "strip", @@ -2399,7 +2543,6 @@ impl Fuser { } } - #[cfg(feature = "attestation")] fn output_format_label(&self) -> &'static str { match self.config.output_format { OutputFormat::CoreModule => "core-module", diff --git a/meld-core/tests/boundary_records_p1.rs b/meld-core/tests/boundary_records_p1.rs new file mode 100644 index 0000000..05bf1e1 --- /dev/null +++ b/meld-core/tests/boundary_records_p1.rs @@ -0,0 +1,166 @@ +//! ADR-7 P1 — per-boundary strategy records: **declared, attested, observable**. +//! +//! ADR-7 made it a binding requirement that each fused boundary's strategy be +//! recorded and auditable, not inferable only from aggregate counts. meld now +//! emits one `BoundaryRecord` per entry in `DependencyGraph::adapter_sites`, +//! carrying the call-lowering class chosen for that boundary and — critically — +//! how it was ACTUALLY wired. +//! +//! The wiring field is recorded at the wiring step rather than from the lowering +//! seam's `inline_eligible`, because a widening wrapper takes precedence over +//! inlining: eligibility alone would misreport what shipped. +//! +//! Pinned here: +//! 1. one record per adapter site, in that (deterministically sorted) order; +//! 2. the record describes a real boundary (endpoints, function, lowering, +//! wiring), and `adapters_inlined` agrees with the records; +//! 3. the records reach the fusion attestation embedded in the artifact; +//! 4. they are stable across runs (safe under `--reproducible`). + +use meld_core::{Fuser, FuserConfig, MemoryStrategy}; + +/// The wac-composed consumer→provider fixture (a genuine cross-component call). +fn composed_fixture() -> Option> { + let path = format!( + "{}/../tests/wit_bindgen/fixtures/compose/composed.wasm", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(path).ok() +} + +fn fuse(bytes: &[u8]) -> (Vec, meld_core::FusionStats) { + let config = FuserConfig { + memory_strategy: MemoryStrategy::MultiMemory, + reproducible: true, + ..Default::default() + }; + let mut fuser = Fuser::new(config); + fuser.add_component_named(bytes, Some("composed")).unwrap(); + fuser.fuse_with_stats().expect("fusion") +} + +#[test] +fn every_fused_boundary_is_recorded_with_its_strategy() { + let Some(bytes) = composed_fixture() else { + eprintln!("composed.wasm fixture absent — skipping"); + return; + }; + let (_out, stats) = fuse(&bytes); + + // (1) A cross-component composition must produce at least one boundary, and + // one record per generated adapter (records are emitted per adapter site). + assert!( + !stats.boundaries.is_empty(), + "a wac-composed consumer->provider fusion must record a boundary" + ); + assert_eq!( + stats.boundaries.len(), + stats.adapter_functions, + "exactly one boundary record per generated adapter" + ); + + // (2) Each record describes a real boundary with a known strategy. + for b in &stats.boundaries { + assert!( + !b.function.is_empty(), + "boundary must name the callee function: {b:?}" + ); + assert!( + matches!( + b.lowering.as_str(), + "direct" | "memory-copy" | "transcode" | "async-lift" + ), + "unknown lowering label {:?}", + b.lowering + ); + assert!( + matches!( + b.wiring.as_str(), + "inlined-direct" | "widening-wrapper" | "thunk" + ), + "unknown wiring label {:?}", + b.wiring + ); + } + + // The inline COUNT must agree with the records — this is what catches the + // eligibility-vs-outcome confusion the wiring-step recording exists to avoid. + let inlined_records = stats + .boundaries + .iter() + .filter(|b| b.wiring == "inlined-direct") + .count(); + assert_eq!( + inlined_records, stats.adapters_inlined, + "records must agree with the inlined tally (outcome, not eligibility)" + ); +} + +#[test] +fn boundary_records_reach_the_attestation() { + let Some(bytes) = composed_fixture() else { + eprintln!("composed.wasm fixture absent — skipping"); + return; + }; + let (out, stats) = fuse(&bytes); + assert!( + !stats.boundaries.is_empty(), + "expected a boundary to attest" + ); + + // Pull the attestation custom section back out of the artifact — the + // "auditable after the fact" half of the requirement. + let mut attestation_json: Option = None; + for payload in wasmparser::Parser::new(0).parse_all(&out) { + if let wasmparser::Payload::CustomSection(reader) = payload.expect("payload") + && reader.name() == "wsc.transformation.attestation" + { + attestation_json = Some(String::from_utf8_lossy(reader.data()).into_owned()); + } + } + let json = attestation_json.expect("fused artifact carries an attestation section"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("attestation is valid JSON"); + + let boundaries = parsed + .get("metadata") + .and_then(|m| m.get("boundaries")) + .and_then(|b| b.as_array()) + .expect("attestation metadata carries boundaries"); + assert_eq!( + boundaries.len(), + stats.boundaries.len(), + "every recorded boundary must be attested" + ); + + let first = &boundaries[0]; + for key in [ + "from_component", + "to_component", + "function", + "lowering", + "wiring", + "crosses_memory", + ] { + assert!( + first.get(key).is_some(), + "attested boundary must carry `{key}`: {first}" + ); + } +} + +#[test] +fn boundary_records_are_stable_across_runs() { + let Some(bytes) = composed_fixture() else { + eprintln!("composed.wasm fixture absent — skipping"); + return; + }; + // `adapter_sites` is sorted into a total order by the resolver, so records + // emitted in that order must be identical run to run — otherwise embedding + // them in the attestation would break `--reproducible`. + let (_, a) = fuse(&bytes); + let (_, b) = fuse(&bytes); + assert_eq!( + a.boundaries, b.boundaries, + "boundary records must be deterministic (they are serialized into the attestation)" + ); +} diff --git a/safety/requirements/safety-requirements.yaml b/safety/requirements/safety-requirements.yaml index 2c98754..5857f9c 100644 --- a/safety/requirements/safety-requirements.yaml +++ b/safety/requirements/safety-requirements.yaml @@ -2932,3 +2932,73 @@ artifacts: references: - "https://github.com/pulseengine/meld/issues/386" - "meld-core/tests/safety_profile_386.rs" + + - id: SR-69 + type: sw-req + title: Per-boundary strategy and build configuration are attested and observable + description: > + ADR-7 binds meld to make each fused boundary's strategy DECLARED, ATTESTED + and OBSERVABLE. meld shall (a) record one entry per fused cross-component + call boundary — caller/callee component+module, the callee function and + interface, the call-lowering class chosen (`direct` / `memory-copy` / + `transcode` / `async-lift`) and how the call was ACTUALLY wired + (`inlined-direct` / `widening-wrapper` / `thunk`) — and (b) record the + build configuration that shaped the artifact. Both shall be embedded in the + fusion attestation carried by the output, and (a) shall additionally be + printable via `meld fuse --explain`. + The wiring field shall be captured at the WIRING step, not from the + call-lowering seam's `inline_eligible`: a widening wrapper takes precedence + over inlining, so eligibility alone would misreport what shipped. + Records shall be emitted in `adapter_sites` order, which the resolver sorts + into a total order, and the configuration shall be a TYPED structure rather + than a map, so both serialize deterministically and `--reproducible` output + stays byte-stable. Neither perturbs the artifact hash, which is computed + over the module with the attestation and provenance sections stripped. + This CORRECTS a traceability defect: SR-28 requires every `FuserConfig` + field to be recorded so an auditor can reconstruct the build, but the + parameter set was gated behind the optional `attestation` (wsc) feature, + which releases do NOT build — so shipped artifacts recorded no + configuration beyond the memory strategy. `--share-stack` (a documented + soundness envelope), `--pack-rebase`, `--address-rebase` and `--profile` + were all unattested. The SR-28 sentinel could not catch it because it + asserted against a map built inline in the test rather than real output. + SR-28 completeness shall therefore be enforced at COMPILE TIME: the + parameter-building function destructures `FuserConfig` exhaustively, so + adding a field fails the build until it is recorded or explicitly + acknowledged as not a build parameter. + status: implemented + tags: [attestation, adr-7, traceability, observability, boundary, config] + release: v0.51.0 + links: + - type: derives-from + target: SYS-6 + - type: refines + target: SR-28 + cited-source: + - uri: "https://github.com/pulseengine/meld/issues/386" + kind: github + last-checked: 2026-08-19 + fields: + implementation: + - meld-core/src/lib.rs + - meld-core/src/attestation.rs + - meld-cli/src/main.rs + verification-method: test + verification-description: > + Verified by meld-core/tests/boundary_records_p1.rs (3 tests): one record + per generated adapter with valid lowering/wiring labels AND agreement + between the `inlined-direct` records and `stats.adapters_inlined` (the + check that catches eligibility-vs-outcome confusion); records recovered + from the attestation custom section of the fused artifact with every + expected key present; and byte-stability of the records across two runs. + Configuration attestation verified by the strengthened + attestation::tests::test_sr28_config_completeness, which now asserts + against the REAL serialized attestation (all ten parameter keys present, + values round-tripping) instead of a map built inline. The compile-time + completeness guarantee was demonstrated by adding a probe field to + `FuserConfig` and observing `error[E0027]: pattern does not mention + field` at the sentinel, then removing it. Both feature configurations + green (`--workspace` 858/0, `--all-features` 857/0). + references: + - "https://github.com/pulseengine/meld/issues/386" + - "meld-core/tests/boundary_records_p1.rs" diff --git a/safety/requirements/sw-verifications.yaml b/safety/requirements/sw-verifications.yaml index 2cbfbc9..60fe272 100644 --- a/safety/requirements/sw-verifications.yaml +++ b/safety/requirements/sw-verifications.yaml @@ -1202,3 +1202,22 @@ artifacts: links: - type: verifies target: SR-68 + + - id: SWV-81 + type: sw-verification + title: "Verification of SR-69: per-boundary + configuration attestation" + description: > + Verifies SR-69 via meld-core/tests/boundary_records_p1.rs (3 tests: one + record per adapter with valid labels and inlined-count agreement; records + recovered from the artifact's attestation section; byte-stability across + runs) plus the strengthened attestation::tests::test_sr28_config_ + completeness, which asserts against the real serialized attestation rather + than an inline map. SR-28 completeness is additionally enforced at compile + time by an exhaustive `FuserConfig` destructure (demonstrated to fire via a + probe field). Both feature configurations green. + status: implemented + fields: + method: automated-test + links: + - type: verifies + target: SR-69