diff --git a/rivet-cli/src/check/sources.rs b/rivet-cli/src/check/sources.rs index 2c3badd3..c908f374 100644 --- a/rivet-cli/src/check/sources.rs +++ b/rivet-cli/src/check/sources.rs @@ -365,7 +365,7 @@ pub fn apply_updates(report: &Report, interactive: bool) -> Result { } /// Best-effort ISO-8601 UTC timestamp without pulling chrono in. -fn current_iso8601_utc() -> String { +pub(crate) fn current_iso8601_utc() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let now = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index cb2bc2c5..383b48d8 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -1212,6 +1212,20 @@ enum SupplierAction { #[arg(short, long, default_value = "text")] format: String, }, + /// Federation pull: fetch an `external-anchor`'s `cited-source` + /// into the local supplier cache (#288 Phase 2). Supports + /// `kind: file` and `kind: reqif` — both are read-only on the + /// source side and produce a sha256-verified snapshot under + /// `.rivet/supplier-cache///`. Idempotent: a + /// re-pull with identical bytes is a no-op aside from updating + /// `last-synced`. + Pull { + /// `external-anchor` artifact ID (e.g. `ANCHOR-ACME-001`). + anchor: String, + /// Output format: "text" (default) or "json". + #[arg(short, long, default_value = "text")] + format: String, + }, } #[derive(Subcommand)] @@ -1914,6 +1928,7 @@ fn run(cli: Cli) -> Result { Command::Supplier { action } => match action { SupplierAction::List { format } => cmd_supplier_list(&cli, format), SupplierAction::Check { format } => cmd_supplier_check(&cli, format), + SupplierAction::Pull { anchor, format } => cmd_supplier_pull(&cli, anchor, format), }, Command::Baseline { action } => match action { BaselineAction::Verify { name, strict } => cmd_baseline_verify(&cli, name, *strict), @@ -5832,6 +5847,241 @@ fn cmd_supplier_check(cli: &Cli, format: &str) -> Result { Ok(true) } +/// `rivet supplier pull ` — federation handshake (#288). +/// +/// Reads the external-anchor's `cited-source`, fetches the source +/// payload locally (Phase 2: `kind: file` and `kind: reqif`), +/// verifies the sha256 if stamped, and stages the result under +/// `.rivet/supplier-cache///`. A manifest carrying +/// the [`FederationProvenance`] block is written alongside so the +/// auditor can reconstruct the source. Read-only on the supplier +/// side; idempotent on the cache side. +fn cmd_supplier_pull(cli: &Cli, anchor_id: &str, format: &str) -> Result { + use rivet_core::cited_source as cs; + use rivet_core::model::FederationProvenance; + + validate_format(format, &["text", "json"])?; + let ctx = ProjectContext::load(cli)?; + let project_root = cli.project.clone(); + + // Locate the anchor by ID. + let anchor = ctx + .store + .iter() + .find(|a| a.id == anchor_id) + .ok_or_else(|| anyhow::anyhow!("no artifact with id '{anchor_id}' in project store"))?; + if anchor.artifact_type != "external-anchor" { + anyhow::bail!( + "artifact '{anchor_id}' has type '{}', expected 'external-anchor'", + anchor.artifact_type + ); + } + + // Read `cited-source` — required for a pull. + let cited_raw = anchor.fields.get("cited-source").ok_or_else(|| { + anyhow::anyhow!( + "anchor '{anchor_id}' has no 'cited-source' field — \ + nothing to fetch. See docs/design/cross-org-supplier-traceability.md." + ) + })?; + let cited = cs::parse_cited_source(cited_raw) + .map_err(|e| anyhow::anyhow!("anchor '{anchor_id}' cited-source: {e}"))?; + + // Phase 2 supports `kind: file` and `kind: reqif`. + let kind_str = cited.kind.as_str(); + match cited.kind { + cs::CitedSourceKind::File | cs::CitedSourceKind::Reqif => {} + other => anyhow::bail!( + "supplier pull does not yet implement `kind: {}` (Phase 2 supports file|reqif). \ + See issue #288.", + other.as_str() + ), + } + + // Resolve to a local source path. Both supported kinds use the + // file-URI resolver (reqif:// degrades to file://). + let source_path = match cited.kind { + cs::CitedSourceKind::Reqif => cs::resolve_reqif_uri(&cited.uri, &project_root), + _ => cs::resolve_file_uri(&cited.uri, &project_root), + }; + + // Read the bytes once; compute hash; cross-check stamped sha256 + // if present. We refuse to write a cache entry whose stamped + // hash doesn't match the wire payload (auditor-grade: a drift + // would silently let the cache lie about what was delivered). + let bytes = std::fs::read(&source_path).with_context(|| { + format!( + "reading source for '{anchor_id}': {}", + source_path.display() + ) + })?; + let source_hash = cs::sha256_hex(&bytes); + if let Some(stamped) = &cited.sha256 { + if !stamped.eq_ignore_ascii_case(&source_hash) { + anyhow::bail!( + "anchor '{anchor_id}' cited-source sha256 drift: \ + stamped {stamped} but file hashes to {source_hash}. \ + Refusing to write a poisoned cache entry. Re-stamp the \ + anchor and retry." + ); + } + } + // For ReqIF, also verify well-formedness — caches a bad payload otherwise. + if matches!(cited.kind, cs::CitedSourceKind::Reqif) { + rivet_core::reqif::parse_reqif( + std::str::from_utf8(&bytes) + .map_err(|e| anyhow::anyhow!("ReqIF must be UTF-8 XML: {e}"))?, + &std::collections::HashMap::new(), + ) + .map_err(|e| anyhow::anyhow!("ReqIF parse failed for {}: {e}", source_path.display()))?; + } + + // Determine cache target. Path layout per #288 brief: + // .rivet/supplier-cache///. + let source_of_truth = anchor.fields.get("source-of-truth"); + let org = source_of_truth + .and_then(|v| v.as_mapping()) + .and_then(|m| m.get(serde_yaml::Value::String("org".into()))) + .and_then(|v| v.as_str()) + .unwrap_or("unknown-org"); + let contract = source_of_truth + .and_then(|v| v.as_mapping()) + .and_then(|m| m.get(serde_yaml::Value::String("contract".into()))) + .and_then(|v| v.as_str()) + .or_else(|| { + anchor + .fields + .get("contract-reference") + .and_then(|v| v.as_str()) + }) + .unwrap_or("unknown-contract"); + + let cache_root = project_root + .join(".rivet") + .join("supplier-cache") + .join(sanitize_path_component(org)) + .join(sanitize_path_component(contract)); + std::fs::create_dir_all(&cache_root) + .with_context(|| format!("creating supplier cache dir: {}", cache_root.display()))?; + + // Filename: .; ext from kind. For ReqIF use `.reqif`, + // for plain files inherit the source's extension when available. + let payload_name = match cited.kind { + cs::CitedSourceKind::Reqif => format!("{anchor_id}.reqif"), + _ => { + let ext = source_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("bin"); + format!("{anchor_id}.{ext}") + } + }; + let payload_path = cache_root.join(&payload_name); + let manifest_path = cache_root.join(format!("{anchor_id}.manifest.yaml")); + + // Idempotency: if the existing payload already has the same hash, + // we still refresh the manifest's `fetched-at` so re-runs leave a + // trail in the file mtime but don't churn the bytes. + let mut bytes_unchanged = false; + if payload_path.exists() { + if let Ok(existing) = std::fs::read(&payload_path) { + if cs::sha256_hex(&existing) == source_hash { + bytes_unchanged = true; + } + } + } + if !bytes_unchanged { + std::fs::write(&payload_path, &bytes) + .with_context(|| format!("writing cache payload: {}", payload_path.display()))?; + } + + let fetched_at = check::sources::current_iso8601_utc(); + let source_id = cited + .uri + .rsplit('/') + .next() + .map(|s| s.to_string()) + .unwrap_or_else(|| cited.uri.clone()); + let source_tool = match cited.kind { + cs::CitedSourceKind::Reqif => "reqif-1.2".to_string(), + _ => "file".to_string(), + }; + let provenance = FederationProvenance { + source_org: org.to_string(), + source_tool, + source_id: source_id.clone(), + anchor: anchor_id.to_string(), + fetched_at: fetched_at.clone(), + source_hash: source_hash.clone(), + mapping_recipe: None, + }; + // The manifest YAML is hand-authored to be readable; we wrap + // the provenance struct under a single top-level key so future + // additions (cached-artifacts:, signatures:) compose cleanly. + let manifest = serde_yaml::to_string(&serde_json::json!({ + "federation": provenance, + "cache": { + "payload": payload_name, + "kind": kind_str, + }, + })) + .map_err(|e| anyhow::anyhow!("serializing manifest: {e}"))?; + std::fs::write(&manifest_path, manifest) + .with_context(|| format!("writing manifest: {}", manifest_path.display()))?; + + if format == "json" { + let output = serde_json::json!({ + "command": "supplier pull", + "anchor": anchor_id, + "org": org, + "contract": contract, + "kind": kind_str, + "source_path": source_path.display().to_string(), + "source_hash": source_hash, + "fetched_at": fetched_at, + "cache_payload": payload_path.display().to_string(), + "cache_manifest": manifest_path.display().to_string(), + "bytes_unchanged": bytes_unchanged, + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } else { + println!("Pulled '{anchor_id}' from {kind_str} source:"); + println!(" org/contract: {org} / {contract}"); + println!(" source: {}", source_path.display()); + println!(" sha256: {source_hash}"); + println!(" cache payload: {}", payload_path.display()); + println!(" cache manifest: {}", manifest_path.display()); + println!(" fetched-at: {fetched_at}"); + if bytes_unchanged { + println!(" (payload bytes unchanged — manifest refreshed only)"); + } + } + Ok(true) +} + +/// Sanitize a single path component for use under +/// `.rivet/supplier-cache///`. Limits to ASCII +/// alphanum + `-_.` so a typo in `source-of-truth.contract` can't +/// escape the cache root. Empty after sanitization becomes +/// `unknown`. +fn sanitize_path_component(s: &str) -> String { + let cleaned: String = s + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + if cleaned.is_empty() { + "unknown".to_string() + } else { + cleaned + } +} + /// Test-to-requirement coverage via source markers. fn cmd_coverage_tests(cli: &Cli, format: &str, scan_paths: &[PathBuf]) -> Result { validate_format(format, &["text", "json"])?; @@ -7950,6 +8200,7 @@ fn parse_yaml_content( .map(|l| rivet_core::model::Link { link_type: l.r#type, target: l.target, + external: None, }) .collect(), fields: raw.fields, @@ -7980,6 +8231,7 @@ fn parse_yaml_content( .map(|l| rivet_core::model::Link { link_type: l.r#type, target: l.target, + external: None, }) .collect(), fields: raw.fields, @@ -11768,6 +12020,7 @@ fn cmd_add( .map(|(link_type, target)| Link { link_type: link_type.clone(), target: target.clone(), + external: None, }) .collect(); @@ -11827,6 +12080,7 @@ fn cmd_link(cli: &Cli, source_id: &str, link_type: &str, target_id: &str) -> Res let link = Link { link_type: link_type.to_string(), target: target_id.to_string(), + external: None, }; mutate::add_link_to_file(source_id, &link, &source_file) @@ -12274,6 +12528,7 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { Link { link_type: l.link_type.clone(), target, + external: None, } }) .collect(); @@ -12364,6 +12619,7 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { Link { link_type: l.link_type.clone(), target, + external: None, } }) .collect(); @@ -12419,6 +12675,7 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { let link = Link { link_type: link_type.clone(), target: target.clone(), + external: None, }; mutate::add_link_to_file(&source, &link, &source_file) diff --git a/rivet-cli/src/mcp.rs b/rivet-cli/src/mcp.rs index 7df05dc9..59bcec3f 100644 --- a/rivet-cli/src/mcp.rs +++ b/rivet-cli/src/mcp.rs @@ -947,6 +947,7 @@ fn tool_add(project_dir: &Path, arguments: &Value) -> Result { Some(Link { link_type: lt.to_string(), target: target.to_string(), + external: None, }) }) .collect() @@ -1180,6 +1181,7 @@ fn tool_link(project_dir: &Path, source: &str, link_type: &str, target: &str) -> let link = Link { link_type: link_type.to_string(), target: target.to_string(), + external: None, }; mutate::add_link_to_file(source, &link, &source_file)?; diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 645bfc8d..2d334975 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -3309,3 +3309,353 @@ fn supplier_check_classifies_delegated_dd_as_boundary() { "DD-DELEGATED must be classified as external_boundary, got: {value}" ); } + +// ── rivet supplier pull (#288 Phase 2) ─────────────────────────────────── + +/// Build a fresh dev-preset project, append an `external-anchor` +/// whose `cited-source` points to a local fixture, return the temp +/// dir alongside the file path so each test can choose what to +/// stamp/fetch. +fn supplier_pull_project_with_file( + payload: &[u8], + stamp_correct_hash: bool, +) -> (tempfile::TempDir, std::path::PathBuf) { + use sha2::{Digest, Sha256}; + 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:?}"); + + // Write the payload at a stable relative path. + let payload_path = dir.join("suppliers").join("acme.bin"); + std::fs::create_dir_all(payload_path.parent().unwrap()).unwrap(); + std::fs::write(&payload_path, payload).expect("write payload"); + + // Compute hash (or pick a wrong one). + let mut hasher = Sha256::new(); + hasher.update(payload); + let real_hash = format!("{:x}", hasher.finalize()); + let stamped = if stamp_correct_hash { + real_hash.clone() + } else { + "0".repeat(64) + }; + + let req_path = dir.join("artifacts").join("requirements.yaml"); + let existing = std::fs::read_to_string(&req_path).expect("read requirements"); + let extra = format!( + r#" + - id: ANCHOR-ACME-001 + type: external-anchor + title: Supplier ACME — bin payload + 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 + cited-source: + uri: suppliers/acme.bin + kind: file + sha256: {stamped} +"# + ); + std::fs::write(&req_path, format!("{existing}{extra}")).expect("write requirements"); + (tmp, payload_path) +} + +/// Happy path: `rivet supplier pull` for a `kind: file` anchor with +/// a correctly stamped sha256 writes the payload + a manifest into +/// `.rivet/supplier-cache///` and exits 0. +/// +/// Verifies: REQ-007 +#[test] +fn supplier_pull_kind_file_writes_cache_and_manifest() { + let payload = b"-- supplier payload v1 --"; + let (tmp, _) = supplier_pull_project_with_file(payload, true); + let output = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "pull", + "ANCHOR-ACME-001", + "--format", + "json", + ]) + .output() + .expect("run supplier pull"); + + assert!( + output.status.success(), + "supplier pull must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + 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 pull"); + assert_eq!(value["anchor"], "ANCHOR-ACME-001"); + assert_eq!(value["org"], "acme-electronics"); + assert_eq!(value["contract"], "PO-4711"); + assert_eq!(value["kind"], "file"); + + // Verify cache files exist under the expected layout. + let cache = tmp + .path() + .join(".rivet") + .join("supplier-cache") + .join("acme-electronics") + .join("PO-4711"); + let payload_path = cache.join("ANCHOR-ACME-001.bin"); + let manifest_path = cache.join("ANCHOR-ACME-001.manifest.yaml"); + assert!(payload_path.exists(), "payload cached at {payload_path:?}"); + assert!( + manifest_path.exists(), + "manifest written at {manifest_path:?}" + ); + let cached_bytes = std::fs::read(&payload_path).unwrap(); + assert_eq!(cached_bytes, payload, "payload bytes must match exactly"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + assert!( + manifest.contains("source-org: acme-electronics"), + "manifest must carry source-org, got: {manifest}" + ); + assert!( + manifest.contains("source-tool: file"), + "manifest must carry source-tool, got: {manifest}" + ); + assert!( + manifest.contains("anchor: ANCHOR-ACME-001"), + "manifest must carry anchor, got: {manifest}" + ); + assert!( + manifest.contains("source-hash:"), + "manifest must carry source-hash, got: {manifest}" + ); +} + +/// Stamped sha256 that doesn't match the payload bytes refuses to +/// write the cache (the auditor must re-stamp first). The error +/// surfaces on stderr with a `drift` message. +/// +/// Verifies: REQ-007 +#[test] +fn supplier_pull_refuses_on_sha256_drift() { + let payload = b"-- supplier payload v1 --"; + let (tmp, _) = supplier_pull_project_with_file(payload, false); + let output = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "pull", + "ANCHOR-ACME-001", + ]) + .output() + .expect("run supplier pull"); + + assert!( + !output.status.success(), + "supplier pull must exit non-zero on stamped-hash drift" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("drift") || stderr.contains("sha256"), + "error should mention sha256 drift, got: {stderr}" + ); + // No cache should be written. + let cache = tmp + .path() + .join(".rivet") + .join("supplier-cache") + .join("acme-electronics") + .join("PO-4711"); + let payload_path = cache.join("ANCHOR-ACME-001.bin"); + assert!( + !payload_path.exists(), + "drift must NOT write a cache entry, but found {payload_path:?}" + ); +} + +/// Re-running `rivet supplier pull` with the same source is a no-op +/// on the payload bytes (idempotent) and only refreshes the manifest. +/// +/// Verifies: REQ-007 +#[test] +fn supplier_pull_idempotent_on_re_run() { + let payload = b"-- supplier payload v1 --"; + let (tmp, _) = supplier_pull_project_with_file(payload, true); + + let first = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "pull", + "ANCHOR-ACME-001", + "--format", + "json", + ]) + .output() + .expect("first pull"); + assert!(first.status.success(), "first pull must succeed"); + let v1: serde_json::Value = + serde_json::from_slice(&first.stdout).expect("valid json on first pull"); + assert_eq!(v1["bytes_unchanged"], serde_json::Value::Bool(false)); + + let second = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "pull", + "ANCHOR-ACME-001", + "--format", + "json", + ]) + .output() + .expect("second pull"); + assert!(second.status.success(), "second pull must succeed"); + let v2: serde_json::Value = + serde_json::from_slice(&second.stdout).expect("valid json on second pull"); + assert_eq!( + v2["bytes_unchanged"], + serde_json::Value::Bool(true), + "re-pull with identical bytes must report bytes_unchanged=true" + ); +} + +/// `kind: reqif` end-to-end: minimal well-formed ReqIF in the +/// project, anchored cited-source, hash agrees → cache layout +/// shows `.reqif` extension and `source-tool: reqif-1.2`. +/// +/// Verifies: REQ-007 +#[test] +fn supplier_pull_kind_reqif_writes_reqif_extension() { + use sha2::{Digest, Sha256}; + 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:?}"); + + let reqif_xml = r#" + + + + supplier-tool + Supplier delivery + + + + + + + + + + + + +"#; + let payload_path = dir.join("suppliers").join("acme.reqif"); + std::fs::create_dir_all(payload_path.parent().unwrap()).unwrap(); + std::fs::write(&payload_path, reqif_xml).expect("write reqif"); + let mut hasher = Sha256::new(); + hasher.update(reqif_xml.as_bytes()); + let stamped = format!("{:x}", hasher.finalize()); + + let req_path = dir.join("artifacts").join("requirements.yaml"); + let existing = std::fs::read_to_string(&req_path).expect("read requirements"); + let extra = format!( + r#" + - id: ANCHOR-REQIF-001 + type: external-anchor + title: Supplier ACME — reqif payload + status: approved + fields: + source-of-truth: + org: acme-electronics + contract: PO-9000 + expected-derived-types: + - requirement + received-status: received-as-reqif + cited-source: + uri: suppliers/acme.reqif + kind: reqif + sha256: {stamped} +"# + ); + std::fs::write(&req_path, format!("{existing}{extra}")).expect("write requirements"); + + let output = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "supplier", + "pull", + "ANCHOR-REQIF-001", + "--format", + "json", + ]) + .output() + .expect("run supplier pull"); + assert!( + output.status.success(), + "kind: reqif pull must succeed. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(v["kind"], "reqif"); + + let cache = dir + .join(".rivet") + .join("supplier-cache") + .join("acme-electronics") + .join("PO-9000"); + let payload_in_cache = cache.join("ANCHOR-REQIF-001.reqif"); + assert!(payload_in_cache.exists(), "cache must hold .reqif file"); + let manifest = std::fs::read_to_string(cache.join("ANCHOR-REQIF-001.manifest.yaml")).unwrap(); + assert!( + manifest.contains("source-tool: reqif-1.2"), + "manifest must mark source-tool as reqif-1.2, got: {manifest}" + ); +} + +/// Non-existent anchor ID fails with a clear "no artifact" error. +/// +/// Verifies: REQ-007 +#[test] +fn supplier_pull_unknown_anchor_errors() { + let tmp = supplier_project(); + let output = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "supplier", + "pull", + "ANCHOR-DOES-NOT-EXIST", + ]) + .output() + .expect("run supplier pull"); + + assert!( + !output.status.success(), + "unknown anchor must exit non-zero" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ANCHOR-DOES-NOT-EXIST"), + "error must name the missing anchor, got: {stderr}" + ); +} diff --git a/rivet-core/benches/core_benchmarks.rs b/rivet-core/benches/core_benchmarks.rs index 3594922e..6b338c6e 100644 --- a/rivet-core/benches/core_benchmarks.rs +++ b/rivet-core/benches/core_benchmarks.rs @@ -85,6 +85,7 @@ fn generate_artifacts(n: usize, links_per: usize) -> Vec { Link { link_type: "leads-to-loss".into(), target: format!("BENCH-{target_idx}"), + external: None, } }) .filter(|l| l.target != format!("BENCH-{i}")) diff --git a/rivet-core/src/bundle.rs b/rivet-core/src/bundle.rs index a7721714..eb7b78e9 100644 --- a/rivet-core/src/bundle.rs +++ b/rivet-core/src/bundle.rs @@ -231,6 +231,7 @@ mod tests { .map(|(t, target)| Link { link_type: t.into(), target: target.into(), + external: None, }) .collect(), fields: BTreeMap::new(), diff --git a/rivet-core/src/cited_source.rs b/rivet-core/src/cited_source.rs index 1c707cc0..c02e2cf6 100644 --- a/rivet-core/src/cited_source.rs +++ b/rivet-core/src/cited_source.rs @@ -78,6 +78,15 @@ impl CitedSourceKind { pub fn is_local(self) -> bool { matches!(self, CitedSourceKind::File) } + + /// True for kinds whose backend is implemented in Phase 2 (this + /// PR, issue #288). Phase 2 adds the ReqIF backend on top of + /// Phase 1's file backend; both are read-only hash-of-bytes + /// checks. ReqIF additionally parses the XML so a malformed + /// upstream surfaces as a typed error. + pub fn is_local_phase2(self) -> bool { + matches!(self, CitedSourceKind::File | CitedSourceKind::Reqif) + } } /// Parsed and validated `cited-source` field. @@ -269,6 +278,31 @@ pub fn resolve_file_uri(uri: &str, project_root: &Path) -> PathBuf { } } +/// Resolve a `kind: reqif` URI to an absolute filesystem path. +/// +/// Phase 2 (issue #288) implements the local-file form only: +/// - bare relative paths (e.g. `./suppliers/acme.reqif`) +/// - bare absolute paths +/// - `reqif://...` URIs (the path component, treated identically to +/// `file://`) +/// - `file://...` URIs (allowed for `kind: reqif` so projects can +/// reuse existing `cited-source` blocks with the file scheme) +/// +/// HTTP(S) ReqIF endpoints are out of scope for Phase 2 — they +/// require a fetch backend with auth handling (design doc §5.2). +pub fn resolve_reqif_uri(uri: &str, project_root: &Path) -> PathBuf { + if let Some(rest) = uri.strip_prefix("reqif://") { + let p = Path::new(rest); + return if p.is_absolute() { + p.to_path_buf() + } else { + project_root.join(p) + }; + } + // Reuse the file-URI resolver for `file://` and bare-path forms. + resolve_file_uri(uri, project_root) +} + /// Outcome of checking a single `cited-source` field against its source. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CheckOutcome { @@ -403,12 +437,16 @@ pub fn check_cited_source( project_root: &Path, _check_remote: bool, ) -> CheckOutcome { - if !src.kind.is_local() { - // Phase 2 wires the http/github/oslc/reqif/polarion backends. + if !src.kind.is_local_phase2() { + // The http / github / oslc / polarion backends still skip + // through; their network paths land in Phase 3+. return CheckOutcome::SkippedRemote; } - let path = resolve_file_uri(&src.uri, project_root); + let path = match src.kind { + CitedSourceKind::Reqif => resolve_reqif_uri(&src.uri, project_root), + _ => resolve_file_uri(&src.uri, project_root), + }; let computed = match sha256_file(&path) { Ok(h) => h, Err(e) => { @@ -418,6 +456,19 @@ pub fn check_cited_source( } }; + // For ReqIF we additionally smoke-test that the file at least + // parses as XML, surfacing malformed upstream payloads at validate + // time rather than at federation-pull time. Issue #288, design + // doc §4.4 calls for "sha verification at fetch time" — we run + // the hash unconditionally; the XML check is a guardrail. + if matches!(src.kind, CitedSourceKind::Reqif) { + if let Err(e) = parse_reqif_well_formed(&path) { + return CheckOutcome::FileError { + reason: format!("{}: malformed ReqIF: {e}", path.display()), + }; + } + } + match &src.sha256 { None => CheckOutcome::MissingHash { computed }, Some(stamped) if stamped.eq_ignore_ascii_case(&computed) => CheckOutcome::Match, @@ -425,6 +476,25 @@ pub fn check_cited_source( } } +/// Read the file at `path` and verify it parses as a ReqIF XML +/// document. Returns an error if the file is unreadable or the XML +/// is malformed. We don't fully type-check the SPEC-OBJECT graph +/// here — that's reqif.rs's `parse_reqif`'s job at import time; +/// this is a lightweight well-formedness gate so `rivet validate` +/// flags rotten supplier payloads early. +fn parse_reqif_well_formed(path: &Path) -> Result<(), String> { + let xml = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + // Empty XML is rejected — every ReqIF root has a element. + if xml.trim().is_empty() { + return Err("empty XML".to_string()); + } + // Reuse the strict reqif.rs parser with an empty type-map. Any + // failure (missing root, bad nesting, etc.) returns Err. + let _ = crate::reqif::parse_reqif(&xml, &std::collections::HashMap::new()) + .map_err(|e| e.to_string())?; + Ok(()) +} + /// Validate every artifact's `cited-source` field and return the /// resulting diagnostics. /// @@ -1323,4 +1393,153 @@ artifacts: .unwrap(); assert!(!changed); } + + // ── ReqIF backend (#288 Phase 2) ───────────────────────────────── + + /// Minimal well-formed ReqIF XML. Reused across the reqif backend + /// tests; the spec-object/header counts are kept small so each + /// fixture stays inspectable. + fn minimal_reqif_xml() -> &'static str { + r#" + + + + rivet-test-fixture + Phase 2 ReqIF backend fixture + + + + + + + + + + + + +"# + } + + /// `kind: reqif` with a valid local file: the backend computes + /// the sha256 and returns `Match` when the stamped value agrees. + /// Without the Phase 2 change, this kind returns `SkippedRemote` + /// — so this test fails on a Phase 1-only build. + /// + /// Verifies: REQ-004 + #[test] + fn check_cited_source_reqif_match_when_hash_agrees() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("supplier.reqif"); + let xml = minimal_reqif_xml(); + std::fs::write(&path, xml).unwrap(); + let stamped = sha256_hex(xml.as_bytes()); + let cs = CitedSource { + uri: path.file_name().unwrap().to_string_lossy().into_owned(), + kind: CitedSourceKind::Reqif, + sha256: Some(stamped), + last_checked: None, + }; + let outcome = check_cited_source(&cs, dir.path(), false); + assert_eq!(outcome, CheckOutcome::Match, "reqif match must verify"); + } + + /// `kind: reqif` with a valid local file but stale stamped hash + /// returns `Drift` carrying the freshly computed hash. + /// + /// Verifies: REQ-004 + #[test] + fn check_cited_source_reqif_drift_when_hash_differs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("supplier.reqif"); + std::fs::write(&path, minimal_reqif_xml()).unwrap(); + let cs = CitedSource { + uri: "supplier.reqif".into(), + kind: CitedSourceKind::Reqif, + sha256: Some("0".repeat(64)), + last_checked: None, + }; + let outcome = check_cited_source(&cs, dir.path(), false); + match outcome { + CheckOutcome::Drift { computed } => { + assert_eq!(computed.len(), 64, "hex sha256 is 64 chars"); + } + other => panic!("expected Drift, got {other:?}"), + } + } + + /// `kind: reqif` with no `sha256:` stamp returns `MissingHash` + /// carrying the computed hash so the auditor can stamp the file + /// in one go. + /// + /// Verifies: REQ-004 + #[test] + fn check_cited_source_reqif_missing_hash_returns_computed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.reqif"); + std::fs::write(&path, minimal_reqif_xml()).unwrap(); + let cs = CitedSource { + uri: "s.reqif".into(), + kind: CitedSourceKind::Reqif, + sha256: None, + last_checked: None, + }; + let outcome = check_cited_source(&cs, dir.path(), false); + match outcome { + CheckOutcome::MissingHash { computed } => { + assert_eq!(computed, sha256_hex(minimal_reqif_xml().as_bytes())); + } + other => panic!("expected MissingHash, got {other:?}"), + } + } + + /// Malformed ReqIF XML at the cited path surfaces as + /// `FileError` with a `malformed ReqIF` message, not a silent + /// hash drift. This is the "ReqIF well-formedness gate" guarding + /// against rotten supplier payloads getting stamped. + /// + /// Verifies: REQ-004 + #[test] + fn check_cited_source_reqif_rejects_malformed_xml() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bad.reqif"); + std::fs::write(&path, "formed").unwrap(); + let cs = CitedSource { + uri: "bad.reqif".into(), + kind: CitedSourceKind::Reqif, + sha256: None, + last_checked: None, + }; + let outcome = check_cited_source(&cs, dir.path(), false); + match outcome { + CheckOutcome::FileError { reason } => { + assert!( + reason.contains("malformed ReqIF"), + "FileError must flag malformed ReqIF, got: {reason}" + ); + } + other => panic!("expected FileError, got {other:?}"), + } + } + + /// The `reqif://...` URI form resolves to the project-relative + /// path (Phase 2 only supports the local-file equivalent). + /// + /// Verifies: REQ-004 + #[test] + fn resolve_reqif_uri_handles_scheme_and_relative() { + let proj = Path::new("/tmp/project-root"); + assert_eq!( + resolve_reqif_uri("reqif://suppliers/acme.reqif", proj), + proj.join("suppliers/acme.reqif") + ); + assert_eq!( + resolve_reqif_uri("./suppliers/acme.reqif", proj), + proj.join("./suppliers/acme.reqif") + ); + assert_eq!( + resolve_reqif_uri("/abs/path.reqif", proj), + Path::new("/abs/path.reqif") + ); + } } diff --git a/rivet-core/src/diff.rs b/rivet-core/src/diff.rs index 71d3b9c3..f40f368b 100644 --- a/rivet-core/src/diff.rs +++ b/rivet-core/src/diff.rs @@ -155,6 +155,7 @@ impl ArtifactDiff { .map(|(lt, tgt)| Link { link_type: (*lt).clone(), target: (*tgt).clone(), + external: None, }) .collect(); let links_removed: Vec = base_links @@ -162,6 +163,7 @@ impl ArtifactDiff { .map(|(lt, tgt)| Link { link_type: (*lt).clone(), target: (*tgt).clone(), + external: None, }) .collect(); diff --git a/rivet-core/src/embed.rs b/rivet-core/src/embed.rs index 0ef65a43..c72f166a 100644 --- a/rivet-core/src/embed.rs +++ b/rivet-core/src/embed.rs @@ -2288,6 +2288,7 @@ traceability-rules: req.links.push(crate::model::Link { link_type: "verifies".into(), target: format!("TC-{i:03}"), + external: None, }); } artifacts.push(req); @@ -2679,6 +2680,7 @@ traceability-rules: req1.links.push(crate::model::Link { link_type: "verifies".into(), target: "TC-1".into(), + external: None, }); let store = make_store(vec![req1, plain("TC-1", "test", None, &[])]); let graph = LinkGraph::build(&store, &schema); diff --git a/rivet-core/src/externals.rs b/rivet-core/src/externals.rs index f424ddd1..ac295a44 100644 --- a/rivet-core/src/externals.rs +++ b/rivet-core/src/externals.rs @@ -1277,11 +1277,13 @@ mod tests { links: vec![ Link { link_type: "traces-to".to_string(), - target: "REQ-001".to_string(), // links back to our local artifact + target: "REQ-001".to_string(), // links back to our local artifact, + external: None, }, Link { link_type: "mitigates".to_string(), - target: "EXT-OTHER".to_string(), // links to something in their own project + target: "EXT-OTHER".to_string(), // links to something in their own project, + external: None, }, ], fields: std::collections::BTreeMap::new(), @@ -1320,7 +1322,8 @@ mod tests { tags: vec![], links: vec![Link { link_type: "traces-to".to_string(), - target: "other:REQ-001".to_string(), // cross-external ref + target: "other:REQ-001".to_string(), // cross-external ref, + external: None, }], fields: std::collections::BTreeMap::new(), fields_per_variant: Default::default(), diff --git a/rivet-core/src/formats/generic.rs b/rivet-core/src/formats/generic.rs index 7975633c..4c2a566e 100644 --- a/rivet-core/src/formats/generic.rs +++ b/rivet-core/src/formats/generic.rs @@ -123,14 +123,7 @@ impl Adapter for GenericYamlAdapter { description: a.description.clone(), status: a.status.clone(), tags: a.tags.clone(), - links: a - .links - .iter() - .map(|l| GenericLink { - link_type: l.link_type.clone(), - target: l.target.clone(), - }) - .collect(), + links: a.links.clone(), fields: a.fields.clone(), fields_per_variant: a.fields_per_variant.clone(), provenance: a.provenance.clone(), @@ -165,7 +158,7 @@ struct GenericArtifact { #[serde(default, skip_serializing_if = "Vec::is_empty")] tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - links: Vec, + links: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] fields: BTreeMap, #[serde( @@ -178,13 +171,6 @@ struct GenericArtifact { provenance: Option, } -#[derive(Deserialize, serde::Serialize)] -struct GenericLink { - #[serde(rename = "type")] - link_type: String, - target: String, -} - pub fn parse_generic_yaml(content: &str, source: Option<&Path>) -> Result, Error> { let file: GenericFile = serde_yaml::from_str(content)?; @@ -198,14 +184,7 @@ pub fn parse_generic_yaml(content: &str, source: Option<&Path>) -> Result, +} + +/// Structured cross-organizational link metadata. +/// +/// Carried by `*-external` link types (currently +/// `derives-from-external`). The `anchor` is the in-store +/// `external-anchor` artifact this link terminates at — coverage and +/// link-graph machinery use that as the actual target. The other +/// fields describe *what* was delegated, *to whom*, and *which +/// revision the upstream tool delivered*. +/// +/// Mirrors `cited-source` semantics: `sha256` plus `last-synced` +/// stamp the snapshot. The auditor can verify the upstream +/// document hasn't drifted since the supplier handed it over. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExternalLinkTarget { + /// Originating organization (e.g. `acme-electronics`). Free-form + /// short name — matches the `source-of-truth.org` on the anchor. + pub org: String, + /// Contract / PO / DIA reference. Matches the anchor's + /// `contract-reference` field at the audit-trail level. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contract: Option, + /// Foreign ID at the supplier (e.g. their ReqIF identifier). + #[serde(default, rename = "doc-id", skip_serializing_if = "Option::is_none")] + pub doc_id: Option, + /// ISO-8601 date of the last successful pull. + #[serde( + default, + rename = "last-synced", + skip_serializing_if = "Option::is_none" + )] + pub last_synced: Option, + /// Hex sha256 of the wire payload the supplier delivered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, + /// Local `external-anchor` artifact ID. Required: this is what + /// the in-store traceability graph follows. + pub anchor: ArtifactId, +} + +// ── Link YAML round-trip ───────────────────────────────────────────────── +// +// YAML accepts two `target:` shapes: +// +// target: REQ-001 # flat string — every existing link type +// +// target: # mapping — *-external link types only +// org: acme +// contract: PO-4711 +// doc-id: REQ-SW-022 +// anchor: ANCHOR-ACME-001 +// +// `Link::target` is always populated with the in-store ID; for the +// mapping form that's the `anchor:` field. The remaining mapping +// keys land in `Link::external`. Serialization preserves whichever +// shape was used: a link with `external: Some(...)` emits the +// mapping form, otherwise the flat string. + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum LinkTargetWire { + Flat(String), + Structured(ExternalLinkTarget), +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LinkWire { + #[serde(rename = "type")] + link_type: String, + target: LinkTargetWire, +} + +impl serde::Serialize for Link { + fn serialize(&self, serializer: S) -> Result { + let target = match &self.external { + Some(ext) => LinkTargetWire::Structured(ext.clone()), + None => LinkTargetWire::Flat(self.target.clone()), + }; + let wire = LinkWire { + link_type: self.link_type.clone(), + target, + }; + wire.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Link { + fn deserialize>(deserializer: D) -> Result { + let wire = LinkWire::deserialize(deserializer)?; + Ok(match wire.target { + LinkTargetWire::Flat(s) => Link { + link_type: wire.link_type, + target: s, + external: None, + }, + LinkTargetWire::Structured(ext) => { + if ext.anchor.is_empty() { + return Err(serde::de::Error::custom( + "structured link target requires non-empty 'anchor' field", + )); + } + let target = ext.anchor.clone(); + Link { + link_type: wire.link_type, + target, + external: Some(ext), + } + } + }) + } +} + +impl Link { + /// Construct a plain link by ID. Convenience used in tests and + /// non-external code paths. + pub fn new(link_type: S, target: T) -> Self + where + S: Into, + T: Into, + { + Link { + link_type: link_type.into(), + target: target.into(), + external: None, + } + } } /// AI provenance metadata for an artifact. @@ -88,6 +229,54 @@ pub struct Provenance { rename = "reviewed-by" )] pub reviewed_by: Option, + /// Federation provenance — populated only when this artifact was + /// imported from another organization via `rivet supplier pull` + /// (issue #288). The block is omitted entirely for first-party + /// artifacts and AI/human-authored ones. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub federation: Option, +} + +/// Cross-organizational provenance for a federated artifact. +/// +/// Stamped on every artifact written under `.rivet/supplier-cache/` +/// by `rivet supplier pull` (issue #288, design doc §4.6). The +/// auditor uses this block to reconstruct: which supplier, which +/// contract, which payload (by sha256), and when the pull happened. +/// +/// Parallels the AI [`Provenance`] block in spirit — both answer +/// "where did this artifact come from?" — but the dimension is +/// cross-org instead of cross-author. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FederationProvenance { + /// Originating organization (e.g. `acme-electronics`). + #[serde(rename = "source-org")] + pub source_org: String, + /// Tool of record at the supplier (e.g. `reqif-1.2`, `polarion-3.21`, + /// `doors-9.7`). Free-form string — matches the anchor's + /// `received-status` variant where applicable. + #[serde(rename = "source-tool")] + pub source_tool: String, + /// Foreign artifact ID at the supplier. + #[serde(rename = "source-id")] + pub source_id: String, + /// Local `external-anchor` artifact ID this import belongs to. + pub anchor: ArtifactId, + /// ISO-8601 timestamp the pull completed. + #[serde(rename = "fetched-at")] + pub fetched_at: String, + /// Hex sha256 of the wire payload at fetch time. + #[serde(rename = "source-hash")] + pub source_hash: String, + /// Path to the mapping recipe applied at import (Phase 3). `None` + /// in Phase 2 — fields land in `fields:` as-imported. + #[serde( + default, + rename = "mapping-recipe", + skip_serializing_if = "Option::is_none" + )] + pub mapping_recipe: Option, } /// An artifact — the fundamental unit of the data model. @@ -466,6 +655,198 @@ description: Initial release assert_eq!(configs[1].description, None); assert_eq!(configs[2].name, "v0.3.0"); } + + // ── derives-from-external structured target (#288) ────────────── + + /// Flat-string target round-trips unchanged — every existing + /// link type. Regression test: the new custom (de)serializer for + /// `Link` must not break the legacy shape. + /// + /// Verifies: REQ-010 + #[test] + fn link_flat_target_yaml_roundtrip() { + let l = Link::new("satisfies", "REQ-001"); + let yaml = serde_yaml::to_string(&l).unwrap(); + assert!( + yaml.contains("type: satisfies"), + "flat-link YAML should include type, got: {yaml}" + ); + assert!( + yaml.contains("target: REQ-001"), + "flat-link YAML should include scalar target, got: {yaml}" + ); + // Round-trip parse. + let parsed: Link = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed.link_type, "satisfies"); + assert_eq!(parsed.target, "REQ-001"); + assert!(parsed.external.is_none(), "no structured external"); + } + + /// Structured `target:` mapping parses to `external: Some(...)` + /// with the `anchor:` value mirrored into `target`. This is the + /// Phase 2 wire shape the design doc §4.2 specifies. + /// + /// Verifies: REQ-010 + #[test] + fn link_structured_target_yaml_parse() { + let yaml = " +type: derives-from-external +target: + org: acme-electronics + contract: PO-4711 + doc-id: REQ-SW-022 + last-synced: 2026-04-20 + sha256: 7f3c0000 + anchor: ANCHOR-ACME-001 +"; + let parsed: Link = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(parsed.link_type, "derives-from-external"); + assert_eq!( + parsed.target, "ANCHOR-ACME-001", + "structured target must mirror `anchor:` into Link.target" + ); + let ext = parsed + .external + .expect("structured target populates external"); + assert_eq!(ext.org, "acme-electronics"); + assert_eq!(ext.contract.as_deref(), Some("PO-4711")); + assert_eq!(ext.doc_id.as_deref(), Some("REQ-SW-022")); + assert_eq!(ext.last_synced.as_deref(), Some("2026-04-20")); + assert_eq!(ext.sha256.as_deref(), Some("7f3c0000")); + assert_eq!(ext.anchor, "ANCHOR-ACME-001"); + } + + /// Structured target serializes back to the mapping form (not the + /// flat string), so `rivet add --to ...` and edit tooling can + /// round-trip without losing the cross-org metadata. + /// + /// Verifies: REQ-010 + #[test] + fn link_structured_target_yaml_serialize_then_parse() { + let original = Link { + link_type: "derives-from-external".into(), + target: "ANCHOR-X".into(), + external: Some(ExternalLinkTarget { + org: "acme".into(), + contract: Some("PO-1".into()), + doc_id: Some("REQ-99".into()), + last_synced: None, + sha256: Some("deadbeef".into()), + anchor: "ANCHOR-X".into(), + }), + }; + let yaml = serde_yaml::to_string(&original).unwrap(); + // The output MUST be the structured mapping form, not `target: ANCHOR-X`. + assert!( + yaml.contains("anchor: ANCHOR-X"), + "serialized YAML should carry anchor:, got: {yaml}" + ); + assert!( + yaml.contains("org: acme"), + "serialized YAML should carry org:, got: {yaml}" + ); + // Round-trip back. + let parsed: Link = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed, original, "structured link must round-trip exactly"); + } + + /// A structured target missing `anchor:` is a hard schema error — + /// without it, the in-store graph has nothing to follow. We surface + /// it at deserialize time rather than producing a silent + /// not-yet-loaded link. + /// + /// Verifies: REQ-010 + #[test] + fn link_structured_target_requires_anchor() { + let yaml = " +type: derives-from-external +target: + org: acme + contract: PO-1 + anchor: \"\" +"; + let err = serde_yaml::from_str::(yaml).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("anchor"), + "missing-anchor error should mention 'anchor', got: {msg}" + ); + } + + // ── FederationProvenance shape (#288) ──────────────────────────── + + /// `FederationProvenance` round-trips through serde_yaml with the + /// canonical dashed-key form (`source-org`, `fetched-at`, …). + /// + /// Verifies: REQ-010 + #[test] + fn federation_provenance_yaml_roundtrip() { + let fp = FederationProvenance { + source_org: "acme-electronics".into(), + source_tool: "reqif-1.2".into(), + source_id: "REQ-SW-022".into(), + anchor: "ANCHOR-ACME-001".into(), + fetched_at: "2026-05-16T08:00:00Z".into(), + source_hash: "deadbeef".into(), + mapping_recipe: None, + }; + let yaml = serde_yaml::to_string(&fp).unwrap(); + assert!(yaml.contains("source-org: acme-electronics")); + assert!(yaml.contains("source-tool: reqif-1.2")); + assert!(yaml.contains("source-id: REQ-SW-022")); + assert!(yaml.contains("anchor: ANCHOR-ACME-001")); + assert!( + yaml.contains("fetched-at: '2026-05-16T08:00:00Z'") + || yaml.contains("fetched-at: \"2026-05-16T08:00:00Z\"") + || yaml.contains("fetched-at: 2026-05-16T08:00:00Z") + ); + assert!(yaml.contains("source-hash: deadbeef")); + let parsed: FederationProvenance = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed, fp); + } + + /// `Provenance` carries an optional `federation:` block, omitted + /// from serialized YAML when `None`. Stays backward-compatible + /// with existing AI-provenance YAML. + /// + /// Verifies: REQ-010 + #[test] + fn provenance_federation_block_is_optional() { + let prov = Provenance { + created_by: "ai-assisted".into(), + model: Some("claude-opus-4-7".into()), + session_id: None, + timestamp: Some("2026-05-16T08:00:00Z".into()), + reviewed_by: None, + federation: None, + }; + let yaml = serde_yaml::to_string(&prov).unwrap(); + assert!( + !yaml.contains("federation"), + "federation: None should not appear in YAML, got: {yaml}" + ); + // Add federation block, expect it to surface. + let prov_fed = Provenance { + federation: Some(FederationProvenance { + source_org: "acme".into(), + source_tool: "reqif-1.2".into(), + source_id: "X-1".into(), + anchor: "ANCHOR-1".into(), + fetched_at: "2026-05-16T08:00:00Z".into(), + source_hash: "abc".into(), + mapping_recipe: None, + }), + ..prov.clone() + }; + let yaml2 = serde_yaml::to_string(&prov_fed).unwrap(); + assert!( + yaml2.contains("federation:"), + "federation: Some(...) must surface, got: {yaml2}" + ); + assert!(yaml2.contains("source-org: acme")); + let parsed: Provenance = serde_yaml::from_str(&yaml2).unwrap(); + assert_eq!(parsed.federation, prov_fed.federation); + } } /// Configuration for a named baseline (release scope). diff --git a/rivet-core/src/oslc.rs b/rivet-core/src/oslc.rs index cb9e4656..8d19a6ae 100644 --- a/rivet-core/src/oslc.rs +++ b/rivet-core/src/oslc.rs @@ -645,18 +645,21 @@ pub fn oslc_to_artifact(resource: &OslcResource) -> Result { link_list.push(Link { link_type: "elaborated-by".to_string(), target: extract_link_target(&link.href), + external: None, }); } for link in &r.satisfied_by { link_list.push(Link { link_type: "satisfied-by".to_string(), target: extract_link_target(&link.href), + external: None, }); } for link in &r.tracked_by { link_list.push(Link { link_type: "tracked-by".to_string(), target: extract_link_target(&link.href), + external: None, }); } (r.description.clone(), None, link_list, BTreeMap::new()) @@ -667,6 +670,7 @@ pub fn oslc_to_artifact(resource: &OslcResource) -> Result { link_list.push(Link { link_type: "validates".to_string(), target: extract_link_target(&link.href), + external: None, }); } (r.description.clone(), None, link_list, BTreeMap::new()) @@ -677,6 +681,7 @@ pub fn oslc_to_artifact(resource: &OslcResource) -> Result { link_list.push(Link { link_type: "reports-on".to_string(), target: extract_link_target(&link.href), + external: None, }); } (None, r.status.clone(), link_list, BTreeMap::new()) @@ -687,12 +692,14 @@ pub fn oslc_to_artifact(resource: &OslcResource) -> Result { link_list.push(Link { link_type: "implements".to_string(), target: extract_link_target(&link.href), + external: None, }); } for link in &r.affects_requirement { link_list.push(Link { link_type: "affects".to_string(), target: extract_link_target(&link.href), + external: None, }); } ( @@ -1546,6 +1553,7 @@ mod tests { links: vec![Link { link_type: "satisfied-by".to_string(), target: "IMPL-001".to_string(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -1579,6 +1587,7 @@ mod tests { links: vec![Link { link_type: "validates".to_string(), target: "REQ-001".to_string(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -1610,6 +1619,7 @@ mod tests { links: vec![Link { link_type: "reports-on".to_string(), target: "TC-001".to_string(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -1645,10 +1655,12 @@ mod tests { Link { link_type: "implements".to_string(), target: "REQ-001".to_string(), + external: None, }, Link { link_type: "affects".to_string(), target: "REQ-002".to_string(), + external: None, }, ], fields: BTreeMap::new(), diff --git a/rivet-core/src/proofs.rs b/rivet-core/src/proofs.rs index 9555761b..5e2b364e 100644 --- a/rivet-core/src/proofs.rs +++ b/rivet-core/src/proofs.rs @@ -293,6 +293,7 @@ mod proofs { links.push(Link { link_type: "depends-on".into(), target: target_id, + external: None, }); } @@ -440,6 +441,7 @@ mod proofs { vec![Link { link_type: "dep".into(), target: "B".into(), + external: None, }], )) .unwrap(); @@ -450,6 +452,7 @@ mod proofs { vec![Link { link_type: "dep".into(), target: "C".into(), + external: None, }], )) .unwrap(); @@ -473,6 +476,7 @@ mod proofs { vec![Link { link_type: "dep".into(), target: "CYC-B".into(), + external: None, }], )) .unwrap(); @@ -483,6 +487,7 @@ mod proofs { vec![Link { link_type: "dep".into(), target: "CYC-A".into(), + external: None, }], )) .unwrap(); @@ -521,14 +526,17 @@ mod proofs { Link { link_type: "satisfies".into(), target: "SC-1".into(), + external: None, }, Link { link_type: "satisfies".into(), target: "SC-3".into(), + external: None, }, Link { link_type: "implements".into(), target: "DD-001".into(), + external: None, }, ], fields, diff --git a/rivet-core/src/reqif.rs b/rivet-core/src/reqif.rs index b67b5d26..44420872 100644 --- a/rivet-core/src/reqif.rs +++ b/rivet-core/src/reqif.rs @@ -871,6 +871,7 @@ pub fn parse_reqif(xml: &str, type_map: &HashMap) -> Result) -> Result .map(|(lt, t)| Link { link_type: lt.to_string(), target: t.to_string(), + external: None, }) .collect(); a diff --git a/rivet-core/src/validate.rs b/rivet-core/src/validate.rs index 6d7cf821..7e300678 100644 --- a/rivet-core/src/validate.rs +++ b/rivet-core/src/validate.rs @@ -1209,10 +1209,12 @@ mod tests { Link { link_type: "undeclared-type".to_string(), target: "B-1".to_string(), + external: None, }, Link { link_type: "undeclared-type".to_string(), target: "B-2".to_string(), + external: None, }, ]; let mut store = Store::new(); @@ -1285,6 +1287,7 @@ mod tests { let links = vec![Link { link_type: "mitigated_by".to_string(), target: "MIT-1".to_string(), + external: None, }]; let art = make_artifact("A-1", "test", None, None, vec![], links); let diags = req.check(&art, "test-rule", Severity::Warning); @@ -1941,6 +1944,7 @@ then: dd.links = vec![Link { link_type: "satisfies".to_string(), target: "REQ-001".to_string(), + external: None, }]; store.insert(dd).unwrap(); store @@ -2027,6 +2031,7 @@ then: dd.links = vec![Link { link_type: "satisfies".to_string(), target: "REQ-001".to_string(), + external: None, }]; store.insert(dd).unwrap(); @@ -2108,6 +2113,7 @@ then: Link { link_type: "satisfies".to_string(), target: target.to_string(), + external: None, } } @@ -2569,6 +2575,7 @@ then: vec![Link { link_type: "satisfies".to_string(), target: "B-1".to_string(), + external: None, }], ); assert!( @@ -2675,6 +2682,7 @@ then: links: vec![Link { link_type: "verifies".into(), target: target.into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -2881,6 +2889,7 @@ then: links: vec![Link { link_type: "verifies".into(), target: "REQ-MISSING".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -2963,6 +2972,7 @@ then: links: vec![Link { link_type: "verifies".into(), target: "REQ-MISSING".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -3342,6 +3352,7 @@ then: links: vec![Link { link_type: "verifies".into(), // correct target: "REQ-001".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), diff --git a/rivet-core/src/wasm_runtime.rs b/rivet-core/src/wasm_runtime.rs index 7f1f1307..b678b080 100644 --- a/rivet-core/src/wasm_runtime.rs +++ b/rivet-core/src/wasm_runtime.rs @@ -521,6 +521,7 @@ fn convert_wit_artifact_to_host( .map(|l| Link { link_type: l.link_type, target: l.target, + external: None, }) .collect(); @@ -575,6 +576,7 @@ fn convert_host_artifact_to_wit( .map(|l| wit::Link { link_type: l.link_type.clone(), target: l.target.clone(), + external: None, }) .collect(); @@ -798,6 +800,7 @@ mod tests { links: vec![wit::Link { link_type: "satisfies".into(), target: "REQ-000".into(), + external: None, }], fields: vec![wit::FieldEntry { key: "priority".into(), diff --git a/rivet-core/src/yaml_hir.rs b/rivet-core/src/yaml_hir.rs index 78ec0e99..2970ab93 100644 --- a/rivet-core/src/yaml_hir.rs +++ b/rivet-core/src/yaml_hir.rs @@ -467,6 +467,7 @@ fn extract_nested_sub_artifacts( sa.artifact.links.push(Link { link_type: link_type.clone(), target: pid.clone(), + external: None, }); } } @@ -522,6 +523,7 @@ fn extract_sequence_items_with_inherited( sa.artifact.links.push(Link { link_type: link_type.clone(), target: value.clone(), + external: None, }); } } else if !sa.artifact.fields.contains_key(field) { @@ -612,6 +614,7 @@ fn extract_section_item( links.push(Link { link_type: link_type.clone(), target, + external: None, }); } // Also handle single-value shorthand: `uca: UCA-1` @@ -621,6 +624,7 @@ fn extract_section_item( links.push(Link { link_type: link_type.clone(), target, + external: None, }); } } @@ -1031,6 +1035,9 @@ fn extract_links(value_node: &SyntaxNode) -> Vec { let mut link_type = String::new(); let mut target = String::new(); + // Issue #288: when `target:` is a mapping (cross-org link + // types), the structured payload lands here. + let mut external: Option = None; for entry in map.children() { if node_kind(&entry) != SyntaxKind::MappingEntry { @@ -1052,8 +1059,37 @@ fn extract_links(value_node: &SyntaxNode) -> Vec { } } "target" => { - if let Some(t) = scalar_text(&v) { - target = t; + // Issue #288: a structured `target:` is a YAML + // mapping nested under the link entry. The CST + // may surface this as a `Mapping` child of the + // value, OR as raw newline-indented text when + // the parser didn't promote it to a typed node. + // Cover both: dedent the raw value text so the + // mapping's relative indentation becomes + // absolute, then round-trip through serde_yaml. + // If it parses as `ExternalLinkTarget`, prefer + // the structured shape; otherwise fall back to + // scalar_text for the flat `target: ID` form. + let raw = v.text().to_string(); + let looks_like_mapping = child_of_kind(&v, SyntaxKind::Mapping).is_some() + || raw.trim().contains(':'); + let mut handled = false; + if looks_like_mapping { + let dedented = dedent_block(&raw); + if let Ok(ext) = + serde_yaml::from_str::(&dedented) + { + if !ext.anchor.is_empty() { + target = ext.anchor.clone(); + external = Some(ext); + handled = true; + } + } + } + if !handled { + if let Some(t) = scalar_text(&v) { + target = t; + } } } _ => {} @@ -1061,7 +1097,11 @@ fn extract_links(value_node: &SyntaxNode) -> Vec { } if !link_type.is_empty() && !target.is_empty() { - links.push(Link { link_type, target }); + links.push(Link { + link_type, + target, + external, + }); } } @@ -1073,32 +1113,58 @@ fn extract_links(value_node: &SyntaxNode) -> Vec { /// Re-parses the value text via serde_yaml and converts `type` + `target` /// into `Link`s. Unknown shapes and parse errors silently produce no /// links (matching the permissive behaviour of the primary path). +/// +/// Accepts both the flat-string and the structured `target:` mapping +/// shapes (issue #288) by delegating directly to `Link`'s +/// `Deserialize` impl. fn extract_links_via_serde(value_node: &SyntaxNode) -> Vec { - #[derive(serde::Deserialize)] - struct RawLink { - #[serde(rename = "type")] - link_type: Option, - target: Option, - } let text = value_node.text().to_string(); let trimmed = text.trim(); if trimmed.is_empty() { return Vec::new(); } - let Ok(raws) = serde_yaml::from_str::>(trimmed) else { + let Ok(raws) = serde_yaml::from_str::>(trimmed) else { return Vec::new(); }; raws.into_iter() - .filter_map(|r| match (r.link_type, r.target) { - (Some(t), Some(tgt)) if !t.is_empty() && !tgt.is_empty() => Some(Link { - link_type: t, - target: tgt, - }), - _ => None, - }) + .filter(|l| !l.link_type.is_empty() && !l.target.is_empty()) .collect() } +/// Strip the minimum common leading-whitespace prefix from every +/// non-empty line of `s`. The CST surfaces an indented block-scalar +/// value with leading whitespace preserved; YAML re-parsing needs +/// the inner mapping to start at column 0. Tabs and spaces are +/// treated as equal-cost whitespace. Used by the issue #288 +/// structured-target parsing path. +fn dedent_block(s: &str) -> String { + let lines: Vec<&str> = s.split('\n').collect(); + // Find the minimum leading whitespace across non-blank lines. + let mut min_indent: Option = None; + for line in &lines { + if line.trim().is_empty() { + continue; + } + let n = line.chars().take_while(|c| matches!(c, ' ' | '\t')).count(); + min_indent = Some(min_indent.map_or(n, |m| m.min(n))); + } + let strip = min_indent.unwrap_or(0); + let mut out = String::with_capacity(s.len()); + for (i, line) in lines.iter().enumerate() { + if line.trim().is_empty() { + // Preserve blank lines as-is. + out.push_str(line); + } else { + let trimmed_prefix: String = line.chars().skip(strip).collect(); + out.push_str(&trimmed_prefix); + } + if i + 1 < lines.len() { + out.push('\n'); + } + } + out +} + // ── Provenance extraction ───────────────────────────────────────────── /// Extract a `Provenance` struct from a `provenance:` mapping value node. @@ -1143,6 +1209,7 @@ fn extract_provenance(value_node: &SyntaxNode) -> Option { session_id, timestamp, reviewed_by, + federation: None, }) } @@ -1679,6 +1746,47 @@ artifacts: ); } + /// Structured `target:` mapping (issue #288) is recognised by the + /// CST extractor: the anchor becomes `Link.target` and the rest + /// flows into `Link.external`. Regression guard: the previous + /// implementation called `scalar_text` on the mapping value and + /// silently mis-targeted the link at the first key text ("org"). + /// + /// Verifies: REQ-028 + #[test] + fn links_extraction_structured_external_target() { + let source = "\ +artifacts: + - id: A-1 + type: req + title: cross-org link + links: + - type: derives-from-external + target: + org: acme-electronics + contract: PO-4711 + doc-id: REQ-SW-022 + anchor: ANCHOR-ACME-001 +"; + let hir = extract_generic_artifacts(source); + assert_eq!(hir.artifacts.len(), 1); + let links = &hir.artifacts[0].artifact.links; + assert_eq!(links.len(), 1, "expected one link, got {links:?}"); + assert_eq!(links[0].link_type, "derives-from-external"); + assert_eq!( + links[0].target, "ANCHOR-ACME-001", + "structured-target link must populate Link.target with anchor" + ); + let ext = links[0] + .external + .as_ref() + .expect("structured-target link must populate Link.external"); + assert_eq!(ext.org, "acme-electronics"); + assert_eq!(ext.contract.as_deref(), Some("PO-4711")); + assert_eq!(ext.doc_id.as_deref(), Some("REQ-SW-022")); + assert_eq!(ext.anchor, "ANCHOR-ACME-001"); + } + /// 4. Custom fields stored as serde_yaml::Value correctly. #[test] fn custom_fields_typed_correctly() { diff --git a/rivet-core/tests/externals_schemas.rs b/rivet-core/tests/externals_schemas.rs index 1cf467b6..7611e5fd 100644 --- a/rivet-core/tests/externals_schemas.rs +++ b/rivet-core/tests/externals_schemas.rs @@ -255,6 +255,7 @@ fn external_link_types_are_not_flagged_unknown() { links: vec![Link { link_type: "implements".to_string(), target: "spar:OTHER".to_string(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), diff --git a/rivet-core/tests/externals_sync.rs b/rivet-core/tests/externals_sync.rs index 3dfdcf6f..d87845bd 100644 --- a/rivet-core/tests/externals_sync.rs +++ b/rivet-core/tests/externals_sync.rs @@ -195,6 +195,7 @@ fn backlinks_from_spar_to_local() { links: vec![Link { link_type: "allocated-from".into(), target: "REQ-001".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), diff --git a/rivet-core/tests/integration.rs b/rivet-core/tests/integration.rs index e3d8051f..eb1e157a 100644 --- a/rivet-core/tests/integration.rs +++ b/rivet-core/tests/integration.rs @@ -176,6 +176,7 @@ fn test_generic_yaml_roundtrip() { vec![Link { link_type: "satisfies".into(), target: "RT-001".into(), + external: None, }], { let mut f = BTreeMap::new(); @@ -368,6 +369,7 @@ fn test_traceability_matrix() { h.links.push(Link { link_type: "leads-to-loss".into(), target: target.into(), + external: None, }); store.insert(h).unwrap(); } @@ -463,6 +465,7 @@ fn test_query_filters() { vec![Link { link_type: "satisfies".into(), target: "A-3".into(), + external: None, }], BTreeMap::new(), )) @@ -499,6 +502,7 @@ fn test_query_filters() { vec![Link { link_type: "implements".into(), target: "A-3".into(), + external: None, }], BTreeMap::new(), )) @@ -589,6 +593,7 @@ fn test_link_graph_integration() { h.links.push(Link { link_type: "leads-to-loss".into(), target: "L-1".into(), + external: None, }); store.insert(h).unwrap(); @@ -596,6 +601,7 @@ fn test_link_graph_integration() { sc.links.push(Link { link_type: "prevents".into(), target: "H-1".into(), + external: None, }); store.insert(sc).unwrap(); @@ -657,6 +663,7 @@ fn test_aspice_traceability_rules() { sys_req.links.push(Link { link_type: "derives-from".into(), target: "STKH-1".into(), + external: None, }); store.insert(sys_req).unwrap(); @@ -664,6 +671,7 @@ fn test_aspice_traceability_rules() { sw_req.links.push(Link { link_type: "derives-from".into(), target: "SYSREQ-1".into(), + external: None, }); store.insert(sw_req).unwrap(); @@ -758,6 +766,7 @@ fn test_reqif_roundtrip() { vec![Link { link_type: "derives-from".into(), target: "REQ-001".into(), + external: None, }], BTreeMap::new(), ), @@ -770,6 +779,7 @@ fn test_reqif_roundtrip() { vec![Link { link_type: "verifies".into(), target: "REQ-001".into(), + external: None, }], BTreeMap::new(), ), @@ -881,6 +891,7 @@ fn test_reqif_store_integration() { vec![Link { link_type: "derives-from".into(), target: "SYS-001".into(), + external: None, }], BTreeMap::new(), ), @@ -1003,6 +1014,7 @@ fn test_diff_modified_artifact() { vec![Link { link_type: "satisfies".into(), target: "M-2".into(), + external: None, }], { let mut f = BTreeMap::new(); @@ -1026,10 +1038,12 @@ fn test_diff_modified_artifact() { Link { link_type: "satisfies".into(), target: "M-2".into(), + external: None, }, Link { link_type: "derives-from".into(), target: "M-3".into(), + external: None, }, ], { diff --git a/rivet-core/tests/mutate_integration.rs b/rivet-core/tests/mutate_integration.rs index 2830cef0..cf543636 100644 --- a/rivet-core/tests/mutate_integration.rs +++ b/rivet-core/tests/mutate_integration.rs @@ -330,6 +330,7 @@ fn test_remove_with_incoming_links_rejected() { vec![Link { link_type: "satisfies".to_string(), target: "REQ-001".to_string(), + external: None, }], BTreeMap::new(), )) diff --git a/rivet-core/tests/proptest_core.rs b/rivet-core/tests/proptest_core.rs index 896b8554..f3d21201 100644 --- a/rivet-core/tests/proptest_core.rs +++ b/rivet-core/tests/proptest_core.rs @@ -254,6 +254,7 @@ proptest! { art.links.push(Link { link_type: "leads-to-loss".into(), target: ids[target_idx].clone(), + external: None, }); store.upsert(art); } @@ -316,6 +317,7 @@ fn prop_validation_determinism() { links: vec![Link { link_type: "leads-to-loss".into(), target: "DET-L1".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -335,6 +337,7 @@ fn prop_validation_determinism() { links: vec![Link { link_type: "leads-to-loss".into(), target: "NONEXISTENT".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), diff --git a/rivet-core/tests/proptest_operations.rs b/rivet-core/tests/proptest_operations.rs index 43aad6ff..05d40b6d 100644 --- a/rivet-core/tests/proptest_operations.rs +++ b/rivet-core/tests/proptest_operations.rs @@ -293,6 +293,7 @@ proptest! { let links = vec![Link { link_type: link_type.clone(), target: target.clone(), + external: None, }]; let art = make_artifact_with_links(id, artifact_type, links); if store.insert(art).is_ok() { diff --git a/rivet-core/tests/proptest_sexpr.rs b/rivet-core/tests/proptest_sexpr.rs index 9fb5e29b..6b16aaca 100644 --- a/rivet-core/tests/proptest_sexpr.rs +++ b/rivet-core/tests/proptest_sexpr.rs @@ -104,6 +104,7 @@ fn arb_artifact() -> impl Strategy { .map(|(lt, tgt)| Link { link_type: lt.to_string(), target: tgt, + external: None, }) .collect(); Artifact { diff --git a/rivet-core/tests/schema_validation_rules.rs b/rivet-core/tests/schema_validation_rules.rs index db945eea..8688b15b 100644 --- a/rivet-core/tests/schema_validation_rules.rs +++ b/rivet-core/tests/schema_validation_rules.rs @@ -49,6 +49,7 @@ fn artifact(id: &str, art_type: &str, status: &str, links: &[(&str, &str)]) -> A .map(|(lt, tgt)| Link { link_type: (*lt).into(), target: (*tgt).into(), + external: None, }) .collect(), fields: BTreeMap::new(), diff --git a/rivet-core/tests/sexpr_doc_examples.rs b/rivet-core/tests/sexpr_doc_examples.rs index 147c176b..2461cf66 100644 --- a/rivet-core/tests/sexpr_doc_examples.rs +++ b/rivet-core/tests/sexpr_doc_examples.rs @@ -47,6 +47,7 @@ fn art(id: &str, t: &str, tags: &[&str], status: Option<&str>, links: &[(&str, & .map(|(lt, tgt)| Link { link_type: (*lt).into(), target: (*tgt).into(), + external: None, }) .collect(), fields: BTreeMap::new(), diff --git a/rivet-core/tests/sexpr_fuzz.rs b/rivet-core/tests/sexpr_fuzz.rs index 2ca65218..40792d02 100644 --- a/rivet-core/tests/sexpr_fuzz.rs +++ b/rivet-core/tests/sexpr_fuzz.rs @@ -62,6 +62,7 @@ fn fixture_store() -> (Store, LinkGraph) { .map(|(lt, tgt)| Link { link_type: (*lt).into(), target: (*tgt).into(), + external: None, }) .collect(), fields: BTreeMap::new(), @@ -509,10 +510,12 @@ proptest! { Link { link_type: "verifies".into(), target: "REQ-001".into(), + external: None, }, Link { link_type: "verifies".into(), target: "REQ-UNRESOLVED".into(), + external: None, }, ], fields: BTreeMap::new(), diff --git a/rivet-core/tests/sexpr_predicate_matrix.rs b/rivet-core/tests/sexpr_predicate_matrix.rs index c7ced76d..d2359de2 100644 --- a/rivet-core/tests/sexpr_predicate_matrix.rs +++ b/rivet-core/tests/sexpr_predicate_matrix.rs @@ -56,14 +56,17 @@ fn base_artifact() -> Artifact { Link { link_type: "satisfies".into(), target: "SC-1".into(), + external: None, }, Link { link_type: "satisfies".into(), target: "SC-3".into(), + external: None, }, Link { link_type: "implements".into(), target: "DD-001".into(), + external: None, }, ], fields: { @@ -530,6 +533,7 @@ fn linked_from_source_filter_is_honoured() { links: vec![Link { link_type: "satisfies".into(), target: "SC-1".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -546,6 +550,7 @@ fn linked_from_source_filter_is_honoured() { links: vec![Link { link_type: "satisfies".into(), target: "SC-1".into(), + external: None, }], fields: BTreeMap::new(), fields_per_variant: Default::default(), @@ -844,6 +849,7 @@ fn chain_store() -> (Store, LinkGraph) { vec![Link { link_type: "satisfies".into(), target: t.into(), + external: None, }] }) .unwrap_or_default(), diff --git a/rivet-core/tests/stpa_roundtrip.rs b/rivet-core/tests/stpa_roundtrip.rs index c48b7284..18485acb 100644 --- a/rivet-core/tests/stpa_roundtrip.rs +++ b/rivet-core/tests/stpa_roundtrip.rs @@ -141,6 +141,7 @@ fn test_broken_link_detected() { links: vec![rivet_core::model::Link { link_type: "leads-to-loss".into(), target: "L-NONEXISTENT".into(), + external: None, }], fields: Default::default(), fields_per_variant: Default::default(), diff --git a/schemas/common.yaml b/schemas/common.yaml index f92aba63..dd92c268 100644 --- a/schemas/common.yaml +++ b/schemas/common.yaml @@ -89,6 +89,16 @@ link-types: inverse: derived-into description: Source is derived from the target + - name: derives-from-external + inverse: derived-into-external + description: > + Cross-organizational derivation: the source artifact derives from an + upstream document owned by another organization. The link target is + a mapping `{org, contract, doc-id, last-synced, sha256, anchor}` + that names the supplier, the contract, the upstream ID, and a local + `external-anchor` artifact for graph navigation. See + docs/design/cross-org-supplier-traceability.md §4.2 and issue #288. + - name: mitigates inverse: mitigated-by description: Source mitigates or prevents the target