diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index abf5fd0..3185eca 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -659,6 +659,19 @@ enum Command { action: ExternalsAction, }, + /// Cross-organizational / supplier-boundary traceability (#253). + /// + /// `rivet supplier list` shows every `external-anchor` artifact — + /// the typed leaves marking points where the in-house chain hands + /// off to a supplier. `rivet supplier check` runs coverage and + /// reports the 3-state breakdown (satisfied / external-boundary / + /// uncovered) so the auditor can distinguish "delegated" from + /// "missing." See docs/design/cross-org-supplier-traceability.md. + Supplier { + #[command(subcommand)] + action: SupplierAction, + }, + /// Analyze change impact between current state and a baseline Impact { /// Git ref to compare against (branch, tag, or commit) @@ -1161,6 +1174,24 @@ enum ExternalsAction { }, } +#[derive(Debug, Subcommand)] +enum SupplierAction { + /// List every `external-anchor` artifact (chain end at a supplier + /// boundary) with its received-status and expected derivatives. + List { + /// Output format: "text" (default) or "json". + #[arg(short, long, default_value = "text")] + format: String, + }, + /// Coverage report with the 3-state breakdown (satisfied / + /// external-boundary / uncovered). Read-only. + Check { + /// Output format: "text" (default) or "json". + #[arg(short, long, default_value = "text")] + format: String, + }, +} + #[derive(Subcommand)] enum RunsAction { /// List runs under .rivet/runs/, newest first. @@ -1836,6 +1867,10 @@ fn run(cli: Cli) -> Result { Command::Externals { action } => match action { ExternalsAction::Discover { path, format } => cmd_externals_discover(path, format), }, + Command::Supplier { action } => match action { + SupplierAction::List { format } => cmd_supplier_list(&cli, format), + SupplierAction::Check { format } => cmd_supplier_check(&cli, format), + }, Command::Baseline { action } => match action { BaselineAction::Verify { name, strict } => cmd_baseline_verify(&cli, name, *strict), BaselineAction::List => cmd_baseline_list(&cli), @@ -5346,20 +5381,25 @@ fn cmd_coverage( "link_type": e.link_type, "direction": e.direction, "covered": e.covered, + "external_boundary": e.external_boundary, + "external_boundary_ids": e.external_boundary_ids, "total": e.total, "percentage": (e.percentage() * 10.0).round() / 10.0, + "accounted_percentage": (e.accounted_percentage() * 10.0).round() / 10.0, "uncovered_ids": e.uncovered_ids, }) }) .collect(); let total: usize = report.entries.iter().map(|e| e.total).sum(); let covered: usize = report.entries.iter().map(|e| e.covered).sum(); + let external_boundary: usize = report.entries.iter().map(|e| e.external_boundary).sum(); let overall_pct = (report.overall_coverage() * 10.0).round() / 10.0; let mut output = serde_json::json!({ "command": "coverage", "rules": rules_json, "overall": { "covered": covered, + "external_boundary": external_boundary, "total": total, "percentage": overall_pct, }, @@ -5376,28 +5416,61 @@ fn cmd_coverage( } println!("{}", serde_json::to_string_pretty(&output).unwrap()); } else { + let any_boundary = report.entries.iter().any(|e| e.external_boundary > 0); println!("Traceability Coverage Report\n"); - println!( - " {:<30} {:<20} {:>8} {:>8} {:>8}", - "Rule", "Source Type", "Covered", "Total", "%" - ); + if any_boundary { + println!( + " {:<30} {:<20} {:>8} {:>9} {:>8} {:>8}", + "Rule", "Source Type", "Covered", "Boundary", "Total", "%" + ); + } else { + println!( + " {:<30} {:<20} {:>8} {:>8} {:>8}", + "Rule", "Source Type", "Covered", "Total", "%" + ); + } println!(" {}", "-".repeat(80)); for entry in &report.entries { - println!( - " {:<30} {:<20} {:>8} {:>8} {:>7.1}%", - entry.rule_name, - entry.source_type, - entry.covered, - entry.total, - entry.percentage() - ); + if any_boundary { + println!( + " {:<30} {:<20} {:>8} {:>9} {:>8} {:>7.1}%", + entry.rule_name, + entry.source_type, + entry.covered, + entry.external_boundary, + entry.total, + entry.percentage() + ); + } else { + println!( + " {:<30} {:<20} {:>8} {:>8} {:>7.1}%", + entry.rule_name, + entry.source_type, + entry.covered, + entry.total, + entry.percentage() + ); + } } let overall = report.overall_coverage(); println!(" {}", "-".repeat(80)); println!(" {:<52} {:>7.1}%", "Overall (weighted)", overall); + // 3-state breakdown for the auditor — only printed when at least + // one boundary exists, to keep the common case uncluttered. + if any_boundary { + let total: usize = report.entries.iter().map(|e| e.total).sum(); + let covered: usize = report.entries.iter().map(|e| e.covered).sum(); + let boundary: usize = report.entries.iter().map(|e| e.external_boundary).sum(); + let uncovered: usize = report.entries.iter().map(|e| e.uncovered_ids.len()).sum(); + println!( + " Breakdown: {} satisfied, {} external-boundary, {} uncovered (of {})", + covered, boundary, uncovered, total + ); + } + // Show uncovered artifacts let has_uncovered = report.entries.iter().any(|e| !e.uncovered_ids.is_empty()); if has_uncovered { @@ -5411,6 +5484,25 @@ fn cmd_coverage( } } } + + // Show external-boundary artifacts (issue #253). Distinct list + // because the auditor needs "delegated to supplier" to read + // differently from "missing in our store." + let has_boundary = report + .entries + .iter() + .any(|e| !e.external_boundary_ids.is_empty()); + if has_boundary { + println!("\nExternal-boundary artifacts (delegated to supplier):"); + for entry in &report.entries { + if !entry.external_boundary_ids.is_empty() { + println!(" {} ({}):", entry.rule_name, entry.source_type); + for id in &entry.external_boundary_ids { + println!(" {}", id); + } + } + } + } } if let Some(&threshold) = fail_under { @@ -5432,6 +5524,177 @@ fn cmd_coverage( Ok(true) } +/// `rivet supplier list` — enumerate every `external-anchor` artifact +/// in the project, with its expected derivatives and received status. +/// Read-only. +/// +/// Issue #253 MVP: this is the minimum-viable surface so an auditor can +/// answer "what supplier boundaries does this project declare?" before +/// running coverage. +fn cmd_supplier_list(cli: &Cli, format: &str) -> Result { + validate_format(format, &["text", "json"])?; + let ctx = ProjectContext::load(cli)?; + let anchors: Vec<_> = ctx + .store + .iter() + .filter(|a| a.artifact_type == "external-anchor") + .collect(); + + if format == "json" { + let entries: Vec = anchors + .iter() + .map(|a| { + let expected = a + .fields + .get("expected-derived-types") + .cloned() + .unwrap_or(serde_yaml::Value::Null); + let received = a + .fields + .get("received-status") + .and_then(|v| v.as_str()) + .unwrap_or("not-set"); + let contract = a + .fields + .get("contract-reference") + .and_then(|v| v.as_str()); + serde_json::json!({ + "id": a.id, + "title": a.title, + "expected_derived_types": serde_json::to_value(&expected).unwrap_or(serde_json::Value::Null), + "received_status": received, + "contract_reference": contract, + }) + }) + .collect(); + let output = serde_json::json!({ + "command": "supplier list", + "count": anchors.len(), + "anchors": entries, + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + return Ok(true); + } + + if anchors.is_empty() { + println!("No `external-anchor` artifacts declared."); + println!( + "See docs/design/cross-org-supplier-traceability.md for how \ + to mark a supplier boundary." + ); + return Ok(true); + } + + println!("External anchors ({} declared):\n", anchors.len()); + println!( + " {:<28} {:<10} {:<20} Expected derivatives", + "ID", "Status", "Contract" + ); + println!(" {}", "-".repeat(80)); + for a in &anchors { + let received = a + .fields + .get("received-status") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let contract = a + .fields + .get("contract-reference") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + let expected = a + .fields + .get("expected-derived-types") + .and_then(|v| match v { + serde_yaml::Value::Sequence(items) => Some( + items + .iter() + .filter_map(|i| i.as_str()) + .collect::>() + .join(", "), + ), + _ => None, + }) + .unwrap_or_else(|| "-".to_string()); + println!( + " {:<28} {:<10} {:<20} {}", + a.id, received, contract, expected + ); + } + Ok(true) +} + +/// `rivet supplier check` — read-only coverage report with the 3-state +/// breakdown surfaced, filtered down to rules where at least one +/// boundary or uncovered exists. +fn cmd_supplier_check(cli: &Cli, format: &str) -> Result { + validate_format(format, &["text", "json"])?; + let ctx = ProjectContext::load(cli)?; + let report = coverage::compute_coverage(&ctx.store, &ctx.schema, &ctx.graph); + + let interesting: Vec<_> = report + .entries + .iter() + .filter(|e| e.external_boundary > 0 || !e.uncovered_ids.is_empty()) + .collect(); + + if format == "json" { + let rules: Vec = interesting + .iter() + .map(|e| { + serde_json::json!({ + "name": e.rule_name, + "source_type": e.source_type, + "covered": e.covered, + "external_boundary": e.external_boundary, + "external_boundary_ids": e.external_boundary_ids, + "uncovered": e.uncovered_ids.len(), + "uncovered_ids": e.uncovered_ids, + "total": e.total, + "accounted_percentage": (e.accounted_percentage() * 10.0).round() / 10.0, + }) + }) + .collect(); + let total_boundary: usize = report.entries.iter().map(|e| e.external_boundary).sum(); + let total_uncovered: usize = report.entries.iter().map(|e| e.uncovered_ids.len()).sum(); + let output = serde_json::json!({ + "command": "supplier check", + "summary": { + "external_boundary": total_boundary, + "uncovered": total_uncovered, + }, + "rules": rules, + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + return Ok(true); + } + + if interesting.is_empty() { + println!("No supplier-boundary or uncovered findings. Every rule satisfied in-house."); + return Ok(true); + } + + println!("Supplier-boundary coverage:\n"); + for e in &interesting { + println!( + " {} ({}): {} satisfied, {} external-boundary, {} uncovered (of {})", + e.rule_name, + e.source_type, + e.covered, + e.external_boundary, + e.uncovered_ids.len(), + e.total + ); + if !e.external_boundary_ids.is_empty() { + println!(" boundary: {}", e.external_boundary_ids.join(", ")); + } + if !e.uncovered_ids.is_empty() { + println!(" uncovered: {}", e.uncovered_ids.join(", ")); + } + } + Ok(true) +} + /// Test-to-requirement coverage via source markers. fn cmd_coverage_tests(cli: &Cli, format: &str, scan_paths: &[PathBuf]) -> Result { validate_format(format, &["text", "json"])?; diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 30cc914..cd3788c 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -3120,3 +3120,135 @@ fn bundle_invalid_format_fails() { "stderr must list valid formats, got: {stderr}" ); } + +// ── rivet supplier (#253 MVP) ─────────────────────────────────────────── + +/// Build a minimal project with one `external-anchor` artifact and a +/// design-decision that links to it. Used by the supplier smoke tests. +fn supplier_project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().expect("create temp dir"); + let dir = tmp.path(); + + let init = Command::new(rivet_bin()) + .args(["init", "--preset", "dev", "--dir", dir.to_str().unwrap()]) + .output() + .expect("rivet init"); + assert!(init.status.success(), "init failed: {:?}", init); + + // Append an external anchor + a delegating DD to the requirements file. + // + // NB: use a raw string. Plain `"\..."` literals trigger Rust's + // whitespace-eating line continuation, which silently strips leading + // indentation and breaks YAML at column 0. + let req_path = dir.join("artifacts").join("requirements.yaml"); + let existing = std::fs::read_to_string(&req_path).expect("read requirements"); + let extra = r#" + - id: ANCHOR-ACME-001 + type: external-anchor + title: Supplier ACME — SW driver pack + status: approved + fields: + source-of-truth: + org: acme-electronics + contract: PO-4711 + expected-derived-types: + - requirement + received-status: not-received + contract-reference: DIA-2026-001 + - id: DD-DELEGATED + type: design-decision + title: Delegated to supplier + status: approved + links: + - type: derives-from + target: ANCHOR-ACME-001 +"#; + std::fs::write(&req_path, format!("{existing}{extra}")).expect("write requirements"); + tmp +} + +#[test] +fn supplier_list_text_output() { + let tmp = supplier_project(); + let output = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "list", + ]) + .output() + .expect("run supplier list"); + + assert!( + output.status.success(), + "supplier list must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("ANCHOR-ACME-001"), + "list must show the anchor ID, got: {stdout}" + ); + assert!( + stdout.contains("not-received"), + "list must show received-status, got: {stdout}" + ); +} + +#[test] +fn supplier_list_json_shape() { + let tmp = supplier_project(); + let output = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "list", + "--format", + "json", + ]) + .output() + .expect("run supplier list --format json"); + + assert!(output.status.success(), "json must succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json"); + assert_eq!(value["command"], "supplier list"); + assert_eq!(value["count"], 1); + assert_eq!(value["anchors"][0]["id"], "ANCHOR-ACME-001"); + assert_eq!(value["anchors"][0]["received_status"], "not-received"); + assert_eq!(value["anchors"][0]["contract_reference"], "DIA-2026-001"); +} + +#[test] +fn supplier_check_classifies_delegated_dd_as_boundary() { + let tmp = supplier_project(); + let output = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "check", + "--format", + "json", + ]) + .output() + .expect("run supplier check"); + + assert!(output.status.success(), "check must succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json"); + + // At least one rule should report DD-DELEGATED as external-boundary. + let rules = value["rules"].as_array().expect("rules array"); + let saw_boundary = rules.iter().any(|r| { + r["external_boundary_ids"] + .as_array() + .is_some_and(|ids| ids.iter().any(|i| i == "DD-DELEGATED")) + }); + assert!( + saw_boundary, + "DD-DELEGATED must be classified as external_boundary, got: {value}" + ); +} diff --git a/rivet-core/src/coverage.rs b/rivet-core/src/coverage.rs index b5f0fd1..a6f48a6 100644 --- a/rivet-core/src/coverage.rs +++ b/rivet-core/src/coverage.rs @@ -49,6 +49,12 @@ use crate::schema::Schema; use crate::store::Store; /// Coverage result for a single traceability rule. +/// +/// 3-state coverage (issue #253): `covered + external_boundary + +/// uncovered_ids.len() == total`. `external_boundary` counts source +/// artifacts whose chain terminates at an `external-anchor` whose +/// `expected-derived-types` covers the rule's target type — i.e. the +/// derivative is delegated to a supplier, not missing. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct CoverageEntry { /// Rule name from the schema. @@ -63,14 +69,27 @@ pub struct CoverageEntry { pub direction: CoverageDirection, /// Target / from types for the required link. pub target_types: Vec, - /// Number of source artifacts that satisfy the rule. + /// Number of source artifacts that satisfy the rule in-house. pub covered: usize, + /// Number of source artifacts whose derivative is delegated to a + /// supplier (terminates at an `external-anchor`). Issue #253 MVP. + #[serde(default, skip_serializing_if = "is_zero")] + pub external_boundary: usize, + /// IDs of source artifacts counted as `external_boundary`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub external_boundary_ids: Vec, /// Total source artifacts of the given type. pub total: usize, - /// IDs of artifacts that are NOT covered. + /// IDs of artifacts that are NOT covered (strictly missing, NOT + /// counting `external_boundary_ids`). pub uncovered_ids: Vec, } +#[inline] +fn is_zero(n: &usize) -> bool { + *n == 0 +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum CoverageDirection { @@ -79,7 +98,8 @@ pub enum CoverageDirection { } impl CoverageEntry { - /// Coverage percentage (0..100). Returns 100 when total is 0. + /// In-house coverage percentage (0..100). Counts only `covered`, + /// excluding `external_boundary`. Returns 100 when total is 0. pub fn percentage(&self) -> f64 { if self.total == 0 { 100.0 @@ -87,6 +107,18 @@ impl CoverageEntry { (self.covered as f64 / self.total as f64) * 100.0 } } + + /// Combined accounted percentage: `(covered + external_boundary) / + /// total`. Issue #253: an auditor sees this as "what's not strictly + /// missing from the trace — either we satisfy it, or a supplier + /// owes it on a recorded boundary." Returns 100 when total is 0. + pub fn accounted_percentage(&self) -> f64 { + if self.total == 0 { + 100.0 + } else { + ((self.covered + self.external_boundary) as f64 / self.total as f64) * 100.0 + } + } } /// Full coverage report across all traceability rules. @@ -120,6 +152,8 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co let source_ids = store.by_type(&rule.source_type); let total = source_ids.len(); let mut covered = 0usize; + let mut external_boundary = 0usize; + let mut external_boundary_ids = Vec::new(); let mut uncovered_ids = Vec::new(); let (link_type, direction, target_types) = if let Some(ref req_link) = rule.required_link { @@ -177,6 +211,9 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co if has_match { covered += 1; + } else if terminates_at_external_anchor(store, graph, id, &target_types) { + external_boundary += 1; + external_boundary_ids.push(id.clone()); } else { uncovered_ids.push(id.clone()); } @@ -190,6 +227,8 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co direction, target_types, covered, + external_boundary, + external_boundary_ids, total, uncovered_ids, }); @@ -198,6 +237,57 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co CoverageReport { entries } } +/// Issue #253: does any forward link from `id` reach an `external-anchor` +/// artifact whose `expected-derived-types` field declares at least one +/// of `target_types`? If so, the unsatisfied rule should count as +/// `external_boundary` rather than `uncovered`. +/// +/// Walks only the *outgoing* links (any link type — supplier delegation +/// is a property of the artifact's existence at the chain end, not of +/// a specific link predicate). Self-links are ignored to match the +/// forward-coverage rule above. +fn terminates_at_external_anchor( + store: &Store, + graph: &LinkGraph, + id: &str, + rule_target_types: &[String], +) -> bool { + for link in graph.links_from(id) { + if link.target == id { + continue; + } + let Some(target_art) = store.get(&link.target) else { + continue; + }; + if target_art.artifact_type != "external-anchor" { + continue; + } + // The anchor's `expected-derived-types` field is a YAML list of + // strings. Accept the boundary only when the rule's required + // target type set overlaps the anchor's expected derivatives + // (or when the rule has no target-type restriction at all). + if rule_target_types.is_empty() { + return true; + } + let Some(expected) = target_art.fields.get("expected-derived-types") else { + // Anchor without an explicit contract: be conservative — + // only honour the boundary when the rule is unrestricted. + continue; + }; + let serde_yaml::Value::Sequence(items) = expected else { + continue; + }; + let anchor_provides: Vec<&str> = items.iter().filter_map(|v| v.as_str()).collect(); + if anchor_provides + .iter() + .any(|t| rule_target_types.iter().any(|rt| rt == t)) + { + return true; + } + } + false +} + // ── Tests ──────────────────────────────────────────────────────────────── #[cfg(test)] @@ -380,6 +470,137 @@ mod tests { assert_eq!(entry.uncovered_ids, vec!["DD-001"]); } + /// Issue #253: an artifact whose chain ends at an `external-anchor` + /// counts as `external_boundary`, not `uncovered`. The boundary + /// signal is honoured only when the anchor's + /// `expected-derived-types` includes the rule's target type — so + /// the auditor sees "delegated to supplier" only for the *kind* of + /// derivative the supplier was actually contracted to deliver. + /// + /// rivet: verifies REQ-004 + #[test] + fn external_anchor_terminates_chain_as_boundary_not_uncovered() { + // Rule: every DD must `satisfies` a `requirement` upstream. + let mut file = minimal_schema("test"); + file.traceability_rules = vec![TraceabilityRule { + name: "dd-justification".into(), + description: "Every DD must satisfy a req".into(), + source_type: "design-decision".into(), + required_link: Some("satisfies".into()), + required_backlink: None, + target_types: vec!["requirement".into()], + from_types: vec![], + severity: Severity::Error, + alternate_backlinks: vec![], + }]; + let schema = Schema::merge(&[file]); + + let mut store = Store::new(); + store + .insert(minimal_artifact("REQ-001", "requirement")) + .unwrap(); + // DD-A satisfies REQ-001 in-house → covered. + store + .insert(artifact_with_links( + "DD-A", + "design-decision", + &[("satisfies", "REQ-001")], + )) + .unwrap(); + // DD-B has no satisfies link and no anchor → uncovered. + store + .insert(minimal_artifact("DD-B", "design-decision")) + .unwrap(); + // DD-C terminates at an external-anchor that declares it covers + // requirements → external_boundary. + let mut anchor = minimal_artifact("ANCHOR-ACME-001", "external-anchor"); + anchor.fields.insert( + "expected-derived-types".into(), + serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("requirement".into())]), + ); + store.insert(anchor).unwrap(); + store + .insert(artifact_with_links( + "DD-C", + "design-decision", + &[("derives-from", "ANCHOR-ACME-001")], + )) + .unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + let entry = &report.entries[0]; + + assert_eq!(entry.covered, 1, "DD-A satisfies in-house"); + assert_eq!(entry.external_boundary, 1, "DD-C delegated to anchor"); + assert_eq!(entry.external_boundary_ids, vec!["DD-C"]); + assert_eq!(entry.uncovered_ids, vec!["DD-B"], "DD-B genuinely missing"); + assert_eq!(entry.total, 3); + + // 3-state sum invariant: every source artifact lands in exactly + // one bucket. + assert_eq!( + entry.covered + entry.external_boundary + entry.uncovered_ids.len(), + entry.total + ); + + // Percentages: covered alone is 1/3 ≈ 33.3%, but the accounted + // figure (covered + boundary) is 2/3 ≈ 66.7%. + assert!((entry.percentage() - 33.333).abs() < 0.1); + assert!((entry.accounted_percentage() - 66.666).abs() < 0.1); + } + + /// An external-anchor whose `expected-derived-types` does NOT include + /// the rule's target type must NOT trigger the boundary classification — + /// otherwise an unrelated anchor would silently absorb every coverage + /// gap that happens to link to it. + /// + /// rivet: verifies REQ-004 + #[test] + fn external_anchor_only_counts_when_expected_types_match() { + let mut file = minimal_schema("test"); + file.traceability_rules = vec![TraceabilityRule { + name: "dd-justification".into(), + description: "Every DD must satisfy a req".into(), + source_type: "design-decision".into(), + required_link: Some("satisfies".into()), + required_backlink: None, + target_types: vec!["requirement".into()], + from_types: vec![], + severity: Severity::Error, + alternate_backlinks: vec![], + }]; + let schema = Schema::merge(&[file]); + + let mut store = Store::new(); + // Anchor delivers *verifications*, not requirements → off-contract + // for this rule. + let mut anchor = minimal_artifact("ANCHOR-X", "external-anchor"); + anchor.fields.insert( + "expected-derived-types".into(), + serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("verification".into())]), + ); + store.insert(anchor).unwrap(); + store + .insert(artifact_with_links( + "DD-1", + "design-decision", + &[("derives-from", "ANCHOR-X")], + )) + .unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + let entry = &report.entries[0]; + + assert_eq!(entry.external_boundary, 0, "anchor off-contract"); + assert_eq!( + entry.uncovered_ids, + vec!["DD-1"], + "must remain uncovered, not silently absorbed" + ); + } + /// Backlink direction of the same bug: a DD that claims its own /// requirement (e.g. REQ-X backlinked by REQ-X via some self-link) /// must not count. diff --git a/schemas/common.yaml b/schemas/common.yaml index 4b8bfe3..23990e2 100644 --- a/schemas/common.yaml +++ b/schemas/common.yaml @@ -101,6 +101,60 @@ link-types: inverse: constrains description: Source is constrained by the target +# ────────────────────────────────────────────────────────────────────────── +# Cross-organizational / supplier-boundary artifact types. +# See docs/design/cross-org-supplier-traceability.md for the rationale. +# ────────────────────────────────────────────────────────────────────────── +artifact-types: + + - name: external-anchor + description: > + A typed leaf marking the point at which an in-house traceability + chain hands off to an external supplier or organization. The chain + is intentionally not followed further — the supplier owns what's + downstream. Used to keep coverage honest at the organizational + boundary (3-state coverage: satisfied / external-boundary / + uncovered) instead of conflating "missing in our store" with + "delegated to a supplier". + fields: + - name: source-of-truth + type: mapping + required: true + description: > + Who owns it: supplier name, contract reference, doc ID. Free-form + mapping at the MVP stage; expected keys are `org`, `contract`, + `doc-id`. + - name: expected-derived-types + type: list + required: true + description: > + What we expect the supplier to produce (e.g. ["sw-req", + "verification"]). This is the *contract*: it tells coverage + which downstream types count as "the supplier has delivered." + - name: received-status + type: string + required: true + allowed-values: + - not-received + - received-as-reqif + - received-as-pdf + - received-as-oslc + - received-as-polarion-export + - received-as-arxml + - received-other + description: > + Lifecycle state of the supplier delivery. `not-received` flags + an open delivery (counted as boundary in coverage but visible + as outstanding); the `received-as-*` variants stamp what the + auditor saw. + - name: contract-reference + type: string + required: false + description: > + Optional contract / PO / DIA reference for traceability into + the legal artefact (ISO 26262-8 §5 DIA, ASPICE SUP.10). + link-fields: [] + # ────────────────────────────────────────────────────────────────────────── # Conditional rules — cross-field validation rules that fire when a # condition is met and check for additional requirements.