diff --git a/Cargo.lock b/Cargo.lock index 76fbb01..16e6961 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1405,6 +1405,16 @@ dependencies = [ "spar-hir-def", ] +[[package]] +name = "spar-trace-topology" +version = "0.9.2" +dependencies = [ + "spar-base-db", + "spar-hir-def", + "spar-syntax", + "tempfile", +] + [[package]] name = "spar-transform" version = "0.9.2" diff --git a/Cargo.toml b/Cargo.toml index c6dfce7..cc7efab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/spar-verify", "crates/spar-wasm", "crates/spar-insight", + "crates/spar-trace-topology", ] [workspace.package] @@ -45,6 +46,7 @@ spar-transform = { path = "crates/spar-transform" } spar-variants = { path = "crates/spar-variants" } spar-codegen = { path = "crates/spar-codegen" } spar-insight = { path = "crates/spar-insight" } +spar-trace-topology = { path = "crates/spar-trace-topology" } rowan = "0.16" salsa = "0.26" la-arena = "0.3" diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index e68340b..e2eb7e9 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -1731,4 +1731,52 @@ artifacts: status: implemented tags: [mcp, migration, track-e, v090, integration] + # ── Track G — v0.10.0 trace-topology foundation ────────────────────── + + - id: REQ-TRACE-TOPOLOGY-001 + type: requirement + title: Spar_Identity property surface + description: > + System shall expose a Spar_Identity property set that lets + AADL declare runtime-observable identities for the v0.10.0+ + `spar trace topology` reconciler. The set carries six + properties: MAC_Address (aadlstring; device, processor), + VLAN_ID (aadlinteger 0 .. 4094; connection, bus), + Stream_Handle (aadlinteger; connection), Multicast_Group + (aadlstring; connection), LLDP_Chassis_Id (aadlstring; + device, processor, bus), and LLDP_Port_Id (aadlstring; bus + access feature). The set is registered as a predefined + (non-AS5506) property set so AADL annotations resolve without + explicit `with` imports, mirroring Spar_TSN, Spar_Network, + Spar_Migration, and Spar_Power. Per the v0.10.0 trace-topology + foundation design (docs/designs/v0.10.0-trace-topology.md §3). + status: planned + tags: [trace-topology, track-g, v0100, properties] + + - id: REQ-TRACE-TOPOLOGY-002 + type: requirement + title: spar-trace-topology crate skeleton + description: > + System shall expose a spar-trace-topology crate carrying the + foundation surface for runtime/declared topology + reconciliation. v0.10.0 ships four modules: `identity` (typed + accessors for the six Spar_Identity properties, typed-first + / string-fallback per the existing Spar_TSN pattern), + `ingest` (placeholder traits FrameSource / TopologySource / + SwitchConfigSource / PtpTimeSource for the v0.10.x parsers), + `reconcile` (ReconcileFinding enum with the five + deterministic check kinds — IdentityUnknown, + TopologyMissingWiring, ConfigDrift, GptpOutOfBudget, + BinaryMismatch), and `report` (TopologyReport struct + aggregating findings). v0.10.0 ships no real parsing, no + reconciliation logic, no SARIF emission, and no in-toto + attestation; those land in v0.10.x sibling commits and + v0.11.0 / v1.0 respectively per the design doc's + §"Implementation phasing". The in-toto attestation predicate + URL is `https://pulseengine.eu/spar-trace-topology/v1`. Per + docs/designs/v0.10.0-trace-topology.md and the companion + external-integrator contract docs/contracts/spar-trace-topology-v1.md. + status: planned + tags: [trace-topology, track-g, v0100, crate] + # Research findings tracked separately in research/findings.yaml diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 3dea92f..7bf704f 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -2275,3 +2275,38 @@ artifacts: target: REQ-MIGRATION-006 - type: satisfies target: REQ-MIGRATION-008 + + # ── Track G — v0.10.0 trace-topology foundation ───────────────────── + + - id: TEST-TRACE-TOPOLOGY-IDENTITY + type: feature + title: Spar_Identity property surface + spar-trace-topology accessors + description: > + Verifies that the v0.10.0 Spar_Identity property surface and + the spar-trace-topology crate's identity-accessor surface are + registered and reachable. spar-hir-def standard_properties + tests assert that Spar_Identity appears in + STANDARD_PROPERTY_SET_NAMES with the expected six properties + (MAC_Address, VLAN_ID, Stream_Handle, Multicast_Group, + LLDP_Chassis_Id, LLDP_Port_Id), that each property resolves + via GlobalScope without explicit `with` imports, and that the + total predeclared property count is 137 (was 131 before this + commit). spar-trace-topology integration tests + (tests/identity_accessors.rs) verify the six typed accessors + (get_mac_address, get_vlan_id, get_stream_handle, + get_multicast_group, get_lldp_chassis_id, get_lldp_port_id) + read both typed PropertyExpr values and string-fallback + values from a PropertyMap, and that VLAN_ID rejects values + outside 0..=4094 (including the reserved 4095) on both paths. + fields: + method: automated-test + steps: + - run: cargo test -p spar-hir-def --lib -- standard_properties + - run: cargo test -p spar-trace-topology + status: passing + tags: [v0.10.0, trace-topology, track-g, properties] + links: + - type: satisfies + target: REQ-TRACE-TOPOLOGY-001 + - type: satisfies + target: REQ-TRACE-TOPOLOGY-002 diff --git a/crates/spar-hir-def/src/standard_properties.rs b/crates/spar-hir-def/src/standard_properties.rs index 8655795..8e7628b 100644 --- a/crates/spar-hir-def/src/standard_properties.rs +++ b/crates/spar-hir-def/src/standard_properties.rs @@ -40,6 +40,7 @@ pub const STANDARD_PROPERTY_SET_NAMES: &[&str] = &[ "Spar_Migration", "Spar_Power", "Spar_TSN", + "Spar_Identity", ]; // ── Timing_Properties ─────────────────────────────────────────────── @@ -529,6 +530,51 @@ const SPAR_TSN: &[(&str, &str)] = &[ ("Lo_Credit", "aadlinteger units Size_Units"), ]; +// ── Spar_Identity ─────────────────────────────────────────────────── +// +// Non-standard property set defined by spar itself (not AS5506); used +// for runtime/declared topology reconciliation (v0.10.0 trace-topology +// foundation, Track G). Provides the AADL vocabulary that lets spar +// match an OEM's runtime artefacts (PCAPNG captures, LLDP topology +// snapshots, Qcc YANG configs, gPTP logs) against the AADL declaration +// of "what should be on the wire" so the v0.11.0 reconciliation engine +// can emit the five deterministic checks +// (IdentityUnknown, TopologyMissingWiring, ConfigDrift, +// GptpOutOfBudget, BinaryMismatch) and the v1.0 SARIF + signed +// in-toto attestation artefact. +// +// v0.10.0 ships the property surface only; the parsers and the +// reconciliation engine land in subsequent commits. See +// `docs/designs/v0.10.0-trace-topology.md` (the v1 design doc) and +// `docs/contracts/spar-trace-topology-v1.md` (the external-integrator +// contract). + +const SPAR_IDENTITY: &[(&str, &str)] = &[ + // L2 MAC address of a device or processor — the canonical identity + // a PCAPNG capture and LLDP snapshot will report. Applies to + // device, processor. + ("MAC_Address", "aadlstring"), + // 802.1Q VLAN ID carried by frames on a connection or bus + // (`0..4094` per 802.1Q-2022, with 0 meaning "priority-tagged, no + // VLAN" and 4095 reserved). Applies to connection, bus. + ("VLAN_ID", "aadlinteger 0 .. 4094"), + // 802.1Qcc CB stream-handle for a reserved stream. The Qcc YANG + // config declares the same handle; reconciliation matches against + // this value. Applies to connection. + ("Stream_Handle", "aadlinteger"), + // L2 destination multicast group MAC — for multicast streams the + // Qcc reservation declares the destination MAC; PCAPNG captures + // observe it. Applies to connection. + ("Multicast_Group", "aadlstring"), + // LLDP chassis-id of a device, processor, or bus endpoint as + // reported by the runtime LLDP topology snapshot (typically a MAC + // or interface-name). Applies to device, processor, bus. + ("LLDP_Chassis_Id", "aadlstring"), + // LLDP port-id of a bus access feature as reported by the runtime + // LLDP snapshot. Applies to bus access (feature-level). + ("LLDP_Port_Id", "aadlstring"), +]; + /// Helper: collect properties from a table into the result vector. fn collect_properties( table: &[(&'static str, &'static str)], @@ -570,6 +616,7 @@ pub fn all_standard_properties() -> Vec { collect_properties(SPAR_MIGRATION, "Spar_Migration", &mut result); collect_properties(SPAR_POWER, "Spar_Power", &mut result); collect_properties(SPAR_TSN, "Spar_TSN", &mut result); + collect_properties(SPAR_IDENTITY, "Spar_Identity", &mut result); result } @@ -601,6 +648,7 @@ fn lookup_table(set_lower: &str) -> Option<&'static [(&'static str, &'static str "spar_migration" => Some(SPAR_MIGRATION), "spar_power" => Some(SPAR_POWER), "spar_tsn" => Some(SPAR_TSN), + "spar_identity" => Some(SPAR_IDENTITY), _ => None, } } @@ -651,6 +699,7 @@ mod tests { assert!(is_standard_property_set("Spar_Migration")); assert!(is_standard_property_set("Spar_Power")); assert!(is_standard_property_set("Spar_TSN")); + assert!(is_standard_property_set("Spar_Identity")); // Case-insensitive assert!(is_standard_property_set("timing_properties")); @@ -1206,6 +1255,103 @@ mod tests { ); } + #[test] + fn test_standard_properties_in_spar_identity() { + // Spar_Identity is a known property set (v0.10.0 trace-topology + // foundation). + assert!(is_standard_property_set("Spar_Identity")); + + let props = standard_properties_in_set("Spar_Identity"); + assert_eq!(props.len(), 6); + assert!(props.contains(&"MAC_Address")); + assert!(props.contains(&"VLAN_ID")); + assert!(props.contains(&"Stream_Handle")); + assert!(props.contains(&"Multicast_Group")); + assert!(props.contains(&"LLDP_Chassis_Id")); + assert!(props.contains(&"LLDP_Port_Id")); + + // Each property resolves to its expected type. + assert_eq!( + standard_property_type("Spar_Identity", "MAC_Address"), + Some("aadlstring") + ); + assert_eq!( + standard_property_type("Spar_Identity", "VLAN_ID"), + Some("aadlinteger 0 .. 4094") + ); + assert_eq!( + standard_property_type("Spar_Identity", "Stream_Handle"), + Some("aadlinteger") + ); + assert_eq!( + standard_property_type("Spar_Identity", "Multicast_Group"), + Some("aadlstring") + ); + assert_eq!( + standard_property_type("Spar_Identity", "LLDP_Chassis_Id"), + Some("aadlstring") + ); + assert_eq!( + standard_property_type("Spar_Identity", "LLDP_Port_Id"), + Some("aadlstring") + ); + + // Deliberately-wrong name returns None. + assert_eq!(standard_property_type("Spar_Identity", "Nonexistent"), None); + + // Case-insensitive. + assert_eq!( + standard_property_type("spar_identity", "mac_address"), + Some("aadlstring") + ); + assert_eq!( + standard_property_type("SPAR_IDENTITY", "VLAN_ID"), + Some("aadlinteger 0 .. 4094") + ); + } + + #[test] + fn test_spar_identity_property_set_resolved_via_global_scope() { + use crate::name::Name; + use crate::resolver::{GlobalScope, ResolvedProperty}; + + let scope = GlobalScope::from_trees(vec![]); + + // Each Spar_Identity property is resolvable without explicit `with`. + for prop_name in [ + "MAC_Address", + "VLAN_ID", + "Stream_Handle", + "Multicast_Group", + "LLDP_Chassis_Id", + "LLDP_Port_Id", + ] { + let result = scope.resolve_property(&Name::new("Spar_Identity"), &Name::new(prop_name)); + assert!( + matches!(result, ResolvedProperty::PropertyDef { .. }), + "expected PropertyDef for Spar_Identity::{}, got {:?}", + prop_name, + result + ); + } + + // Deliberately-wrong name inside a known spar set is Unresolved. + let result = scope.resolve_property(&Name::new("Spar_Identity"), &Name::new("Nonexistent")); + assert!( + matches!(result, ResolvedProperty::Unresolved), + "expected Unresolved for Spar_Identity::Nonexistent, got {:?}", + result + ); + + // Case-insensitive resolution. + let result = scope.resolve_property(&Name::new("spar_identity"), &Name::new("mac_address")); + assert!( + matches!(result, ResolvedProperty::PropertyDef { .. }), + "expected case-insensitive match for Spar_Identity::MAC_Address, got {:?}", + result + ); + } + #[test] fn test_standard_properties_unknown_set() { let props = standard_properties_in_set("Nonexistent_Properties"); @@ -1222,10 +1368,10 @@ mod tests { #[test] fn test_all_standard_properties_total_count() { let all = all_standard_properties(); - // 12 + 13 + 14 + 14 + 8 + 25 + 4 + 13 + 5 + 4 + 5 + 4 + 1 + 9 = 131 + // 12 + 13 + 14 + 14 + 8 + 25 + 4 + 13 + 5 + 4 + 5 + 4 + 1 + 9 + 6 = 137 // (Timing + Communication + Memory + Deployment + Thread + Programming // + Modeling + AADL_Project + Spar_Timing + Spar_Trace + Spar_Network - // + Spar_Migration + Spar_Power + Spar_TSN) + // + Spar_Migration + Spar_Power + Spar_TSN + Spar_Identity) // Thread_Properties: +1 for Locking_Protocol (v0.7.1 PIP/PCP). // Spar_Timing: +1 for Critical_Section_Blocking (v0.7.1 PIP/PCP). // Spar_Network: +1 for WCTT_Budget (Track D commit 4). @@ -1234,7 +1380,10 @@ mod tests { // Max_Frame_Size, Frame_Preemption (Track D Phase 2 v0.8.1 c1) // +1 for Bandwidth_Reservation (Track D Phase 2 v0.8.1 c3, CBS). // +1 for Sync_Error (v0.9.1 NC soundness, gPTP ε budget). - assert_eq!(all.len(), 131); + // Spar_Identity: +6 for MAC_Address, VLAN_ID, Stream_Handle, + // Multicast_Group, LLDP_Chassis_Id, LLDP_Port_Id (v0.10.0 + // trace-topology foundation, Track G). + assert_eq!(all.len(), 137); } #[test] diff --git a/crates/spar-trace-topology/Cargo.toml b/crates/spar-trace-topology/Cargo.toml new file mode 100644 index 0000000..084dc03 --- /dev/null +++ b/crates/spar-trace-topology/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "spar-trace-topology" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Runtime/declared topology reconciliation for spar (PCAPNG + LLDP + Qcc + AADL)" + +[dependencies] +spar-hir-def.workspace = true +spar-base-db.workspace = true +spar-syntax.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/spar-trace-topology/src/identity.rs b/crates/spar-trace-topology/src/identity.rs new file mode 100644 index 0000000..c34cd84 --- /dev/null +++ b/crates/spar-trace-topology/src/identity.rs @@ -0,0 +1,111 @@ +//! Typed accessors for the `Spar_Identity::*` property surface. +//! +//! Each accessor consults the typed [`PropertyExpr`] first and falls +//! back to the raw string blob, mirroring the pattern established by +//! `spar-network::tsn`. The accessors return `Option` so callers +//! can distinguish "absent" from "malformed". +//! +//! See `docs/designs/v0.10.0-trace-topology.md` §"Spar_Identity +//! property surface" for the property semantics and reconciliation +//! intent. + +use spar_hir_def::item_tree::PropertyExpr; +use spar_hir_def::properties::PropertyMap; + +const SPAR_IDENTITY: &str = "Spar_Identity"; + +fn get_typed<'a>(props: &'a PropertyMap, name: &str) -> Option<&'a PropertyExpr> { + props + .get_typed(SPAR_IDENTITY, name) + .or_else(|| props.get_typed("", name)) +} + +fn get_raw<'a>(props: &'a PropertyMap, name: &str) -> Option<&'a str> { + props + .get(SPAR_IDENTITY, name) + .or_else(|| props.get("", name)) +} + +/// Strip surrounding quote characters from an `aadlstring` raw form. +/// +/// String-fallback parsing sees the source-text-preserved value, which +/// for `aadlstring` properties typically retains the surrounding +/// double quotes. The typed path returns the unquoted contents +/// directly via `PropertyExpr::StringLit`. +fn unquote(s: &str) -> String { + s.trim().trim_matches('"').to_string() +} + +/// Read [`Spar_Identity::MAC_Address`] — the canonical L2 MAC of a +/// device or processor as observed by PCAPNG/LLDP. +/// +/// Returns the raw declared string (e.g. `"aa:bb:cc:dd:ee:ff"`); no +/// canonicalisation here — the v0.11.0 reconciliation engine +/// normalises before comparison. +pub fn get_mac_address(props: &PropertyMap) -> Option { + if let Some(PropertyExpr::StringLit(s)) = get_typed(props, "MAC_Address") { + return Some(s.clone()); + } + get_raw(props, "MAC_Address").map(unquote) +} + +/// Read [`Spar_Identity::VLAN_ID`] — the 802.1Q VLAN ID of a +/// connection or bus, range `0..=4094`. +/// +/// Values outside `0..=4094` (including the reserved `4095`) return +/// `None`. +pub fn get_vlan_id(props: &PropertyMap) -> Option { + if let Some(expr) = get_typed(props, "VLAN_ID") + && let PropertyExpr::Integer(v, _) = expr + && (0..=4094).contains(v) + { + return Some(*v as u16); + } + let raw = get_raw(props, "VLAN_ID")?; + let v: u16 = raw.trim().parse().ok()?; + if v <= 4094 { Some(v) } else { None } +} + +/// Read [`Spar_Identity::Stream_Handle`] — the 802.1Qcc CB stream +/// handle of a reserved connection. Returns `None` if the property +/// is unset, negative, or larger than `u32::MAX`. +pub fn get_stream_handle(props: &PropertyMap) -> Option { + if let Some(expr) = get_typed(props, "Stream_Handle") + && let PropertyExpr::Integer(v, _) = expr + && *v >= 0 + && *v <= u32::MAX as i64 + { + return Some(*v as u32); + } + let raw = get_raw(props, "Stream_Handle")?; + raw.trim().parse::().ok() +} + +/// Read [`Spar_Identity::Multicast_Group`] — the L2 destination +/// multicast MAC for a multicast stream. Raw declared form; no +/// canonicalisation. +pub fn get_multicast_group(props: &PropertyMap) -> Option { + if let Some(PropertyExpr::StringLit(s)) = get_typed(props, "Multicast_Group") { + return Some(s.clone()); + } + get_raw(props, "Multicast_Group").map(unquote) +} + +/// Read [`Spar_Identity::LLDP_Chassis_Id`] — the LLDP chassis-id of +/// a device, processor, or bus endpoint as reported by the runtime +/// LLDP topology snapshot. +pub fn get_lldp_chassis_id(props: &PropertyMap) -> Option { + if let Some(PropertyExpr::StringLit(s)) = get_typed(props, "LLDP_Chassis_Id") { + return Some(s.clone()); + } + get_raw(props, "LLDP_Chassis_Id").map(unquote) +} + +/// Read [`Spar_Identity::LLDP_Port_Id`] — the LLDP port-id of a bus +/// access feature as reported by the runtime LLDP snapshot. +pub fn get_lldp_port_id(props: &PropertyMap) -> Option { + if let Some(PropertyExpr::StringLit(s)) = get_typed(props, "LLDP_Port_Id") { + return Some(s.clone()); + } + get_raw(props, "LLDP_Port_Id").map(unquote) +} diff --git a/crates/spar-trace-topology/src/ingest.rs b/crates/spar-trace-topology/src/ingest.rs new file mode 100644 index 0000000..c41ff26 --- /dev/null +++ b/crates/spar-trace-topology/src/ingest.rs @@ -0,0 +1,102 @@ +//! Placeholder trait surface for runtime-artefact parsers. +//! +//! Real parsers land in v0.10.x sibling commits — PCAPNG (`pcap-parser` +//! crate or hand-rolled), LLDP (LLDP TLVs from frames or +//! lldpd-style YAML), Qcc YANG (`ieee802-dot1q-bridge`, +//! `ieee802-dot1q-tsn-types` schema), gPTP (linuxptp's `ptp4l` / +//! `pmc` JSON or CTF events). +//! +//! The trait shapes are minimal — concrete return types are +//! deliberately deferred (returning `()` rather than typed +//! envelopes) so the parsers can negotiate their own data +//! structures without churning this surface. v0.11.0 widens these +//! traits once the reconciliation engine settles on its working set. +//! +//! See `docs/designs/v0.10.0-trace-topology.md` §"Implementation +//! phasing" for the per-source roadmap. + +use std::path::Path; + +/// Source of L2 frames captured at runtime — typically a PCAPNG file +/// recorded with `tcpdump`, `tshark`, or a TAP/SPAN port. +/// +/// TODO(v0.10.0+): real parser — PCAPNG (RFC pcapng-draft / IETF +/// opsawg-pcapng). See design doc §"Input artefact set" for the full +/// list of supported link types and capture-options. +pub trait FrameSource { + /// Open the frame source at `path`. v0.10.0 placeholder — the + /// real parser returns an iterator of typed frames. + fn open(path: &Path) -> Result + where + Self: Sized; +} + +/// Source of LLDP topology snapshots — neighbor adjacency observed at +/// runtime via standard LLDP TLV exchange. Typical forms are +/// `lldpctl -f xml`, `lldpd`'s JSON dump, or per-frame extraction +/// from a PCAPNG that captured the LLDP multicast. +/// +/// TODO(v0.10.0+): real parser — IEEE 802.1AB-2016 (LLDP) TLV +/// decoding. See design doc §"Input artefact set" §LLDP. +pub trait TopologySource { + /// Open the topology source at `path`. v0.10.0 placeholder. + fn open(path: &Path) -> Result + where + Self: Sized; +} + +/// Source of switch configuration as declared by the deployed switch +/// — typically a Qcc YANG dump retrieved over NETCONF/RESTCONF or +/// `ieee802-dot1q-bridge` / `ieee802-dot1q-tsn-types`-shaped JSON. +/// +/// TODO(v0.10.0+): real parser — IEEE 802.1Qcc-2018 plus the +/// `ieee802-dot1q-tsn-types` and `ieee802-dot1q-stream-filters-and-policing` +/// YANG modules. See design doc §"Input artefact set" §Qcc YANG. +pub trait SwitchConfigSource { + /// Open the switch-config source at `path`. v0.10.0 placeholder. + fn open(path: &Path) -> Result + where + Self: Sized; +} + +/// Source of gPTP / IEEE 802.1AS synchronization-error observations +/// over the capture window — typically `ptp4l` summary logs, `pmc` +/// JSON dumps, or CTF events emitted by a Linux/Zephyr gPTP stack. +/// +/// TODO(v0.10.0+): real parser — IEEE 802.1AS-2020. The reconciler +/// uses these readings to evaluate the `GptpOutOfBudget` check +/// against `Spar_TSN::Sync_Error`. See design doc §"Input artefact +/// set" §gPTP. +pub trait PtpTimeSource { + /// Open the gPTP-time source at `path`. v0.10.0 placeholder. + fn open(path: &Path) -> Result + where + Self: Sized; +} + +/// Errors surfaced from a runtime-artefact parser. +/// +/// v0.10.0 ships an `Unimplemented` variant only — the foundation +/// crate carries no real I/O. v0.10.x parsers extend this enum with +/// the concrete I/O / format-decode kinds. +#[derive(Debug)] +pub enum IngestError { + /// The requested parser surface is not implemented in this + /// build of spar-trace-topology. v0.10.0 returns this from every + /// `open` call; v0.10.x parsers replace it with concrete kinds. + Unimplemented, +} + +impl core::fmt::Display for IngestError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Unimplemented => write!( + f, + "parser not implemented in v0.10.0 foundation; see \ + docs/designs/v0.10.0-trace-topology.md" + ), + } + } +} + +impl std::error::Error for IngestError {} diff --git a/crates/spar-trace-topology/src/lib.rs b/crates/spar-trace-topology/src/lib.rs new file mode 100644 index 0000000..ac6d719 --- /dev/null +++ b/crates/spar-trace-topology/src/lib.rs @@ -0,0 +1,43 @@ +//! Runtime/declared topology reconciliation for spar. +//! +//! v0.10.0 trace-topology foundation (Track G). This crate provides +//! the surface that, in subsequent commits, lets `spar trace topology` +//! consume the runtime artefact set an OEM produces from a real +//! deployment — PCAPNG captures, LLDP topology snapshots, Qcc YANG +//! switch configs, tc/ethtool dumps, gPTP synchronization logs — and +//! reconcile them against the AADL declaration of "what should be on +//! the wire". +//! +//! The v1 design — input artefacts, the five deterministic checks +//! (`IdentityUnknown`, `TopologyMissingWiring`, `ConfigDrift`, +//! `GptpOutOfBudget`, `BinaryMismatch`), the SARIF + signed in-toto +//! attestation output shape, and the implementation phasing — is laid +//! out in `docs/designs/v0.10.0-trace-topology.md`. The +//! external-integrator contract (predicate URL, JSON schema reference, +//! stability promise) lives in `docs/contracts/spar-trace-topology-v1.md`. +//! +//! v0.10.0 ships only the foundation: +//! +//! - The [`identity`] module exposes typed accessors for the new +//! `Spar_Identity::*` property surface (`MAC_Address`, `VLAN_ID`, +//! `Stream_Handle`, `Multicast_Group`, `LLDP_Chassis_Id`, +//! `LLDP_Port_Id`). +//! - The [`ingest`] module declares trait skeletons for the four +//! parsers — frame source (PCAPNG), topology source (LLDP), switch +//! config source (Qcc YANG), and PTP-time source (gPTP). Real +//! parsing lands in v0.10.x sibling commits. +//! - The [`reconcile`] module declares the `ReconcileFinding` enum +//! carrying the five deterministic check kinds. The reconciliation +//! engine itself ships in v0.11.0. +//! - The [`report`] module declares the `TopologyReport` struct that +//! collects findings. SARIF emission and the signed in-toto +//! attestation predicate (`https://pulseengine.eu/spar-trace-topology/v1`) +//! land in v0.11.0 / v1.0 respectively. +//! +//! Out of scope for v1: PCAP-classic, BLF, OPC-UA, deep packet +//! inspection. See the design doc §"Out-of-scope for v1". + +pub mod identity; +pub mod ingest; +pub mod reconcile; +pub mod report; diff --git a/crates/spar-trace-topology/src/reconcile.rs b/crates/spar-trace-topology/src/reconcile.rs new file mode 100644 index 0000000..893e0b4 --- /dev/null +++ b/crates/spar-trace-topology/src/reconcile.rs @@ -0,0 +1,103 @@ +//! Reconciliation finding type surface. +//! +//! v0.10.0 declares the [`ReconcileFinding`] enum but ships no +//! reconciliation logic — the engine that emits these findings lands +//! in v0.11.0. The five variants correspond to the five deterministic +//! checks described in `docs/designs/v0.10.0-trace-topology.md` +//! §"Five deterministic checks": +//! +//! 1. [`ReconcileFinding::IdentityUnknown`] — a runtime artefact +//! (frame, LLDP neighbor, Qcc stream) refers to an identity +//! (`MAC_Address`, `LLDP_Chassis_Id`, `Stream_Handle`, +//! `Multicast_Group`) that no AADL `Spar_Identity::*` annotation +//! declares. +//! 2. [`ReconcileFinding::TopologyMissingWiring`] — the LLDP +//! snapshot reports a neighbor adjacency for which no AADL +//! `bus access` connection is declared. +//! 3. [`ReconcileFinding::ConfigDrift`] — the Qcc/tc/ethtool +//! configuration differs from the AADL declaration of the same +//! surface (`Spar_TSN::Gate_Control_List`, +//! `Spar_TSN::Bandwidth_Reservation`, `Spar_TSN::Max_Frame_Size`, +//! …). +//! 4. [`ReconcileFinding::GptpOutOfBudget`] — the observed gPTP +//! synchronization error exceeds the declared +//! `Spar_TSN::Sync_Error` per-hop budget for at least one +//! capture-window sample. +//! 5. [`ReconcileFinding::BinaryMismatch`] — the running image's +//! digest differs from the AADL `Source_Text` / build-recorded +//! digest for the same component. +//! +//! Each variant carries minimal placeholder fields so the v0.11.0 +//! engine can extend them without churning the SARIF / in-toto +//! attestation predicate URL `https://pulseengine.eu/spar-trace-topology/v1`. + +/// One reconciliation finding produced by `spar trace topology`. +/// +/// The variants correspond to the five deterministic checks in the +/// v1 design. v0.10.0 ships the type surface only; the +/// [`crate::report::TopologyReport`] aggregates these for emission +/// to SARIF (v0.11.0) and to a signed in-toto attestation (v1.0). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReconcileFinding { + /// Runtime artefact references an identity unknown to AADL. + IdentityUnknown { + /// What the runtime saw — a MAC, a chassis-id, a stream + /// handle, etc., serialised in its native form. + observed: String, + /// Free-form context (capture file, LLDP snapshot, …). + context: String, + }, + /// LLDP neighbor adjacency without a corresponding AADL + /// `bus access` connection. + TopologyMissingWiring { + /// Local end of the unwired adjacency (LLDP chassis-id / + /// port-id pair, serialised). + local: String, + /// Remote end of the unwired adjacency. + remote: String, + }, + /// Switch / NIC config drift versus the AADL declaration. + ConfigDrift { + /// Property surface that disagrees (e.g. + /// `"Spar_TSN::Gate_Control_List"`). + property: String, + /// AADL-declared value, source-text form. + declared: String, + /// Observed runtime value, source-text form. + observed: String, + }, + /// gPTP error exceeded the declared per-hop budget. + GptpOutOfBudget { + /// AADL identity of the bus / processor whose synchronization + /// budget was exceeded. + bus_or_processor: String, + /// Declared budget in picoseconds (matches + /// `Spar_TSN::Sync_Error`'s lowering). + budget_ps: u64, + /// Worst-case observed error in the capture window, picoseconds. + observed_ps: u64, + }, + /// Running image digest disagrees with the build-recorded digest. + BinaryMismatch { + /// AADL FQN of the affected component. + component: String, + /// Declared digest (e.g. `"sha256:…"`). + declared_digest: String, + /// Observed digest at runtime. + observed_digest: String, + }, +} + +impl ReconcileFinding { + /// Stable kind tag for SARIF rule-id assignment / JSON + /// serialisation. The v1 contract pins these strings. + pub fn kind(&self) -> &'static str { + match self { + Self::IdentityUnknown { .. } => "IdentityUnknown", + Self::TopologyMissingWiring { .. } => "TopologyMissingWiring", + Self::ConfigDrift { .. } => "ConfigDrift", + Self::GptpOutOfBudget { .. } => "GptpOutOfBudget", + Self::BinaryMismatch { .. } => "BinaryMismatch", + } + } +} diff --git a/crates/spar-trace-topology/src/report.rs b/crates/spar-trace-topology/src/report.rs new file mode 100644 index 0000000..c1d5e8e --- /dev/null +++ b/crates/spar-trace-topology/src/report.rs @@ -0,0 +1,44 @@ +//! Topology-report aggregation surface. +//! +//! v0.10.0 ships only the [`TopologyReport`] container. SARIF +//! emission and the signed in-toto attestation predicate URL +//! `https://pulseengine.eu/spar-trace-topology/v1` land in v0.11.0 +//! and v1.0 respectively per +//! `docs/designs/v0.10.0-trace-topology.md` §"Implementation +//! phasing". + +use crate::reconcile::ReconcileFinding; + +/// Aggregated reconciliation findings for one +/// `spar trace topology` run. +/// +/// v0.10.0 carries only the findings list. v0.11.0 widens this to +/// include capture metadata (input artefact hashes, capture window +/// timestamps), the SARIF emitter target, and the in-toto +/// attestation envelope. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TopologyReport { + /// Every reconciliation finding raised over the capture window. + /// Empty when the runtime artefacts are byte-clean against the + /// AADL declaration. + pub findings: Vec, +} + +impl TopologyReport { + /// Create an empty report — no findings yet. + pub fn new() -> Self { + Self::default() + } + + /// Record one [`ReconcileFinding`]. + pub fn push(&mut self, finding: ReconcileFinding) { + self.findings.push(finding); + } + + /// `true` when no reconciliation finding was raised. The + /// v0.11.0 engine exits with status 0 when this is the case + /// and the in-toto attestation declares the run "verified". + pub fn is_clean(&self) -> bool { + self.findings.is_empty() + } +} diff --git a/crates/spar-trace-topology/tests/identity_accessors.rs b/crates/spar-trace-topology/tests/identity_accessors.rs new file mode 100644 index 0000000..b468469 --- /dev/null +++ b/crates/spar-trace-topology/tests/identity_accessors.rs @@ -0,0 +1,191 @@ +//! Round-trip tests for the `Spar_Identity::*` typed accessors. +//! +//! Each test exercises both the typed `PropertyExpr` path and the +//! string-fallback path so the v0.11.0 reconciliation engine can +//! depend on either parser-typed or hand-written test fixtures. + +use spar_hir_def::item_tree::PropertyExpr; +use spar_hir_def::name::{Name, PropertyRef}; +use spar_hir_def::properties::{PropertyMap, PropertyValue}; +use spar_trace_topology::identity; + +fn make_props(set: &str, name: &str, value: &str, expr: Option) -> PropertyMap { + let mut props = PropertyMap::new(); + props.add(PropertyValue { + name: PropertyRef { + property_set: if set.is_empty() { + None + } else { + Some(Name::new(set)) + }, + property_name: Name::new(name), + }, + value: value.to_string(), + typed_expr: expr, + is_append: false, + }); + props +} + +#[test] +fn mac_address_typed_and_string_fallback() { + // Typed PropertyExpr::StringLit path. + let typed = make_props( + "Spar_Identity", + "MAC_Address", + "\"aa:bb:cc:dd:ee:ff\"", + Some(PropertyExpr::StringLit("aa:bb:cc:dd:ee:ff".to_string())), + ); + assert_eq!( + identity::get_mac_address(&typed).as_deref(), + Some("aa:bb:cc:dd:ee:ff") + ); + + // String-fallback path — quoted source-text form. + let raw = make_props( + "Spar_Identity", + "MAC_Address", + "\"11:22:33:44:55:66\"", + None, + ); + assert_eq!( + identity::get_mac_address(&raw).as_deref(), + Some("11:22:33:44:55:66") + ); + + // Absent => None. + let empty = PropertyMap::new(); + assert_eq!(identity::get_mac_address(&empty), None); +} + +#[test] +fn vlan_id_in_and_out_of_range() { + // Typed integer in range. + let typed = make_props( + "Spar_Identity", + "VLAN_ID", + "100", + Some(PropertyExpr::Integer(100, None)), + ); + assert_eq!(identity::get_vlan_id(&typed), Some(100)); + + // Typed integer at the upper bound 4094 — accepted. + let upper = make_props( + "Spar_Identity", + "VLAN_ID", + "4094", + Some(PropertyExpr::Integer(4094, None)), + ); + assert_eq!(identity::get_vlan_id(&upper), Some(4094)); + + // Typed integer 4095 (reserved) — rejected. + let reserved = make_props( + "Spar_Identity", + "VLAN_ID", + "4095", + Some(PropertyExpr::Integer(4095, None)), + ); + assert_eq!(identity::get_vlan_id(&reserved), None); + + // Negative value via typed path — rejected. + let neg = make_props( + "Spar_Identity", + "VLAN_ID", + "-1", + Some(PropertyExpr::Integer(-1, None)), + ); + assert_eq!(identity::get_vlan_id(&neg), None); + + // String-fallback in range. + let raw = make_props("Spar_Identity", "VLAN_ID", "42", None); + assert_eq!(identity::get_vlan_id(&raw), Some(42)); + + // String-fallback out of range. + let raw_oor = make_props("Spar_Identity", "VLAN_ID", "9999", None); + assert_eq!(identity::get_vlan_id(&raw_oor), None); +} + +#[test] +fn stream_handle_typed_and_string_fallback() { + // Typed integer. + let typed = make_props( + "Spar_Identity", + "Stream_Handle", + "12345", + Some(PropertyExpr::Integer(12345, None)), + ); + assert_eq!(identity::get_stream_handle(&typed), Some(12345)); + + // String-fallback. + let raw = make_props("Spar_Identity", "Stream_Handle", "67890", None); + assert_eq!(identity::get_stream_handle(&raw), Some(67890)); + + // Negative => rejected (Stream_Handle is unsigned). + let neg = make_props( + "Spar_Identity", + "Stream_Handle", + "-5", + Some(PropertyExpr::Integer(-5, None)), + ); + assert_eq!(identity::get_stream_handle(&neg), None); +} + +#[test] +fn multicast_group_typed_and_string_fallback() { + let typed = make_props( + "Spar_Identity", + "Multicast_Group", + "\"01:1b:19:00:00:00\"", + Some(PropertyExpr::StringLit("01:1b:19:00:00:00".to_string())), + ); + assert_eq!( + identity::get_multicast_group(&typed).as_deref(), + Some("01:1b:19:00:00:00") + ); + + let raw = make_props( + "Spar_Identity", + "Multicast_Group", + "\"33:33:00:00:00:01\"", + None, + ); + assert_eq!( + identity::get_multicast_group(&raw).as_deref(), + Some("33:33:00:00:00:01") + ); +} + +#[test] +fn lldp_chassis_id_typed_and_string_fallback() { + let typed = make_props( + "Spar_Identity", + "LLDP_Chassis_Id", + "\"ECU3\"", + Some(PropertyExpr::StringLit("ECU3".to_string())), + ); + assert_eq!( + identity::get_lldp_chassis_id(&typed).as_deref(), + Some("ECU3") + ); + + let raw = make_props("Spar_Identity", "LLDP_Chassis_Id", "\"sw-A\"", None); + assert_eq!(identity::get_lldp_chassis_id(&raw).as_deref(), Some("sw-A")); +} + +#[test] +fn lldp_port_id_typed_and_string_fallback() { + let typed = make_props( + "Spar_Identity", + "LLDP_Port_Id", + "\"eth0\"", + Some(PropertyExpr::StringLit("eth0".to_string())), + ); + assert_eq!(identity::get_lldp_port_id(&typed).as_deref(), Some("eth0")); + + let raw = make_props("Spar_Identity", "LLDP_Port_Id", "\"swp3\"", None); + assert_eq!(identity::get_lldp_port_id(&raw).as_deref(), Some("swp3")); + + // Absent => None. + let empty = PropertyMap::new(); + assert_eq!(identity::get_lldp_port_id(&empty), None); +} diff --git a/docs/contracts/spar-trace-topology-v1.md b/docs/contracts/spar-trace-topology-v1.md new file mode 100644 index 0000000..cfc6d25 --- /dev/null +++ b/docs/contracts/spar-trace-topology-v1.md @@ -0,0 +1,222 @@ +# spar-trace-topology external contract, v1 + +Status: **proposed** — stabilises when the v0.11.0 reconciliation +engine ships. v0.10.0 publishes the property surface and the +predicate URL only; the engine and SARIF emitter land in v0.11.0, +and the signed in-toto envelope lands in v1.0. +Last update: 2026-04-28. + +## Purpose + +Define the interchange surface between `spar trace topology` (the +runtime/declared topology reconciler) and external integrators +(rivet variant pipelines, witness verification kits, OEM-internal +certification toolchains). + +`spar trace topology` consumes the runtime artefact set an OEM +produces from a real deployment — PCAPNG captures, LLDP topology +snapshots, Qcc YANG switch configs, tc/ethtool dumps, gPTP +synchronization logs — and reconciles them against the AADL +declaration of "what should be on the wire". The output is a SARIF +2.1.0 finding stream plus a signed in-toto v1.0 attestation envelope. + +The architecture mirrors the rivet ↔ spar variant binding contract +(`docs/contracts/rivet-spar-variant-v1.md`): spar owns the +deterministic check; external readers consume the certified output; +no party crosses into the certified path. + +## Predicate URL + +``` +https://pulseengine.eu/spar-trace-topology/v1 +``` + +This URL is the in-toto v1.0 attestation predicate type. v1 readers +MUST recognise this exact URL; v2+ readers MAY add support for +successor URLs (`/v2`, `/v3`, …). v1 readers MUST refuse predicate +bodies whose declared predicate type differs from the v1 URL — that +is the correct behaviour per the same forward-compatibility pattern +the variant contract uses. + +The URL is referenced — but not yet served as a JSON Schema document +— as of v0.10.0. The machine-readable schema lands alongside v1 +stabilisation as +`docs/contracts/spar-trace-topology-v1.schema.json`. + +## Input artefact list + +A v1 reconciliation run consumes: + +| Artefact | Format | Source | +|---|---|---| +| L2 frame capture | PCAPNG | `tcpdump`, `tshark`, TAP/SPAN port | +| LLDP topology snapshot | `lldpctl -f xml` / lldpd JSON / extracted from PCAPNG | runtime LLDP exchange | +| Switch configuration | Qcc YANG via NETCONF/RESTCONF; tc / ethtool dumps as supplementary fallback | NETCONF client / runtime tooling | +| gPTP synchronization log | `ptp4l` summary / `pmc` JSON / CTF events | linuxptp / Zephyr gPTP stack | +| Build-recorded image digests | JSON sidecar | build pipeline | +| AADL declaration | AADL v2.2/v2.3 with `Spar_Identity::*` and `Spar_TSN::*` annotations | spar-parsable model | + +Out of scope for v1 (v1 readers MUST refuse with a clear migration +message): + +- PCAP-classic (libpcap legacy); +- BLF (Vector binary log); +- OPC-UA captures; +- deep packet inspection / payload reconstruction. + +See `docs/designs/v0.10.0-trace-topology.md` §"Out-of-scope for v1" +for the rationale. + +## Output + +### SARIF stream + +`spar trace topology` emits a SARIF 2.1.0 log on stdout (or to a +target path with `--output`). The log carries one rule per finding +kind: + +| Rule id | Maps to | Level | +|---|---|---| +| `spar-trace-topology/v1/IdentityUnknown` | `ReconcileFinding::IdentityUnknown` | error | +| `spar-trace-topology/v1/TopologyMissingWiring` | `ReconcileFinding::TopologyMissingWiring` | error | +| `spar-trace-topology/v1/ConfigDrift` | `ReconcileFinding::ConfigDrift` | error | +| `spar-trace-topology/v1/GptpOutOfBudget` | `ReconcileFinding::GptpOutOfBudget` | error | +| `spar-trace-topology/v1/BinaryMismatch` | `ReconcileFinding::BinaryMismatch` | error | + +`tool.driver.name = "spar-trace-topology"`; `tool.driver.version` +carries the spar release; `tool.driver.informationUri` points back +at this contract's predicate URL. + +### in-toto attestation envelope + +`spar trace topology` emits an in-toto v1.0 envelope alongside the +SARIF stream: + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://pulseengine.eu/spar-trace-topology/v1", + "subject": [ + { "name": "", "digest": { "sha256": "..." } } + ], + "predicate": { + "spar_trace_topology_version": "1", + "verifier": "spar-trace-topology X.Y.Z", + "inputs": { + "pcapng": { "digest": { "sha256": "..." } }, + "lldp": { "digest": { "sha256": "..." } }, + "switch_yang": { "digest": { "sha256": "..." } }, + "ptp_log": { "digest": { "sha256": "..." } } + }, + "report": { + "findings": [ ... ] + }, + "verified": + } +} +``` + +`verified` is `true` iff `report.findings` is empty. v1 readers +MUST treat the envelope as authoritative *only* when the signature +verifies under a key the reader trusts. + +The full JSON Schema for the predicate body lands as +`docs/contracts/spar-trace-topology-v1.schema.json` alongside v1 +stabilisation. v0.10.0 forward-references this schema; the body +shape above is the canonical reference until then. + +## Spar_Identity property surface (v0.10.0 published) + +The reconciler reads the following properties from the AADL model: + +| Property | AADL type | Applies to | Reconciles against | +|---|---|---|---| +| `Spar_Identity::MAC_Address` | `aadlstring` | device, processor | PCAPNG MAC; LLDP chassis-id | +| `Spar_Identity::VLAN_ID` | `aadlinteger 0 .. 4094` | connection, bus | 802.1Q tag; Qcc YANG | +| `Spar_Identity::Stream_Handle` | `aadlinteger` | connection | Qcc YANG stream-handle | +| `Spar_Identity::Multicast_Group` | `aadlstring` | connection | PCAPNG dest MAC; Qcc YANG | +| `Spar_Identity::LLDP_Chassis_Id` | `aadlstring` | device, processor, bus | LLDP chassis-id TLV | +| `Spar_Identity::LLDP_Port_Id` | `aadlstring` | bus access feature | LLDP port-id TLV | + +These are non-standard property set entries registered with the +predefined property surface (no `with` import required), per +`crates/spar-hir-def/src/standard_properties.rs`. v1 readers MUST +treat them as the canonical declared identity. + +## CLI contract (v0.11.0) + +``` +spar trace topology \ + --aadl spec.aadl \ + --pcapng capture.pcapng \ + --lldp lldp.xml \ + --switch-yang switches.json \ + --ptp ptp4l.log \ + --digests digests.json \ + --format sarif \ + --attestation out.intoto.jsonl +``` + +Exit codes: +- `0` — every check passed; `report.is_clean()`. +- `1` — at least one reconciliation finding raised. +- `2` — input parse failure (artefact missing, malformed, unsupported + format). + +v0.10.0 ships the foundation only — the CLI subcommand is not yet +wired. v0.11.0 lands the engine and the subcommand. + +## Stability promise + +- **v1 is published-but-not-stable until v0.11.0.** v0.10.0 ships + the property surface (`Spar_Identity::*`) and the predicate URL; + changes between v0.10.0 and v0.11.0 are still possible. v1 freezes + at the v0.11.0 release; subsequent v1.x releases preserve the wire + format. +- **v1 readers MUST refuse v2+ blobs.** Per the variant-contract + pattern, predicate types under `https://pulseengine.eu/spar-trace-topology/v2` + (and beyond) MUST be rejected by v1 readers — v2 may break the + predicate body shape. +- **Adding optional fields to the predicate body is not breaking.** + v1 readers MUST ignore predicate-body fields they do not recognise, + to allow forward-compatible additions. +- **Adding new finding kinds is breaking** — it bumps the predicate + URL. +- **Removing a finding kind is breaking** — same rule. +- **Renaming or retyping a `Spar_Identity::*` property is breaking** + — same rule. + +## Validation responsibilities + +- spar (the verifier) is responsible for: + - Schema validation of every input artefact before reconciling. + - Producing per-finding SARIF entries with correct `ruleId` and + `level` (always `error` per §"SARIF stream"). + - Producing the in-toto envelope with the canonical input-digest + set. + - Refusing v2+ predicate types in any envelope it ingests for + cross-checking. +- External readers (rivet, witness, OEM) are responsible for: + - Verifying the in-toto signature under their trust root before + consuming the predicate body. + - Refusing predicate types other than `/v1` in v1 mode. + - Treating `verified: true` as the *only* certifying claim — the + presence or absence of findings is the ground truth, not any + summary string. + +## Out of scope for v1 + +- Time-series reconciliation (per-window sub-findings). +- Variant-aware reconciliation (`--variant-context` integration). +- Application-layer / payload-level reconciliation. +- Sigstore key custody for air-gapped operators (deferred to a v1.0 + follow-up `--emit-unsigned` flag). + +## References + +- `docs/designs/v0.10.0-trace-topology.md` — full v1 design. +- `docs/contracts/rivet-spar-variant-v1.md` — sibling contract, + same shape. +- IEEE 802.1AB / 802.1AS / 802.1Q / 802.1Qbv / 802.1Qcc. +- SARIF 2.1.0 (OASIS). +- in-toto v1.0 / sigstore. diff --git a/docs/designs/v0.10.0-trace-topology.md b/docs/designs/v0.10.0-trace-topology.md new file mode 100644 index 0000000..a8a032a --- /dev/null +++ b/docs/designs/v0.10.0-trace-topology.md @@ -0,0 +1,445 @@ +# Design: v0.10.0 trace-topology — runtime/declared reconciliation foundation (Track G) + +Status: **proposed** — v0.10.0 ships the foundation (this PR); +parsers and the reconciliation engine ship in sibling commits. +Last update: 2026-04-28. +Audience: spar maintainers, OEM integrators wiring captured runtime +artefacts to AADL declarations, and external `spar trace topology` +consumers (rivet, witness, downstream certification kits). + +> **TL;DR.** `spar trace topology` (v1) consumes the runtime artefact +> set an OEM produces from a real deployment — PCAPNG captures, LLDP +> topology snapshots, Qcc YANG switch configs, tc/ethtool dumps, gPTP +> synchronization logs — and reconciles them against the AADL +> declaration of "what should be on the wire". The output is a SARIF +> finding stream plus a signed in-toto attestation (predicate URL +> `https://pulseengine.eu/spar-trace-topology/v1`). v0.10.0 ships the +> foundation: the new `Spar_Identity::*` property surface that lets +> AADL declare device/connection identities, the `spar-trace-topology` +> crate skeleton with placeholder parsers and the +> `ReconcileFinding`/`TopologyReport` type surface, plus this design +> doc and the external-integrator contract. v0.10.x adds the parsers, +> v0.11.0 adds the reconciliation engine and SARIF emission, v1.0 adds +> the signed in-toto attestation. Estimated total scope: ~2 weeks +> (per the external reviewer's v1 spec). + +--- + +## §0 What this design is and is not + +**Is.** A scoped foundation design for `spar trace topology` v1. +Names every input artefact the v1 reader is expected to consume, +defines the five deterministic checks that fire on the reconciler's +diff, pins the SARIF output shape, pins the in-toto attestation +predicate URL, and lays out the implementation phasing +(v0.10.0 = foundation, v0.10.x = parsers, v0.11.0 = engine, +v1.0 = signed attestation). The companion contract doc +(`docs/contracts/spar-trace-topology-v1.md`) carries the +external-integrator-facing surface (predicate URL, JSON schema +forward-reference, stability promise). + +**Is not.** Production parsers, reconciliation logic, SARIF emission, +or in-toto signing. v0.10.0 lands the property surface, the crate +skeleton, the trait shapes, and the type vocabulary; everything else +is sibling-commit work tracked from this doc's §"Implementation +phasing" table. The doc is intentionally critical of overreach: any +v1 input that is not deterministically reconcilable from runtime +artefacts is deferred to v2 or rejected outright (see §"Out-of-scope +for v1"). + +--- + +## §1 Problem statement + +OEMs that deploy AADL-described systems on real silicon today fly +blind between "what the AADL declared" and "what the wire shows". A +typical brake-by-wire deployment carries: + +- 3–10 ECUs running Zephyr / Linux / proprietary RTOSes; +- 1–3 TSN switches (Microchip LAN9662, Marvell 88Q5050, + NXP SJA1110-class) configured via NETCONF/RESTCONF/Qcc YANG; +- gPTP grandmaster + slaves with measurable per-hop ε; +- a body of L2 frames whose `MAC_Address` / `VLAN_ID` / + `Stream_Handle` / `Multicast_Group` identifiers should match what + the AADL declared. + +Without a deterministic reconciler: + +1. **Identity drift goes undetected.** A new ECU is wired in with a + mistyped MAC; PCAPNG sees it, AADL doesn't. +2. **Topology drift goes undetected.** A cable is moved between two + switch ports; LLDP reports the new neighbor, AADL still references + the old one. +3. **Config drift goes undetected.** The deployed switch's GCL + differs from the AADL `Spar_TSN::Gate_Control_List`; bandwidth / + latency analyses still pass against the AADL but the wire violates + them. +4. **gPTP drift goes undetected.** ε observed over the capture window + exceeds the declared `Spar_TSN::Sync_Error`; TAS analyses based on + that ε become unsound. +5. **Binary drift goes undetected.** The image running on an ECU + does not match the build-recorded digest the AADL `Source_Text` / + metadata declared. + +`spar trace topology` v1 closes those five gaps — and **only** those +five. Anything that requires deep packet inspection, application-layer +semantics, or content reconstruction is out of scope. + +--- + +## §2 v1 input artefact set + +The reviewer's v1 spec pins the following input set. v0.10.0 +foundation surface declares the trait shapes; v0.10.x sibling commits +ship the real parsers. + +| Artefact | Format | Trait | Source semantics | v1 references / TODO | +|---|---|---|---|---| +| L2 frame capture | PCAPNG | [`FrameSource`] | One PCAPNG per capture window. Block types: SHB, IDB, EPB. Link-types: Ethernet (1) primary; CAN (227) admissible-but-deferred. | RFC pcapng-draft / IETF opsawg-pcapng. v0.10.x parser TODO. | +| LLDP topology snapshot | `lldpctl -f xml` / `lldpd` JSON / extracted from PCAPNG | [`TopologySource`] | Set of `(local_chassis, local_port, remote_chassis, remote_port)` adjacencies observed during the capture window. | IEEE 802.1AB-2016. v0.10.x parser TODO. | +| Switch config | Qcc YANG (NETCONF/RESTCONF dump) | [`SwitchConfigSource`] | Per-switch state for `ieee802-dot1q-bridge`, `ieee802-dot1q-tsn-types`, `ieee802-dot1q-stream-filters-and-policing`. tc / ethtool dumps accepted as supplementary fallback. | IEEE 802.1Qcc-2018, RFC 7950 (YANG). v0.10.x parser TODO. | +| gPTP synchronization log | `ptp4l` summary / `pmc` JSON / CTF events | [`PtpTimeSource`] | Per-port worst-case observed offsetFromMaster over the capture window. | IEEE 802.1AS-2020. v0.10.x parser TODO. | +| Build-recorded image digests | JSON sidecar | (consumed inline by the reconciliation engine, no separate trait) | One `{component_fqn → digest}` map produced at build time. v1 reads this directly from the AADL `Source_Text` / build metadata. | sigstore / SLSA provenance shapes. v0.11.0 reader TODO. | + +[`FrameSource`]: ../../crates/spar-trace-topology/src/ingest.rs +[`TopologySource`]: ../../crates/spar-trace-topology/src/ingest.rs +[`SwitchConfigSource`]: ../../crates/spar-trace-topology/src/ingest.rs +[`PtpTimeSource`]: ../../crates/spar-trace-topology/src/ingest.rs + +### 2.1 Out-of-scope for v1 + +Per the reviewer's v1 contract, the following are **explicitly +deferred** (or rejected) and v1 readers MUST refuse them: + +- **PCAP-classic** (libpcap legacy single-record format) — superseded + by PCAPNG. Refuse with a clear migration message. +- **BLF** (Vector binary log) — proprietary; defer to v2 if customer + demand justifies a vendor-format adapter crate. +- **OPC-UA** captures — application-layer; orthogonal to the L2/L3 + reconciliation v1 cares about. +- **Deep packet inspection** — payload reconstruction, application- + layer protocol decode. v1 only inspects L2 headers and 802.1Q VLAN / + stream tags. + +--- + +## §3 Spar_Identity property surface + +v0.10.0 introduces a single non-standard property set +`Spar_Identity::*` registered alongside the existing +`Spar_TSN::*` / `Spar_Network::*` / `Spar_Migration::*` / +`Spar_Power::*` sets so AADL annotations resolve without explicit +`with` imports. + +| Property | AADL type | Applies to | Reconciles against | +|---|---|---|---| +| `MAC_Address` | `aadlstring` | device, processor | PCAPNG source/dest MAC; LLDP chassis-id when the chassis-id is a MAC | +| `VLAN_ID` | `aadlinteger 0 .. 4094` | connection, bus | 802.1Q VLAN tag observed in PCAPNG; Qcc YANG `vlan-id` | +| `Stream_Handle` | `aadlinteger` | connection | Qcc YANG `stream-handle` (per 802.1Qcc-2018) | +| `Multicast_Group` | `aadlstring` | connection | PCAPNG dest MAC for multicast streams; Qcc YANG `destination-mac-address` | +| `LLDP_Chassis_Id` | `aadlstring` | device, processor, bus | LLDP `chassis-id` TLV | +| `LLDP_Port_Id` | `aadlstring` | bus access feature | LLDP `port-id` TLV | + +The property surface is **read** by the v0.11.0 reconciliation engine +through the typed accessors in +[`spar_trace_topology::identity`](../../crates/spar-trace-topology/src/identity.rs). +Each accessor implements the typed-first / string-fallback idiom +established by `spar-network::tsn` so hand-written test fixtures and +parser-typed paths both work. + +VLAN range note: v0.10.0 enforces `0..=4094` per 802.1Q-2022 (4095 +reserved); the reserved value is rejected at the accessor level so +the reconciliation engine never sees it. + +### 3.1 Why these six and not more + +The reviewer's v1 spec deliberately scopes identity to what the +five-check matrix can deterministically reconcile. Specifically +deferred: + +- **IPv4/IPv6 addresses** — too volatile for AADL declaration; defer + to a v2 `Spar_IPIdentity` set if customers ask. +- **Hostname / DNS name** — application-layer; same rationale. +- **TLS cert fingerprints** — orthogonal to the L2/L3 reconciliation; + better served by witness/sigstore. +- **PSFP stream-filter id** — Qcc-resident but only exercised by + the not-yet-shipped 802.1Qci PSFP analysis (deferred from Track D). + +--- + +## §4 Five deterministic checks + +The reviewer's v1 spec pins five checks. They are **deterministic** +in the same sense the variant-binding contract uses the word: given +the same AADL model, the same artefact set, and the same byte-content, +two `spar trace topology` runs produce byte-identical reports +(modulo a wall-clock timestamp in the in-toto envelope). + +The v0.10.0 foundation declares the [`ReconcileFinding`] enum +carrying the five variants; the v0.11.0 engine emits them. + +### 4.1 IdentityUnknown + +**Pass:** every PCAPNG MAC, LLDP chassis-id/port-id, Qcc stream-handle, +and Qcc multicast-group destination observed during the capture +window appears as the corresponding `Spar_Identity::*` property on +some declared AADL device, processor, connection, or bus. + +**Fail:** at least one runtime artefact references an identity not +declared in AADL. Carries the observed identity (MAC, chassis-id, +stream-handle, group) and the artefact context. + +### 4.2 TopologyMissingWiring + +**Pass:** for every LLDP adjacency `(local_chassis, local_port, +remote_chassis, remote_port)` observed during the capture window, +some AADL `bus access` connection wires the corresponding endpoints +together. + +**Fail:** at least one observed neighbor adjacency lacks an AADL +`bus access` connection. Carries the local / remote chassis-id + +port-id pair. + +The reconciliation engine matches LLDP endpoints to AADL features +by the `Spar_Identity::LLDP_Chassis_Id` (on the parent component) +and `Spar_Identity::LLDP_Port_Id` (on the bus access feature). When +either annotation is absent the engine emits an +[`ReconcileFinding::IdentityUnknown`] for the missing surface +rather than a topology finding. + +### 4.3 ConfigDrift + +**Pass:** for every TSN-resident property the AADL declares +(`Spar_TSN::Gate_Control_List`, `Spar_TSN::Bandwidth_Reservation`, +`Spar_TSN::Max_Frame_Size`, `Spar_TSN::Hi_Credit`, +`Spar_TSN::Lo_Credit`, `Spar_TSN::Frame_Preemption`, +`Spar_Network::Output_Rate`, `Spar_Network::Switch_Type`), the Qcc +YANG / tc / ethtool reading of the same surface matches. + +**Fail:** the runtime configuration disagrees with the AADL +declaration. Carries the property surface (e.g. +`"Spar_TSN::Gate_Control_List"`), the AADL-declared value, and the +observed value, both in source-text form. + +### 4.4 GptpOutOfBudget + +**Pass:** the worst-case gPTP synchronization error observed at +every port over the capture window does not exceed the declared +`Spar_TSN::Sync_Error` (lowered to picoseconds) for the bus or +processor that owns the port. + +**Fail:** at least one port observed an ε > declared budget. Carries +the AADL identity of the bus or processor, the declared budget in +picoseconds, and the worst-case observed error in picoseconds. + +This check is the soundness guard for the v0.9.1 NC-soundness pass: +if it fires, the WCTT bounds computed off the declared `Sync_Error` +were optimistic and the deployment is not certifiable. + +### 4.5 BinaryMismatch + +**Pass:** for every component whose AADL declares a build-recorded +digest (via `Source_Text` or a sidecar manifest), the digest of the +running image observed at runtime matches. + +**Fail:** at least one component's running digest disagrees. Carries +the AADL FQN, declared digest, and observed digest. + +The runtime-observed digest source is intentionally pluggable — +v0.11.0 will support sigstore attestation pull-down, slsa provenance, +and (where available) the device's own attestation key. The trait +surface for this is *not* in v0.10.0 because the reconciliation +engine reads digests inline; no parser trait is needed. + +--- + +## §5 SARIF + in-toto output shape + +### 5.1 SARIF (v0.11.0) + +The reconciliation engine emits a SARIF 2.1.0 log with one rule per +`ReconcileFinding::kind()`: + +```text +rule-id = "spar-trace-topology/v1/IdentityUnknown" + | "spar-trace-topology/v1/TopologyMissingWiring" + | "spar-trace-topology/v1/ConfigDrift" + | "spar-trace-topology/v1/GptpOutOfBudget" + | "spar-trace-topology/v1/BinaryMismatch" +``` + +Each rule is `error`-level; the v1 contract does not allow demoting +findings to `warning` because the entire point of the reconciler is +that the wire MUST match the AADL. + +The SARIF `tool.driver` carries `name = "spar-trace-topology"`, +`version = `, `informationUri = "https://pulseengine.eu/spar-trace-topology/v1"`. + +### 5.2 in-toto attestation (v1.0) + +The reconciler also emits an in-toto v1.0 attestation envelope with +predicate type: + +``` +https://pulseengine.eu/spar-trace-topology/v1 +``` + +The predicate body carries: + +- the AADL model digest (subject); +- the input artefact digests (PCAPNG, LLDP snapshot, Qcc YANG, gPTP + log) with their reader versions; +- the `TopologyReport` (findings list); +- the verifier's claim: *"every check passed"* iff + `report.is_clean()`, otherwise the explicit fail set. + +The envelope is signed with the operator's sigstore-resident key. +v1.0 is the first release where signing is mandatory; v0.11.0 emits +the predicate body unsigned for early-access integrators. + +The predicate URL is the integration anchor for downstream readers +(rivet, witness, OEM internal certification kits). External reader +contract: see `docs/contracts/spar-trace-topology-v1.md`. + +--- + +## §6 Implementation phasing + +| Phase | Release | Scope | Crate / file deltas | +|---|---|---|---| +| Foundation | v0.10.0 (this PR) | `Spar_Identity::*` property set; `spar-trace-topology` crate skeleton (`identity` typed accessors, `ingest` placeholder traits, `reconcile` enum, `report` struct); design + contract docs | `crates/spar-hir-def/src/standard_properties.rs` (+1 set, +6 properties); new crate `crates/spar-trace-topology/` | +| Parsers | v0.10.x sibling commits | Real PCAPNG / LLDP / Qcc YANG / gPTP readers; concrete I/O variants on `IngestError` | `crates/spar-trace-topology/src/ingest.rs` (per source), one external dep per parser (see §6.1) | +| Reconciliation engine | v0.11.0 | Five-check engine; SARIF 2.1.0 emitter; CLI subcommand `spar trace topology`; minimum-viable v1 fixture (3-ECU + 1-switch PCAPNG + AADL with deliberately-shifted GCL — must emit one `ConfigDrift` and only one) | `crates/spar-trace-topology/src/reconcile.rs` (engine), `crates/spar-cli` (subcommand) | +| Signed attestation | v1.0 | in-toto v1.0 envelope; sigstore signing; mandatory in production | `crates/spar-trace-topology/src/report.rs` (envelope), sigstore dep | + +### 6.1 Parser dependency choices (v0.10.x) + +Working candidates — final selection happens in the parser PRs: + +- **PCAPNG**: `pcap-parser` crate (BSD-licensed, pure Rust, supports + PCAPNG block decoding) or hand-rolled parser if `pcap-parser` does + not cover the IDB linktype 1 (Ethernet) properly. +- **LLDP**: typically extracted from PCAPNG via the same parser; for + `lldpctl -f xml` / lldpd JSON sources, `quick-xml` / `serde_json`. +- **Qcc YANG**: candidate is `yang2-rs`; alternate path is to consume + pre-translated JSON from a NETCONF client and skip the YANG parser + entirely. +- **gPTP**: `ptp4l` summary log is plain ASCII; `pmc` JSON is + trivially `serde_json`; CTF events go through `spar-insight`'s + existing CTF stack. + +The choice between `yang2-rs` and pre-translated JSON is the open +question for v0.10.x — the latter avoids pulling a YANG runtime into +spar but pushes the burden to deployment tooling. + +--- + +## §7 Minimum-viable v1 deliverable + +Per the reviewer's v1 spec the minimum-viable v1 deliverable is: + +> Five deterministic checks, plus one fixture: a 3-ECU + 1-switch +> PCAPNG + LLDP + Qcc YANG + AADL bundle, where the Qcc YANG declares +> a deliberately-shifted GCL versus the AADL. The reconciler MUST +> emit exactly one `ConfigDrift` finding (on +> `Spar_TSN::Gate_Control_List`) and **only** that finding — no +> spurious `IdentityUnknown`, no spurious `TopologyMissingWiring`, +> no spurious `GptpOutOfBudget`, no spurious `BinaryMismatch`. + +That fixture is the v0.11.0 acceptance gate. v0.10.0 does not ship +it; the foundation crate has no engine to exercise. + +--- + +## §8 Open questions and risks + +### 8.1 Identity coverage gaps + +Six identity properties cover the deterministic-reconciliation +matrix. Customers may demand IPv4/IPv6 / hostname / TLS cert +identity in v2; the property-set extension shape is friendly to +that (just add the variants), but the reconciliation engine grows +linearly with the property count, so v2 should be additive, never +mandatory. + +### 8.2 Capture-window semantics + +v1 treats the capture as a single closed window. The reconciler +does not emit time-series findings (e.g. "the GCL was correct +for the first 5 minutes, then drifted"). v2 may add a +`capture_window` predicate field with start/end timestamps; v1 +treats the entire window as a single observation. + +### 8.3 Attestation signing key custody + +v1.0 mandates sigstore-resident keys for the in-toto envelope. +Operators with air-gapped environments need a deferred mode where +the unsigned predicate body is emitted and signed offline. Tracked +as a v1.0 follow-up (`spar trace topology --emit-unsigned`). + +### 8.4 Variant interaction + +`spar trace topology` does not currently consume +`--variant-context` per the rivet ↔ spar variant contract v1. v2 +may need to: capture window + variant identification together is +the ground truth for multi-variant deployments. v1 punts this to +"run one reconciliation per variant". + +### 8.5 Fail-mode prioritisation + +When all five checks fire, the reconciler emits all five — there is +no priority order. Operators have asked whether `BinaryMismatch` +should short-circuit the rest; v1 says no, because the customer +runs *one* reconciliation per certification audit and benefits +from the full diff. + +--- + +## §9 Scope guard for this PR + +This PR ships **only** the v0.10.0 foundation: + +1. The `Spar_Identity::*` property set (6 properties; new + `STANDARD_PROPERTY_SET_NAMES` entry; updated count test + 131 → 137). +2. The `spar-trace-topology` crate skeleton (`Cargo.toml`, + `lib.rs`, `identity.rs` with the six typed accessors, + `ingest.rs` with the four placeholder traits, `reconcile.rs` + with the `ReconcileFinding` enum, `report.rs` with the + `TopologyReport` struct). +3. This design doc. +4. The companion contract doc + `docs/contracts/spar-trace-topology-v1.md`. +5. Six round-trip accessor tests in + `crates/spar-trace-topology/tests/identity_accessors.rs`. +6. Two requirements (REQ-TRACE-TOPOLOGY-001 / 002) and one + verification entry (TEST-TRACE-TOPOLOGY-IDENTITY) in + `artifacts/`. + +No real parsing. No reconciliation logic. No SARIF emitter. No +in-toto signing. No CLI subcommand. No fixture. Those land in +v0.10.x / v0.11.0 / v1.0 per §6 above. + +--- + +## Appendix A — References + +- IEEE 802.1AB-2016, Station and Media Access Control Connectivity + Discovery (LLDP). +- IEEE 802.1AS-2020, Timing and Synchronization for Time-Sensitive + Applications. +- IEEE 802.1Q-2022, Bridges and Bridged Networks (VLAN-tagged frame + format, VLAN ID semantics). +- IEEE 802.1Qcc-2018, Stream Reservation Protocol enhancements + + YANG / CUC / CNC. +- IEEE 802.1Qbv-2015, Time-Aware Shaper (gate-control list + semantics). +- RFC pcapng-draft (IETF opsawg-pcapng), PCAP Next Generation + Capture File Format. +- SARIF 2.1.0 specification, OASIS standard. +- in-toto v1.0 attestation framework, sigstore project. +- `docs/designs/track-d-tsn-wctt-research.md` — TSN modelling + background that drives the `Spar_TSN::*` surface this work + reconciles against. +- `docs/contracts/rivet-spar-variant-v1.md` — contract-doc shape + this design's companion follows.