From d9d629669cae252fa0e85cdb09ca717cc4e88db8 Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Sat, 22 Aug 2026 10:59:40 +0000 Subject: [PATCH 1/2] feat(iso): add dedicated ZLayer::SystemsModel variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirement/Decision/Cost content (ledgrrr#184) gets its own isometric layer rather than folding into the existing 6 or the still-unimplemented proposed Domain layer (docs/ontological-implementation-spec.md §6.1), per decision 2 in docs/systems-modeling-registry-rescope.md: independent toggle/color in the renderer over reusing an ontological-concepts layer. index=6, base_z=816.0 (continuing the existing 136.0 spacing), color #be185d (distinct from all 6 existing hexes). No HasVisualization impls wired yet for Requirement/Decision/Cost themselves — that requires also touching xtask's viz_manifest export + the checked-in viz-manifest.json per iso_objects.rs's own convention, tracked as a follow-on alongside task 6 (ledgerr-mcp/contract.rs wiring), not done here. --- crates/ledger-core/src/iso.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ledger-core/src/iso.rs b/crates/ledger-core/src/iso.rs index 77cc3cb1..0b16966e 100644 --- a/crates/ledger-core/src/iso.rs +++ b/crates/ledger-core/src/iso.rs @@ -51,10 +51,16 @@ pub enum ZLayer { Legal, FormalProof, Attestation, + /// SysML-v2 systems-modeling primitives (Requirement/Decision/Cost) — + /// a dedicated layer rather than folded into the (still-unimplemented, + /// see `docs/ontological-implementation-spec.md` §6.1) proposed `Domain` + /// layer, per explicit decision: independent toggle/color in the + /// isometric renderer over reusing an ontological-concepts layer. + SystemsModel, } impl ZLayer { - /// 0-based layer index (max 5). + /// 0-based layer index (max 6). pub fn index(self) -> u8 { match self { ZLayer::Document => 0, @@ -63,6 +69,7 @@ impl ZLayer { ZLayer::Legal => 3, ZLayer::FormalProof => 4, ZLayer::Attestation => 5, + ZLayer::SystemsModel => 6, } } @@ -75,6 +82,7 @@ impl ZLayer { ZLayer::Legal => "#b91c1c", ZLayer::FormalProof => "#0f766e", ZLayer::Attestation => "#b45309", + ZLayer::SystemsModel => "#be185d", } } @@ -87,6 +95,7 @@ impl ZLayer { ZLayer::Legal => 408.0, ZLayer::FormalProof => 544.0, ZLayer::Attestation => 680.0, + ZLayer::SystemsModel => 816.0, } } @@ -99,6 +108,7 @@ impl ZLayer { ZLayer::Legal => "Legal", ZLayer::FormalProof => "FormalProof", ZLayer::Attestation => "Attestation", + ZLayer::SystemsModel => "SystemsModel", } } } @@ -641,17 +651,31 @@ mod tests { ZLayer::Legal, ZLayer::FormalProof, ZLayer::Attestation, + ZLayer::SystemsModel, ]; for layer in all { assert!( - layer.index() <= 5, - "ZLayer::{:?} has index {} > 5", + layer.index() <= 6, + "ZLayer::{:?} has index {} > 6", layer, layer.index() ); } } + #[test] + fn z_layer_systems_model_is_dedicated_not_pipeline() { + // Decision 2: Requirement/Decision/Cost content gets its own layer, + // not folded into an existing one (e.g. Pipeline or a future Domain). + assert_eq!(ZLayer::SystemsModel.index(), 6); + assert_eq!(ZLayer::SystemsModel.label(), "SystemsModel"); + assert_ne!(ZLayer::SystemsModel.color(), ZLayer::Pipeline.color()); + assert_eq!( + ZLayer::SystemsModel.base_z(), + ZLayer::Attestation.base_z() + 136.0 + ); + } + #[test] fn semantic_type_known_name_nonempty() { let all = [ @@ -771,6 +795,7 @@ mod tests { ZLayer::Legal, ZLayer::FormalProof, ZLayer::Attestation, + ZLayer::SystemsModel, ]; for layer in all { assert!(!layer.to_string().is_empty()); From 77b9155142b85fa496aaf84752df0650557270c2 Mon Sep 17 00:00:00 2001 From: Brian Horakh <35611074+elasticdotventures@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:34:41 +1000 Subject: [PATCH 2/2] feat: spike reqif-opa-mcp over MCP + Requirement converter (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reqif-mcp-spike): Rust MCP client for reqif-opa-mcp + Requirement converter Spike per decision 6 (docs/systems-modeling-registry-rescope.md §5/§6 task 5): reqif-opa-mcp is wrapped over MCP, not ported to Rust; arc-kit-au stays the canonical decision+cost ledger. New crate reqif-mcp-spike: - McpHttpClient: minimal blocking client for reqif-opa-mcp's Streamable- HTTP MCP server (FastMCP 3.0.0b1, protocol 2024-11-05). Handles the initialize handshake + mcp-session-id header, the mandatory notifications/initialized follow-up, and tools/call, extracting the JSON-RPC result out of the single-frame SSE response body. - RequirementRecord: mirrors reqif-opa-mcp's requirement-record.schema.json exactly (uid/key/subtypes/status/policy_baseline/rubrics/text/attrs). - requirement_record_to_node(): converts a RequirementRecord into the arc-kit-au Requirement struct from ledgrrr#184 (ArtifactKind::Requirement / NodeType::Requirement). requirement_id<-uid, title<-key, rationale<-text, source<-attrs.source_standard(+source_url) falling back to the policy baseline id, status passed through, related_decisions always empty (reqif-opa-mcp carries no decision links; those are created later in arc-kit-au itself). Verified live end-to-end against a real reqif-opa-mcp checkout (uv sync --extra ingest-lite; uv run python -m reqif_mcp --http --port 8123) parsing both its own sample derived baselines, samples/standards/derived/{nist_ssdf_dogfood,owasp_asvs_cwe}.reqif (4 and 7 requirements respectively), through reqif_parse -> reqif_query -> requirement_record_to_node -> Requirement::node_id(), all producing correct, deterministic req: NodeIds. That live run is captured as an #[ignore]'d integration test (tests/live_server.rs) since it needs an external repo checkout + Python/uv, not something CI or a fresh clone has; re-run manually with REQIF_MCP_URL set to a running server. cargo test -p reqif-mcp-spike: 5 unit tests pass, 1 ignored (live). cargo check --workspace --all-features: clean. cargo clippy -p reqif-mcp-spike --all-targets: clean. * feat(ledgerr-mcp): wire Requirement/Decision/Cost into contract.rs (task 6) - EvidenceArgs gains import_requirement/record_decision/record_cost actions, each constructing the corresponding arc-kit-au node (Requirement/Decision/Cost from ledgrrr#184) and inserting it into the evidence graph via EvidenceGraph::add_node. DuplicateNode is treated as idempotent success (content-hash dedup), matching the idempotent semantics already established for EvidenceBuilder's ensure_* methods. - parse_evidence_node_type gains requirement/req, decision/dec, cost (plus previously-missing rnd_activity/tax_offset, same class of gap). - Summary's node_counts and ListNodes' invalid-type error message extended to include the 3 new types. - EVIDENCE_TOOL's contract.rs action list and purpose string updated. Verified: new tests/evidence_requirement_decision_cost.rs (3 tests, covering import+list+detail+summary, record_decision+record_cost, and idempotent re-import) all pass; cargo build -p ledgerr-mcp --features legacy clean (1 pre-existing unrelated warning). * docs: regenerate mcp-capability-contract.md (drift from task 6) Task 6 (ledgrrr#186's second commit) added import_requirement/ record_decision/record_cost to ledgerr_evidence's EVIDENCE_TOOL actions in contract.rs but never re-ran regen-docs, so the checked-in mcp-capability-contract.md drifted — caught by CI's check-drift step on downstream stacked PRs (#190, #193). Ran: cargo run -p ledgerr-mcp --bin regen-docs * docs: regenerate viz-manifest.json (stale version field, 1.9.0 -> 1.10.0) Pre-existing drift, unrelated to Requirement/Decision/Cost content (28 objects, unchanged) -- the workspace version was bumped to 1.10.0 at some point after this artifact was last regenerated. Only surfaced now because check-drift's earlier mcp-capability-contract.md failure (fixed in dd168f7) was masking this second, independent drift. Ran: cargo run -p xtask-mcpb -- export-viz-manifest * feat(iso): HasVisualization + viz_manifest wiring for Requirement/Decision/Cost (#190) * feat(iso): HasVisualization + viz_manifest wiring for Requirement/Decision/Cost Adds dedicated SemanticType::{Requirement,Decision,Cost} variants (iso.rs) and HasVisualization impls for arc_kit_au::node::{Requirement, Decision,Cost} (iso_objects.rs, gated behind the arc-kit-au feature, matching ontology.rs's arc_kit_bridge precedent), all routed to ZLayer::SystemsModel (added in ledgrrr#185). Wires the 3 new types into xtask's export_viz_manifest (now 31 domain types, up from 28) and regenerates the checked-in viz-manifest.json. Updates pipeline_e2e.rs's EXPECTED_ENTRY_COUNT and representative-type assertions to match. Verified: cargo test -p ledger-core --lib (185 passed), cargo test -p ledgerr-mcp --test pipeline_e2e (manifest count test passes), cargo check --workspace --all-features clean. This closes out the one remaining gap from ledgrrr#185 (task 4 in docs/systems-modeling-registry-rescope.md's §6), left explicitly unfinished there pending this larger change. * feat(arc-kit-au): retrofit #[derive(SysmlBlock)] onto existing node types (#193) Applies the sysml-derive macro (feat/sysml-derive-spike, #183) to the 8 pre-existing arc-kit-au node structs that predate the systems-modeling epic: SourceDoc, ExtractedRow, Transaction, Classification, ModelProposal, OperatorApproval, ValidationIssue, WorkbookRow. This was the second of two explicitly-deferred follow-ons from task 3 (#184) — the first (HasVisualization/viz_manifest wiring for Requirement/Decision/Cost) landed as part of this same stack. The derive is purely syntactic (walks named fields via syn, stringifies each field's type via quote!) so it applies uniformly regardless of field type — no per-struct special-casing needed, confirmed by re-reading crates/sysml-derive/src/lib.rs before applying. Verified: cargo test -p sysml-derive (2 tests), cargo test -p arc-kit-au (46 tests, unchanged from pre-retrofit baseline), cargo check --workspace --all-features (clean, one pre-existing unrelated warning in ledgerr-mcp/src/fbar.rs), cargo clippy -p arc-kit-au --all-features (clean). --- Cargo.lock | 13 + Cargo.toml | 1 + crates/arc-kit-au/src/node.rs | 16 +- crates/ledger-core/src/iso.rs | 15 + crates/ledger-core/src/iso_objects.rs | 64 +++- crates/ledgerr-mcp/src/contract.rs | 45 ++- crates/ledgerr-mcp/src/mcp_adapter.rs | 140 +++++++- .../evidence_requirement_decision_cost.rs | 142 ++++++++ crates/ledgerr-mcp/tests/pipeline_e2e.rs | 5 +- crates/reqif-mcp-spike/Cargo.toml | 16 + crates/reqif-mcp-spike/src/lib.rs | 329 ++++++++++++++++++ crates/reqif-mcp-spike/tests/live_server.rs | 102 ++++++ docs/mcp-capability-contract.md | 2 +- ui/docs/public/viz-manifest.json | 287 +++++++++++++++ xtask/Cargo.toml | 1 + xtask/src/viz_manifest.rs | 6 +- 16 files changed, 1170 insertions(+), 14 deletions(-) create mode 100644 crates/ledgerr-mcp/tests/evidence_requirement_decision_cost.rs create mode 100644 crates/reqif-mcp-spike/Cargo.toml create mode 100644 crates/reqif-mcp-spike/src/lib.rs create mode 100644 crates/reqif-mcp-spike/tests/live_server.rs diff --git a/Cargo.lock b/Cargo.lock index 2d1e9499..9e1f3de2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7133,6 +7133,18 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqif-mcp-spike" +version = "0.1.0" +dependencies = [ + "arc-kit-au", + "chrono", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -11565,6 +11577,7 @@ dependencies = [ name = "xtask-mcpb" version = "1.10.0" dependencies = [ + "arc-kit-au", "clap", "hex", "ledger-core", diff --git a/Cargo.toml b/Cargo.toml index da89e327..8b0a694b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/beankeeper-bridge", "crates/ledgerr-desktop-agent", "crates/sysml-derive", + "crates/reqif-mcp-spike", ] resolver = "2" diff --git a/crates/arc-kit-au/src/node.rs b/crates/arc-kit-au/src/node.rs index dc930261..81059d27 100644 --- a/crates/arc-kit-au/src/node.rs +++ b/crates/arc-kit-au/src/node.rs @@ -124,7 +124,7 @@ pub fn content_hash(parts: &[&str]) -> String { } /// Source document evidence. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct SourceDoc { /// Original filename (must follow VENDOR--ACCOUNT--YYYY-MM--DOCTYPE.ext) pub filename: String, @@ -151,7 +151,7 @@ impl SourceDoc { } /// Extracted row from document parsing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct ExtractedRow { pub account_id: String, pub date: String, @@ -176,7 +176,7 @@ impl ExtractedRow { } /// Deterministic transaction record. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct Transaction { /// Blake3 hash of account/date/amount/description pub tx_id: String, @@ -194,7 +194,7 @@ impl Transaction { } /// Classification applied to transaction. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct Classification { pub tx_id: String, pub category: String, @@ -221,7 +221,7 @@ impl Classification { } /// Model-generated classification proposal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct ModelProposal { pub tx_id: String, pub model_name: String, @@ -247,7 +247,7 @@ impl ModelProposal { } /// Operator approval/rejection of model proposal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct OperatorApproval { pub tx_id: String, pub operator_id: String, @@ -275,7 +275,7 @@ impl OperatorApproval { /// Distinct from Classification — validation artifacts represent rule/constraint /// failures, not categorization decisions. PRD-4 Phase 2 requires /// classification_artifact → validation_artifact as a separate chain step. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct ValidationIssue { pub tx_id: String, pub rule: String, @@ -301,7 +301,7 @@ impl ValidationIssue { } /// Final workbook row in CPA export. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct WorkbookRow { pub tx_id: String, pub sheet_name: String, diff --git a/crates/ledger-core/src/iso.rs b/crates/ledger-core/src/iso.rs index 0b16966e..cb64638a 100644 --- a/crates/ledger-core/src/iso.rs +++ b/crates/ledger-core/src/iso.rs @@ -137,6 +137,12 @@ pub enum SemanticType { Issue, Proof, Attestation, + /// SysML-v2 systems-modeling requirement record — see `ZLayer::SystemsModel`. + Requirement, + /// SysML-v2 systems-modeling decision record — see `ZLayer::SystemsModel`. + Decision, + /// SysML-v2 systems-modeling cost record — see `ZLayer::SystemsModel`. + Cost, Unknown, } @@ -155,6 +161,9 @@ impl SemanticType { SemanticType::Issue => "issue", SemanticType::Proof => "proof", SemanticType::Attestation => "attestation", + SemanticType::Requirement => "requirement", + SemanticType::Decision => "decision", + SemanticType::Cost => "cost", SemanticType::Unknown => "unknown", } } @@ -690,6 +699,9 @@ mod tests { SemanticType::Issue, SemanticType::Proof, SemanticType::Attestation, + SemanticType::Requirement, + SemanticType::Decision, + SemanticType::Cost, SemanticType::Unknown, ]; for st in all { @@ -816,6 +828,9 @@ mod tests { SemanticType::Issue, SemanticType::Proof, SemanticType::Attestation, + SemanticType::Requirement, + SemanticType::Decision, + SemanticType::Cost, SemanticType::Unknown, ]; for st in all { diff --git a/crates/ledger-core/src/iso_objects.rs b/crates/ledger-core/src/iso_objects.rs index 0b0fbcfd..9c724e37 100644 --- a/crates/ledger-core/src/iso_objects.rs +++ b/crates/ledger-core/src/iso_objects.rs @@ -1,4 +1,4 @@ -//! `HasVisualization` implementations for the 28 domain types that participate +//! `HasVisualization` implementations for the 31 domain types that participate //! in the isometric pipeline view. //! //! Every `impl HasVisualization` added here must also be registered in @@ -469,9 +469,64 @@ let lots = wallet.cost_basis_lots(method); // FIFO | HIFO | ACB"#, } } +// ============================================================================ +// SYSTEMS MODEL — Requirement/Decision/Cost (z=6, SystemsModel layer) +// ============================================================================ + +#[cfg(feature = "arc-kit-au")] +mod systems_model { + use arc_kit_au::node::{Cost, Decision, Requirement}; + + use crate::iso::{HasVisualization, RhaiDsl, SemanticType, VisualizationSpec, ZLayer}; + + impl HasVisualization for Requirement { + fn viz_spec() -> VisualizationSpec { + VisualizationSpec { + semantic_type: SemanticType::Requirement, + z_layer: ZLayer::SystemsModel, + rhai_dsl: RhaiDsl::new( + r#"let req = load_requirement(source); +if req.status == "active" { link_decisions(req.related_decisions) }"#, + ), + description: "SysML v2 systems-modeling requirement — traceable spec record, linked to the decisions it constrains", + } + } + } + + impl HasVisualization for Decision { + fn viz_spec() -> VisualizationSpec { + VisualizationSpec { + semantic_type: SemanticType::Decision, + z_layer: ZLayer::SystemsModel, + rhai_dsl: RhaiDsl::new( + r#"let decision = record_decision(subject, rationale, decided_by); +link_requirements(decision.related_requirements);"#, + ), + description: "Version-controlled decision record — rationale + decider, linked back to the requirements it satisfies", + } + } + } + + impl HasVisualization for Cost { + fn viz_spec() -> VisualizationSpec { + VisualizationSpec { + semantic_type: SemanticType::Cost, + z_layer: ZLayer::SystemsModel, + rhai_dsl: RhaiDsl::new( + r#"let cost = record_cost(subject, amount, currency); +if cost.related_decision.is_some() { attribute_to_decision(cost) }"#, + ), + description: "Version-controlled cost record — monetary amount attributed to a decision for systems-modeling cost traceability", + } + } + } +} + #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "arc-kit-au")] + use arc_kit_au::node::{Cost, Decision, Requirement}; #[test] fn all_viz_spec_rhai_dsl_has_valid_syntax() { @@ -517,6 +572,13 @@ mod tests { check!(UsRdcCredit); check!(CryptoTx); check!(CryptoWallet); + // Systems-modeling domain + #[cfg(feature = "arc-kit-au")] + { + check!(Requirement); + check!(Decision); + check!(Cost); + } } } diff --git a/crates/ledgerr-mcp/src/contract.rs b/crates/ledgerr-mcp/src/contract.rs index 0818bf1f..e77ebead 100644 --- a/crates/ledgerr-mcp/src/contract.rs +++ b/crates/ledgerr-mcp/src/contract.rs @@ -166,13 +166,16 @@ pub const PUBLISHED_TOOLS: [ToolContractSpec; 12] = [ }, ToolContractSpec { name: EVIDENCE_TOOL, - purpose: "evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries", + purpose: "evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries; requirement/decision/cost recording", actions: &[ "provenance_gaps", "trace_tx", "summary", "list_nodes", "node_detail", + "import_requirement", + "record_decision", + "record_cost", ], }, ToolContractSpec { @@ -915,6 +918,46 @@ pub enum EvidenceArgs { }, #[serde(rename = "node_detail")] NodeDetail { node_id: String }, + /// Import a requirement record (e.g. from `reqif-mcp-spike`'s + /// `RequirementRecord`) as a `NodeType::Requirement` evidence node. + #[serde(rename = "import_requirement")] + ImportRequirement { + requirement_id: String, + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + rationale: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + #[serde(default = "default_requirement_status")] + status: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + related_decisions: Vec, + }, + /// Record a decision as a `NodeType::Decision` evidence node. + #[serde(rename = "record_decision")] + RecordDecision { + decision_id: String, + subject: String, + rationale: String, + decided_by: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + related_requirements: Vec, + }, + /// Record a cost as a `NodeType::Cost` evidence node. + #[serde(rename = "record_cost")] + RecordCost { + cost_id: String, + subject: String, + amount: String, + currency: String, + recorded_by: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + related_decision: Option, + }, +} + +fn default_requirement_status() -> String { + "active".to_string() } pub fn parse_evidence(arguments: &Value) -> Result { diff --git a/crates/ledgerr-mcp/src/mcp_adapter.rs b/crates/ledgerr-mcp/src/mcp_adapter.rs index d0cfe04c..d57ece85 100644 --- a/crates/ledgerr-mcp/src/mcp_adapter.rs +++ b/crates/ledgerr-mcp/src/mcp_adapter.rs @@ -3223,6 +3223,11 @@ fn parse_evidence_node_type(s: &str) -> Option { "operator_approval" | "approval" => NodeType::OperatorApproval, "workbook_row" | "wb" => NodeType::WorkbookRow, "validation_issue" | "vi" => NodeType::ValidationIssue, + "rnd_activity" | "rnd" => NodeType::RndActivity, + "tax_offset" | "tax" => NodeType::TaxOffset, + "requirement" | "req" => NodeType::Requirement, + "decision" | "dec" => NodeType::Decision, + "cost" => NodeType::Cost, _ => return None, }) } @@ -3359,6 +3364,9 @@ pub fn handle_evidence_tool(service: &TurboLedgerService, arguments: &Value) -> "operator_approvals": counts.get("operator_approval").copied().unwrap_or(0), "workbook_rows": counts.get("workbook_row").copied().unwrap_or(0), "validation_issues": counts.get("validation_issue").copied().unwrap_or(0), + "requirements": counts.get("requirement").copied().unwrap_or(0), + "decisions": counts.get("decision").copied().unwrap_or(0), + "costs": counts.get("cost").copied().unwrap_or(0), }); let wq = evidence.work_queue_summary(); json!({ @@ -3396,7 +3404,8 @@ pub fn handle_evidence_tool(service: &TurboLedgerService, arguments: &Value) -> "error": format!( "Unknown node type: {nt}. Valid types: \ source_doc, extracted_row, transaction, classification, \ - model_proposal, operator_approval, workbook_row, validation_issue" + model_proposal, operator_approval, workbook_row, validation_issue, \ + rnd_activity, tax_offset, requirement, decision, cost" ), }))], "isError": true, @@ -3452,5 +3461,134 @@ pub fn handle_evidence_tool(service: &TurboLedgerService, arguments: &Value) -> }), } } + EvidenceArgs::ImportRequirement { + requirement_id, + title, + rationale, + source, + status, + related_decisions, + } => { + let mut evidence = match service.evidence.lock() { + Ok(e) => e, + Err(_) => { + return error_envelope(&ToolError::Internal( + "evidence mutex poisoned".to_string(), + )) + } + }; + let node = arc_kit_au::node::Requirement { + requirement_id, + title, + rationale, + source, + status, + related_decisions: related_decisions.into_iter().map(arc_kit_au::NodeId).collect(), + imported_at: chrono::Utc::now(), + }; + let node_id = node.node_id(); + match evidence.add_node(arc_kit_au::EvidenceNode::Requirement(node)) { + Ok(id) | Err(arc_kit_au::graph::GraphError::DuplicateNode(id)) => json!({ + "content": [text_content(json!({ + "action": "import_requirement", + "node_id": id.to_string(), + }))], + "isError": false + }), + Err(err) => json!({ + "content": [text_content(json!({ + "action": "import_requirement", + "node_id": node_id.to_string(), + "error": err.to_string(), + }))], + "isError": true, + }), + } + } + EvidenceArgs::RecordDecision { + decision_id, + subject, + rationale, + decided_by, + related_requirements, + } => { + let mut evidence = match service.evidence.lock() { + Ok(e) => e, + Err(_) => { + return error_envelope(&ToolError::Internal( + "evidence mutex poisoned".to_string(), + )) + } + }; + let node = arc_kit_au::node::Decision { + decision_id, + subject, + rationale, + decided_by, + decided_at: chrono::Utc::now(), + related_requirements: related_requirements + .into_iter() + .map(arc_kit_au::NodeId) + .collect(), + }; + match evidence.add_node(arc_kit_au::EvidenceNode::Decision(node)) { + Ok(id) | Err(arc_kit_au::graph::GraphError::DuplicateNode(id)) => json!({ + "content": [text_content(json!({ + "action": "record_decision", + "node_id": id.to_string(), + }))], + "isError": false + }), + Err(err) => json!({ + "content": [text_content(json!({ + "action": "record_decision", + "error": err.to_string(), + }))], + "isError": true, + }), + } + } + EvidenceArgs::RecordCost { + cost_id, + subject, + amount, + currency, + recorded_by, + related_decision, + } => { + let mut evidence = match service.evidence.lock() { + Ok(e) => e, + Err(_) => { + return error_envelope(&ToolError::Internal( + "evidence mutex poisoned".to_string(), + )) + } + }; + let node = arc_kit_au::node::Cost { + cost_id, + subject, + amount, + currency, + recorded_by, + recorded_at: chrono::Utc::now(), + related_decision: related_decision.map(arc_kit_au::NodeId), + }; + match evidence.add_node(arc_kit_au::EvidenceNode::Cost(node)) { + Ok(id) | Err(arc_kit_au::graph::GraphError::DuplicateNode(id)) => json!({ + "content": [text_content(json!({ + "action": "record_cost", + "node_id": id.to_string(), + }))], + "isError": false + }), + Err(err) => json!({ + "content": [text_content(json!({ + "action": "record_cost", + "error": err.to_string(), + }))], + "isError": true, + }), + } + } } } diff --git a/crates/ledgerr-mcp/tests/evidence_requirement_decision_cost.rs b/crates/ledgerr-mcp/tests/evidence_requirement_decision_cost.rs new file mode 100644 index 00000000..6fd8f00a --- /dev/null +++ b/crates/ledgerr-mcp/tests/evidence_requirement_decision_cost.rs @@ -0,0 +1,142 @@ +//! Task 6 (`docs/systems-modeling-registry-rescope.md` §6): wires +//! Requirement/Decision/Cost into the `ledgerr_evidence` MCP tool's +//! contract. Covers the new `import_requirement`/`record_decision`/ +//! `record_cost` actions, the `list_nodes` filter extension, and the +//! `summary` node-count extension. + +mod common; + +use ledgerr_mcp::mcp_adapter::handle_evidence_tool; +use ledgerr_mcp::TurboLedgerService; +use serde_json::json; + +fn service() -> TurboLedgerService { + let workbook_path = common::unique_workbook_path("evidence-rdc"); + TurboLedgerService::from_manifest_str(&common::manifest_for_workbook(&workbook_path, 2023)) + .expect("manifest") +} + +#[test] +fn import_requirement_then_list_nodes_and_node_detail() { + let svc = service(); + + let import = handle_evidence_tool( + &svc, + &json!({ + "action": "import_requirement", + "requirement_id": "PO-3-1", + "title": "toolchain risk mitigation", + "rationale": "Specify which tools mitigate identified risks.", + "source": "NIST SSDF 1.1", + "status": "active", + }), + ); + assert_eq!(import["isError"], json!(false)); + let node_id = import["content"][0]["text"] + .as_str() + .and_then(|t| serde_json::from_str::(t).ok()) + .expect("parsed content")["node_id"] + .as_str() + .expect("node_id") + .to_string(); + assert!(node_id.starts_with("req:"), "got {node_id}"); + + // Re-importing the identical requirement is idempotent (content-hash dedup), not an error. + let reimport = handle_evidence_tool( + &svc, + &json!({ + "action": "import_requirement", + "requirement_id": "PO-3-1", + "title": "toolchain risk mitigation", + "rationale": "Specify which tools mitigate identified risks.", + "source": "NIST SSDF 1.1", + "status": "active", + }), + ); + assert_eq!(reimport["isError"], json!(false)); + + let list = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "requirement" }), + ); + let text = list["content"][0]["text"].as_str().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed["count"], json!(1), "re-import must not duplicate the node"); + + let detail = handle_evidence_tool( + &svc, + &json!({ "action": "node_detail", "node_id": node_id }), + ); + assert_eq!(detail["isError"], json!(false)); + + let summary = handle_evidence_tool(&svc, &json!({ "action": "summary" })); + let summary_text = summary["content"][0]["text"].as_str().unwrap(); + let summary_parsed: serde_json::Value = serde_json::from_str(summary_text).unwrap(); + assert_eq!(summary_parsed["node_counts"]["requirements"], json!(1)); +} + +#[test] +fn record_decision_and_record_cost_are_content_hashed_and_queryable() { + let svc = service(); + + let decision = handle_evidence_tool( + &svc, + &json!({ + "action": "record_decision", + "decision_id": "DEC-001", + "subject": "Adopt sysml-derive over LinkML", + "rationale": "Rust-first fit, no correctness surprise", + "decided_by": "brianh", + }), + ); + assert_eq!(decision["isError"], json!(false)); + + let cost = handle_evidence_tool( + &svc, + &json!({ + "action": "record_cost", + "cost_id": "COST-001", + "subject": "GPU training run", + "amount": "42.50", + "currency": "USD", + "recorded_by": "brianh", + }), + ); + assert_eq!(cost["isError"], json!(false)); + + let list_dec = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "decision" }), + ); + let dec_text = list_dec["content"][0]["text"].as_str().unwrap(); + let dec_parsed: serde_json::Value = serde_json::from_str(dec_text).unwrap(); + assert_eq!(dec_parsed["count"], json!(1)); + + let list_cost = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "cost" }), + ); + let cost_text = list_cost["content"][0]["text"].as_str().unwrap(); + let cost_parsed: serde_json::Value = serde_json::from_str(cost_text).unwrap(); + assert_eq!(cost_parsed["count"], json!(1)); + + let summary = handle_evidence_tool(&svc, &json!({ "action": "summary" })); + let summary_text = summary["content"][0]["text"].as_str().unwrap(); + let summary_parsed: serde_json::Value = serde_json::from_str(summary_text).unwrap(); + assert_eq!(summary_parsed["node_counts"]["decisions"], json!(1)); + assert_eq!(summary_parsed["node_counts"]["costs"], json!(1)); +} + +#[test] +fn list_nodes_rejects_unknown_type_with_full_valid_list() { + let svc = service(); + let result = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "bogus" }), + ); + assert_eq!(result["isError"], json!(true)); + let text = result["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("requirement")); + assert!(text.contains("decision")); + assert!(text.contains("cost")); +} diff --git a/crates/ledgerr-mcp/tests/pipeline_e2e.rs b/crates/ledgerr-mcp/tests/pipeline_e2e.rs index a7aa0173..333f92d0 100644 --- a/crates/ledgerr-mcp/tests/pipeline_e2e.rs +++ b/crates/ledgerr-mcp/tests/pipeline_e2e.rs @@ -180,7 +180,7 @@ fn pipe_viz_manifest_entry_count_matches_registered_types() { // `impl HasVisualization` blocks in `ledger_core::iso_objects`. It must be // regenerated (and this count updated) whenever an impl is added, removed, // or registered/deregistered in xtask/src/viz_manifest.rs. - const EXPECTED_ENTRY_COUNT: usize = 28; + const EXPECTED_ENTRY_COUNT: usize = 31; let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -218,6 +218,9 @@ fn pipe_viz_manifest_entry_count_matches_registered_types() { "AuRdActivity", "UsRdcCredit", "CryptoWallet", + "Requirement", + "Decision", + "Cost", ] { assert!(names.contains(&expected), "missing {expected} entry"); } diff --git a/crates/reqif-mcp-spike/Cargo.toml b/crates/reqif-mcp-spike/Cargo.toml new file mode 100644 index 00000000..a7956309 --- /dev/null +++ b/crates/reqif-mcp-spike/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "reqif-mcp-spike" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Spike: a throwaway Rust client for PromptExecution/reqif-opa-mcp's MCP Streamable-HTTP tools, plus a converter into arc-kit-au::Requirement. See docs/systems-modeling-registry-rescope.md (epic part 2, §6 task 5)." + +[dependencies] +arc-kit-au = { path = "../arc-kit-au" } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] diff --git a/crates/reqif-mcp-spike/src/lib.rs b/crates/reqif-mcp-spike/src/lib.rs new file mode 100644 index 00000000..0417f672 --- /dev/null +++ b/crates/reqif-mcp-spike/src/lib.rs @@ -0,0 +1,329 @@ +//! Spike: a minimal Rust client for `PromptExecution/reqif-opa-mcp`'s MCP +//! Streamable-HTTP server, plus a converter from its `RequirementRecord` +//! shape into `arc_kit_au::Requirement`. +//! +//! Decision 6 (`docs/systems-modeling-registry-rescope.md` §5): reqif-opa-mcp +//! is wrapped over MCP, not ported to Rust — `arc-kit-au` stays the canonical +//! decision+cost ledger, this crate only supplies parsed requirements into it. +//! +//! Protocol notes (reverse-engineered against a live server, FastMCP 3.0.0b1, +//! protocol version 2024-11-05, 2026-08-22): +//! - Every request is `POST {base_url}/mcp` with +//! `Accept: application/json, text/event-stream`. +//! - The response is a single SSE frame (`event: message\ndata: {json}\n\n`), +//! not a long-lived stream — read the whole body and extract the `data:` +//! line. +//! - `initialize` returns an `mcp-session-id` response header that must be +//! echoed on every subsequent request. +//! - A `notifications/initialized` notification (no `id`, no response body) +//! must be sent once after `initialize` before any `tools/*` call. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use arc_kit_au::node::Requirement; + +#[derive(Debug, thiserror::Error)] +pub enum McpClientError { + #[error("HTTP transport error: {0}")] + Transport(#[from] reqwest::Error), + #[error("server response had no SSE `data:` line: {0}")] + NoDataLine(String), + #[error("malformed JSON-RPC response: {0}")] + MalformedJson(#[from] serde_json::Error), + #[error("server did not return an mcp-session-id header on initialize")] + NoSessionId, + #[error("JSON-RPC error response: {0}")] + RpcError(String), + #[error("tool call reported isError=true: {0}")] + ToolError(String), +} + +/// Blocking client for one MCP Streamable-HTTP server instance. +pub struct McpHttpClient { + http: reqwest::blocking::Client, + base_url: String, + session_id: Option, + next_id: u64, +} + +impl McpHttpClient { + pub fn new(base_url: impl Into) -> Self { + Self { + http: reqwest::blocking::Client::new(), + base_url: base_url.into(), + session_id: None, + next_id: 1, + } + } + + fn alloc_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id += 1; + id + } + + /// Perform the MCP `initialize` handshake and send the mandatory + /// `notifications/initialized` follow-up. + pub fn initialize(&mut self, client_name: &str, client_version: &str) -> Result { + let id = self.alloc_id(); + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": client_name, "version": client_version } + } + }); + + let resp = self + .http + .post(format!("{}/mcp", self.base_url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .json(&body) + .send()?; + + let session_id = resp + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .ok_or(McpClientError::NoSessionId)?; + self.session_id = Some(session_id); + + let text = resp.text()?; + let result = extract_rpc_result(&text)?; + + // Mandatory notification, no response body expected. + let notif = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + self.http + .post(format!("{}/mcp", self.base_url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", self.session_id.as_ref().unwrap()) + .json(¬if) + .send()?; + + Ok(result) + } + + /// Call one MCP tool by name, returning its parsed `structuredContent` + /// (falling back to parsing the first text content block as JSON). + pub fn call_tool(&mut self, name: &str, arguments: Value) -> Result { + let id = self.alloc_id(); + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }); + + let mut req = self + .http + .post(format!("{}/mcp", self.base_url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .json(&body); + if let Some(sid) = &self.session_id { + req = req.header("mcp-session-id", sid); + } + + let text = req.send()?.text()?; + let result = extract_rpc_result(&text)?; + + if result.get("isError").and_then(Value::as_bool) == Some(true) { + return Err(McpClientError::ToolError(result.to_string())); + } + + if let Some(structured) = result.get("structuredContent") { + return Ok(structured.clone()); + } + // Fall back to the first text content block (export_req_set returns + // its payload as a JSON string inside `content`, not `structuredContent`). + if let Some(text_block) = result + .get("content") + .and_then(Value::as_array) + .and_then(|blocks| blocks.first()) + .and_then(|b| b.get("text")) + .and_then(Value::as_str) + { + return Ok(serde_json::from_str(text_block)?); + } + Ok(result) + } +} + +/// Extract the JSON-RPC `result` object from a single SSE frame response body. +fn extract_rpc_result(sse_body: &str) -> Result { + let data_line = sse_body + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .ok_or_else(|| McpClientError::NoDataLine(sse_body.to_string()))?; + + let envelope: Value = serde_json::from_str(data_line)?; + if let Some(err) = envelope.get("error") { + return Err(McpClientError::RpcError(err.to_string())); + } + envelope + .get("result") + .cloned() + .ok_or_else(|| McpClientError::NoDataLine(sse_body.to_string())) +} + +/// Mirrors `schemas/requirement-record.schema.json` in reqif-opa-mcp. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequirementRecord { + pub uid: String, + pub key: String, + pub subtypes: Vec, + pub status: String, + pub policy_baseline: PolicyBaselineRef, + #[serde(default)] + pub rubrics: Vec, + pub text: String, + #[serde(default)] + pub attrs: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyBaselineRef { + pub id: String, + pub version: String, + #[serde(default)] + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rubric { + pub engine: String, + pub bundle: String, + pub package: String, + pub rule: String, +} + +/// Convert one reqif-opa-mcp `RequirementRecord` into an `arc-kit-au` +/// `Requirement` node (the `ArtifactKind::Requirement` / `NodeType::Requirement` +/// widening from ledgrrr#184). +/// +/// Field mapping (no direct 1:1 — reqif-opa-mcp's schema predates and is +/// independent of arc-kit-au's node model): +/// - `requirement_id` <- `uid` (globally unique across baselines; `key` is +/// only unique within one standard, e.g. "PO-3-1") +/// - `title` <- `key` (the human-readable requirement key) +/// - `rationale` <- `text` (the full requirement statement doubles as its +/// rationale — reqif-opa-mcp has no separate rationale field) +/// - `source` <- `attrs.source_standard` + `attrs.source_url` if present, +/// else falls back to the policy baseline id +/// - `status` <- `status` (already one of active/obsolete/draft, a superset- +/// compatible vocabulary with arc-kit-au's free-form `String`) +/// - `related_decisions` <- always empty; reqif-opa-mcp carries no decision +/// links, those are created later in arc-kit-au itself +/// - `imported_at` <- caller-supplied (usually `Utc::now()` at conversion time) +pub fn requirement_record_to_node(rec: &RequirementRecord, imported_at: DateTime) -> Requirement { + let source = match (rec.attrs.get("source_standard"), rec.attrs.get("source_url")) { + (Some(std), Some(url)) => Some(format!( + "{} ({})", + std.as_str().unwrap_or_default(), + url.as_str().unwrap_or_default() + )), + (Some(std), None) => Some(std.as_str().unwrap_or_default().to_string()), + _ => Some(rec.policy_baseline.id.clone()), + }; + + Requirement { + requirement_id: rec.uid.clone(), + title: rec.key.clone(), + rationale: Some(rec.text.clone()), + source, + status: rec.status.clone(), + related_decisions: Vec::new(), + imported_at, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record() -> RequirementRecord { + let mut attrs = HashMap::new(); + attrs.insert("severity".to_string(), json!("high")); + attrs.insert("source_standard".to_string(), json!("NIST SSDF 1.1 (SP 800-218)")); + attrs.insert("source_url".to_string(), json!("https://doi.org/10.6028/NIST.SP.800-218")); + + RequirementRecord { + uid: "REQ-NIST-SSDF-002".to_string(), + key: "PW-7-2".to_string(), + subtypes: vec!["SECURE_SDLC".to_string()], + status: "active".to_string(), + policy_baseline: PolicyBaselineRef { + id: "nist-ssdf".to_string(), + version: "2026.01".to_string(), + hash: "e86ec0846e64074e".to_string(), + }, + rubrics: vec![Rubric { + engine: "opa".to_string(), + bundle: "org/compliance".to_string(), + package: "compliance.secure.sdlc".to_string(), + rule: "decision".to_string(), + }], + text: "Perform the code review and/or code analysis...".to_string(), + attrs, + } + } + + #[test] + fn converts_requirement_record_field_shape() { + let rec = sample_record(); + let now = Utc::now(); + let node = requirement_record_to_node(&rec, now); + + assert_eq!(node.requirement_id, "REQ-NIST-SSDF-002"); + assert_eq!(node.title, "PW-7-2"); + assert_eq!(node.rationale.as_deref(), Some(rec.text.as_str())); + assert_eq!( + node.source.as_deref(), + Some("NIST SSDF 1.1 (SP 800-218) (https://doi.org/10.6028/NIST.SP.800-218)") + ); + assert_eq!(node.status, "active"); + assert!(node.related_decisions.is_empty()); + assert_eq!(node.imported_at, now); + } + + #[test] + fn falls_back_to_policy_baseline_id_when_no_source_attrs() { + let mut rec = sample_record(); + rec.attrs.clear(); + let node = requirement_record_to_node(&rec, Utc::now()); + assert_eq!(node.source.as_deref(), Some("nist-ssdf")); + } + + #[test] + fn node_id_is_deterministic_for_converted_requirement() { + let rec = sample_record(); + let now = Utc::now(); + let a = requirement_record_to_node(&rec, now); + let b = requirement_record_to_node(&rec, now); + assert_eq!(a.node_id(), b.node_id()); + } + + #[test] + fn extract_rpc_result_parses_single_sse_frame() { + let sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; + let result = extract_rpc_result(sse).unwrap(); + assert_eq!(result["ok"], json!(true)); + } + + #[test] + fn extract_rpc_result_surfaces_json_rpc_errors() { + let sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"message\":\"boom\"}}\n\n"; + let err = extract_rpc_result(sse).unwrap_err(); + assert!(matches!(err, McpClientError::RpcError(_))); + } +} diff --git a/crates/reqif-mcp-spike/tests/live_server.rs b/crates/reqif-mcp-spike/tests/live_server.rs new file mode 100644 index 00000000..a6441d39 --- /dev/null +++ b/crates/reqif-mcp-spike/tests/live_server.rs @@ -0,0 +1,102 @@ +//! End-to-end proof against a real, running `reqif-opa-mcp` server. +//! +//! `#[ignore]`d by default: it requires a checkout of +//! `PromptExecution/reqif-opa-mcp` with its Python deps installed +//! (`uv sync --extra ingest-lite`) and a server already started via +//! `just serve ` (or `uv run python -m reqif_mcp --http --port `), +//! neither of which CI or a fresh clone of this repo has. Run manually: +//! +//! ```sh +//! REQIF_MCP_URL=http://localhost:8123 cargo test -p reqif-mcp-spike \ +//! --test live_server -- --ignored --nocapture +//! ``` +//! +//! Verified working end-to-end 2026-08-22 against `nist_ssdf_dogfood.reqif` +//! and `owasp_asvs_cwe.reqif` from reqif-opa-mcp's own +//! `samples/standards/derived/`. + +use std::{env, fs}; + +use chrono::Utc; +use reqif_mcp_spike::{requirement_record_to_node, McpHttpClient, RequirementRecord}; +use serde_json::json; + +#[test] +#[ignore = "requires a live reqif-opa-mcp server; see module docs"] +fn parses_and_converts_nist_ssdf_sample() { + let base_url = env::var("REQIF_MCP_URL").expect("set REQIF_MCP_URL to a running reqif-opa-mcp server"); + let reqif_path = env::var("REQIF_SAMPLE_PATH") + .unwrap_or_else(|_| "samples/standards/derived/nist_ssdf_dogfood.reqif".to_string()); + + let xml = fs::read_to_string(&reqif_path) + .unwrap_or_else(|e| panic!("could not read {reqif_path}: {e}")); + let xml_b64 = base64_encode(xml.as_bytes()); + + let mut client = McpHttpClient::new(base_url); + client + .initialize("reqif-mcp-spike", env!("CARGO_PKG_VERSION")) + .expect("initialize handshake"); + + let parsed = client + .call_tool( + "reqif_parse", + json!({ + "xml_b64": xml_b64, + "policy_baseline_id": "nist-ssdf", + "policy_baseline_version": "2026.01", + }), + ) + .expect("reqif_parse"); + let handle = parsed["handle"].as_str().expect("handle field").to_string(); + let requirement_count = parsed["requirement_count"].as_u64().expect("requirement_count"); + assert!(requirement_count > 0, "expected at least one requirement"); + + let queried = client + .call_tool("reqif_query", json!({ "handle": handle })) + .expect("reqif_query"); + let requirements: Vec = + serde_json::from_value(queried["requirements"].clone()).expect("deserialize requirement records"); + assert_eq!(requirements.len() as u64, requirement_count); + + let now = Utc::now(); + let nodes: Vec<_> = requirements + .iter() + .map(|rec| requirement_record_to_node(rec, now)) + .collect(); + + for (rec, node) in requirements.iter().zip(nodes.iter()) { + assert_eq!(node.requirement_id, rec.uid); + assert_eq!(node.title, rec.key); + assert_eq!(node.status, rec.status); + println!( + "{} -> ArtifactKind::Requirement node_id={:?}", + rec.key, + node.node_id() + ); + } +} + +/// Tiny inline base64 encoder so this test doesn't need a new dependency +/// just for a one-shot request payload. +fn base64_encode(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied(); + let b2 = chunk.get(2).copied(); + out.push(ALPHABET[(b0 >> 2) as usize] as char); + out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char); + if let Some(b1) = b1 { + out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char); + } else { + out.push('='); + } + if let Some(b2) = b2 { + out.push(ALPHABET[(b2 & 0x3f) as usize] as char); + } else { + out.push('='); + } + } + out +} diff --git a/docs/mcp-capability-contract.md b/docs/mcp-capability-contract.md index 7e3218e4..dddcfe15 100644 --- a/docs/mcp-capability-contract.md +++ b/docs/mcp-capability-contract.md @@ -19,7 +19,7 @@ The default catalog is intentionally small: 12 top-level `ledgerr_*` tools. Each | `ledgerr_ontology` | ontology query/export/write operations | `query_path`, `export_snapshot`, `upsert_entities`, `upsert_edges` | | `ledgerr_xero` | Xero accounting integration: contacts, accounts, bank accounts, entity linking | `get_auth_url`, `exchange_code`, `fetch_contacts`, `search_contacts`, `fetch_accounts`, `fetch_bank_accounts`, `fetch_invoices`, `link_entity`, `sync_catalog` | | `ledgerr_focus` | FOCUS (FinOps Cost Usage Spec) v1.3 cost/usage records, FocusDelta comparison, experiment scoring | `append_focus_record`, `query_focus_summary`, `compute_focus_delta`, `experiment_score` | -| `ledgerr_evidence` | evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries | `provenance_gaps`, `trace_tx`, `summary`, `list_nodes`, `node_detail` | +| `ledgerr_evidence` | evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries; requirement/decision/cost recording | `provenance_gaps`, `trace_tx`, `summary`, `list_nodes`, `node_detail`, `import_requirement`, `record_decision`, `record_cost` | | `ledgerr_schema` | runtime schema extensibility: register, list, remove, and inspect custom entity kinds | `list_kinds`, `register_kind`, `remove_kind`, `get_kind` | | `ledgerr_manifest` | returns the full canonical viz-manifest: mapping of type IDs to their canonical Rhai DSL source strings | `get_manifest` | diff --git a/ui/docs/public/viz-manifest.json b/ui/docs/public/viz-manifest.json index 5bdf8ad9..212ac67a 100644 --- a/ui/docs/public/viz-manifest.json +++ b/ui/docs/public/viz-manifest.json @@ -3188,6 +3188,293 @@ }, "description": "Crypto wallet — tracks asset lots, cost basis methods, and balance history for tax event attribution" } + }, + { + "type_name": "Requirement", + "spec": { + "semantic_type": "requirement", + "z_layer": "SystemsModel", + "rhai_dsl": { + "source": "let req = load_requirement(source);\nif req.status == \"active\" { link_decisions(req.related_decisions) }", + "symbols": [ + { + "kind": "Keyword", + "name": "let", + "span": { + "line": 1, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "req", + "span": { + "line": 1, + "col": 5 + } + }, + { + "kind": "FunctionCall", + "name": "load_requirement", + "span": { + "line": 1, + "col": 11 + } + }, + { + "kind": "Variable", + "name": "source", + "span": { + "line": 1, + "col": 28 + } + }, + { + "kind": "Keyword", + "name": "if", + "span": { + "line": 2, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "req", + "span": { + "line": 2, + "col": 4 + } + }, + { + "kind": "Variable", + "name": "status", + "span": { + "line": 2, + "col": 8 + } + }, + { + "kind": "FunctionCall", + "name": "link_decisions", + "span": { + "line": 2, + "col": 29 + } + }, + { + "kind": "Variable", + "name": "req", + "span": { + "line": 2, + "col": 44 + } + }, + { + "kind": "Variable", + "name": "related_decisions", + "span": { + "line": 2, + "col": 48 + } + } + ] + }, + "description": "SysML v2 systems-modeling requirement — traceable spec record, linked to the decisions it constrains" + } + }, + { + "type_name": "Decision", + "spec": { + "semantic_type": "decision", + "z_layer": "SystemsModel", + "rhai_dsl": { + "source": "let decision = record_decision(subject, rationale, decided_by);\nlink_requirements(decision.related_requirements);", + "symbols": [ + { + "kind": "Keyword", + "name": "let", + "span": { + "line": 1, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "decision", + "span": { + "line": 1, + "col": 5 + } + }, + { + "kind": "FunctionCall", + "name": "record_decision", + "span": { + "line": 1, + "col": 16 + } + }, + { + "kind": "Variable", + "name": "subject", + "span": { + "line": 1, + "col": 32 + } + }, + { + "kind": "Variable", + "name": "rationale", + "span": { + "line": 1, + "col": 41 + } + }, + { + "kind": "Variable", + "name": "decided_by", + "span": { + "line": 1, + "col": 52 + } + }, + { + "kind": "FunctionCall", + "name": "link_requirements", + "span": { + "line": 2, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "decision", + "span": { + "line": 2, + "col": 19 + } + }, + { + "kind": "Variable", + "name": "related_requirements", + "span": { + "line": 2, + "col": 28 + } + } + ] + }, + "description": "Version-controlled decision record — rationale + decider, linked back to the requirements it satisfies" + } + }, + { + "type_name": "Cost", + "spec": { + "semantic_type": "cost", + "z_layer": "SystemsModel", + "rhai_dsl": { + "source": "let cost = record_cost(subject, amount, currency);\nif cost.related_decision.is_some() { attribute_to_decision(cost) }", + "symbols": [ + { + "kind": "Keyword", + "name": "let", + "span": { + "line": 1, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "cost", + "span": { + "line": 1, + "col": 5 + } + }, + { + "kind": "FunctionCall", + "name": "record_cost", + "span": { + "line": 1, + "col": 12 + } + }, + { + "kind": "Variable", + "name": "subject", + "span": { + "line": 1, + "col": 24 + } + }, + { + "kind": "Variable", + "name": "amount", + "span": { + "line": 1, + "col": 33 + } + }, + { + "kind": "Variable", + "name": "currency", + "span": { + "line": 1, + "col": 41 + } + }, + { + "kind": "Keyword", + "name": "if", + "span": { + "line": 2, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "cost", + "span": { + "line": 2, + "col": 4 + } + }, + { + "kind": "Variable", + "name": "related_decision", + "span": { + "line": 2, + "col": 9 + } + }, + { + "kind": "FunctionCall", + "name": "is_some", + "span": { + "line": 2, + "col": 26 + } + }, + { + "kind": "FunctionCall", + "name": "attribute_to_decision", + "span": { + "line": 2, + "col": 38 + } + }, + { + "kind": "Variable", + "name": "cost", + "span": { + "line": 2, + "col": 60 + } + } + ] + }, + "description": "Version-controlled cost record — monetary amount attributed to a decision for systems-modeling cost traceability" + } } ] } \ No newline at end of file diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 500f1587..5b4960a1 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -15,6 +15,7 @@ name = "xtask_mcpb" path = "src/lib.rs" [dependencies] +arc-kit-au = { path = "../crates/arc-kit-au" } clap = { version = "4", features = ["derive"] } ledger-core = { path = "../crates/ledger-core" } hex = "0.4" diff --git a/xtask/src/viz_manifest.rs b/xtask/src/viz_manifest.rs index 43e27519..aa0750c3 100644 --- a/xtask/src/viz_manifest.rs +++ b/xtask/src/viz_manifest.rs @@ -1,12 +1,13 @@ //! Export the VisualizationSpec JSON manifest for the docs UI. //! -//! Collects `HasVisualization::viz_spec()` from all 28 domain types (every +//! Collects `HasVisualization::viz_spec()` from all 31 domain types (every //! `impl HasVisualization` in `ledger_core::iso_objects`, with the generic //! `StageResult` represented once via `StageResult<()>`) and writes a //! `VizManifest` JSON file to the specified output path. use std::path::Path; +use arc_kit_au::node::{Cost, Decision, Requirement}; use ledger_core::{ au_rd::{AuRdActivity, AuRdOffset}, constraints::{ @@ -74,6 +75,9 @@ pub fn export_viz_manifest(output: &Path) -> Result<(), Box