From 524ea0707936df61ff03bf06a269a97f86459249 Mon Sep 17 00:00:00 2001 From: Brian H Date: Fri, 2 Oct 2026 09:20:29 +0000 Subject: [PATCH 1/3] feat(agent): load_mbse_skill tool; pi sub-agent delegation pattern (scripts/delegate-qwen38.sh) The planner and drawing agent can load the MBSE skills (confidence label preserved). Errands are delegated to the local Qwen3.8 via pi: read-only by default, Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- AGENTS.md | 7 +++ containers/kr0ki-storyb00k-agent/server.py | 54 ++++++++++++++++- .../kr0ki-storyb00k-agent/test_mbse_skills.py | 58 +++++++++++++++++++ .../kr0ki-storyb00k-agent/test_planner.py | 30 +++++++++- docs/PATTERN-pi-subagents.md | 47 +++++++++++++++ justfile | 4 ++ scripts/delegate-qwen38.sh | 56 ++++++++++++++++++ 7 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 containers/kr0ki-storyb00k-agent/test_mbse_skills.py create mode 100644 docs/PATTERN-pi-subagents.md create mode 100755 scripts/delegate-qwen38.sh diff --git a/AGENTS.md b/AGENTS.md index a147cbd..ee2adf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,13 @@ kr0ki exposes a service surface; agent interaction should go through b00t conven Long-term: a `kr0ki.mcp.toml` datum or vendored `kroki-mcp` server for MCP-native render calls. P0 uses direct HTTP. +## 8b. Delegation: always use Qwen3.8 via pi + +Errands (reviews, drafts, test scaffolding, summaries, fact checks) are **always delegated** to the local Qwen3.8 +NEO-CODER through pi: `scripts/delegate-qwen38.sh` / `just delegate`. Read-only by default; `--tools` only in a throwaway +worktree. The output is a first-pass lead: triage it, verify against source, run the tests. Never delegate the +verification itself. Full pattern, exit codes and gotchas (`< /dev/null`, `127.0.0.1`): `docs/PATTERN-pi-subagents.md`. + ## 8a. Agent communications and infrastructure squawks Agent-to-agent work is migrating to A2A semantics over NATS JetStream. NATS is the diff --git a/containers/kr0ki-storyb00k-agent/server.py b/containers/kr0ki-storyb00k-agent/server.py index 6758355..428ed2a 100644 --- a/containers/kr0ki-storyb00k-agent/server.py +++ b/containers/kr0ki-storyb00k-agent/server.py @@ -103,7 +103,7 @@ # Planner mode (gallery side panel): a thread whose id starts with this prefix is a *planning* conversation. # The playbook uses the same id as the UI session id, so a tool call can steer exactly that browser tab. PLANNER_THREAD_PREFIX = "planner-" -PLANNER_TOOLS = {"list_diagram_types", "suggest_diagram_type", "navigate_ui", "ask_user"} +PLANNER_TOOLS = {"list_diagram_types", "suggest_diagram_type", "navigate_ui", "ask_user", "load_mbse_skill"} PLANNER_PREAMBLE = ( "You are the diagram planner inside kr0ki's gallery. The user is browsing a catalog of diagram types and " @@ -124,6 +124,9 @@ "the user they can press Edit on a pick to start drawing, Agent to work on it with the drawing assistant, or " "Show in gallery to see it among the others. If navigate_ui reports that no UI is connected, say so and " "describe the pick in words.\n\n" + "If the user is really asking about requirements, traceability, SysML v2 modelling, cost attribution or " + "compliance rather than a diagram, call load_mbse_skill first and follow it; skills labelled 'recalled' are " + "guidance, not verified standards text, so say so.\n\n" "Only use type ids and use cases returned by the tools. Keep answers short." ) @@ -199,6 +202,41 @@ def list_mbse_skills(): return out +def mbse_skill(name): + """Body of skills/mbse/.md for a model: frontmatter stripped, but its confidence label kept on top so the model + cannot present recalled standards content as verified.""" + text = mbse_skill_file(name) + if text is None: + return None + confidence = "" + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) == 3: + confidence = next((l[len("confidence:"):].strip() for l in parts[1].splitlines() if l.startswith("confidence:")), "") + text = parts[2] + head = f"[confidence: {confidence}]\n\n" if confidence else "" + return (head + text.strip())[:MAX_SKILL_CHARS] + + +load_mbse_skill_tool = { + "type": "function", + "function": { + "name": "load_mbse_skill", + "description": ( + "Load a model-based systems engineering skill BEFORE advising on requirements, V-model traceability, " + "SysML v2 modelling, cost attribution codes or AI-governance compliance tags. Names: " + "v-model-traceability, requirement-writing, sysml-v2-modeling, cost-attribution-and-compliance. " + "Skills marked 'recalled' are guidance from memory, not verified standards text: say so when you rely on one." + ), + "parameters": { + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string", "description": "The skill name."}}, + }, + }, +} + + class SkillRequiredError(Exception): """A render named a language whose syntax guide has not yet been delivered this run: deliver it first. This is the deterministic gate: unlike a model-judged skill load, it cannot be skipped.""" @@ -836,6 +874,7 @@ def _run(self, stream, payload, thread_id, draft, run_log): manifest = fetch_manifest(KR0KI_URL) tools = [{"type": "function", "function": {"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]}} for tool in manifest] tools.append(local_draft_tool()) + tools.append(load_mbse_skill_tool) project = project_store.get_project(thread_id) or {} questions_asked = int(project.get("questionsAsked", len(project.get("qa", [])))) questions_remaining = max(0, MAX_CLARIFYING_QUESTIONS - questions_asked) @@ -1202,6 +1241,19 @@ def _run(self, stream, payload, thread_id, draft, run_log): }], })) return + if name == "load_mbse_skill" and name in offered_tool_names: + skill_name = str(arguments.get("name") or "") + body = mbse_skill(skill_name) + if body is None: + known = ", ".join(sorted(s["name"] for s in list_mbse_skills())) + body = f"unknown MBSE skill {skill_name!r}; available: {known}" + stream.try_write({"type": "TOOL_CALL_START", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id, "toolCallName": name, "parentMessageId": parent_message_id}) + stream.try_write({"type": "TOOL_CALL_ARGS", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id, "delta": call["function"].get("arguments") or "{}"}) + stream.try_write({"type": "TOOL_CALL_END", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id}) + stream.try_write({"type": "TOOL_CALL_RESULT", "threadId": thread_id, "runId": stream.run_id, "messageId": parent_message_id, "toolCallId": tool_call_id, "content": body, "role": "tool"}) + messages.append({"role": "tool", "tool_call_id": tool_call_id, "content": body}) + run_log.event("skill.mbse", {"name": skill_name, "found": not body.startswith("unknown MBSE skill")}) + continue tool = find_tool(manifest, name) # Only tools this thread was offered may run: a model can name a tool it was never given. if tool is not None and name not in offered_tool_names: diff --git a/containers/kr0ki-storyb00k-agent/test_mbse_skills.py b/containers/kr0ki-storyb00k-agent/test_mbse_skills.py new file mode 100644 index 0000000..06564e8 --- /dev/null +++ b/containers/kr0ki-storyb00k-agent/test_mbse_skills.py @@ -0,0 +1,58 @@ +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import server + + +class MbseSkillTest(unittest.TestCase): + """Drafted by Qwen3.8 via pi (docs/PATTERN-pi-subagents.md); imports, naming and the mutation check are ours.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.patcher = patch.object(server, "MBSE_SKILLS_DIR", self.tmp) + self.patcher.start() + + def tearDown(self): + self.patcher.stop() + shutil.rmtree(self.tmp) + + def test_confidence_included(self): + skill = self.tmp / "reqwriting.md" + skill.write_text("---\nname: reqwriting\nconfidence: recalled (2)\n---\nAsk clarifying questions.") + result = server.mbse_skill("reqwriting") + self.assertTrue(result.startswith("[confidence: recalled (2)]")) + self.assertNotIn("---", result) + self.assertNotIn("name:", result) + + def test_no_frontmatter(self): + skill = self.tmp / "plain.md" + skill.write_text(" Just plain advice. ") + result = server.mbse_skill("plain") + self.assertEqual(result, "Just plain advice.") + self.assertFalse(result.startswith("[confidence:")) + + def test_unknown_returns_none(self): + self.assertIsNone(server.mbse_skill("does-not-exist")) + + def test_path_traversal_rejected(self): + # a real file just outside the skills directory: without name validation "../outside" would read it + outside = self.tmp.parent / "kr0ki-outside-skill.md" + outside.write_text("secret") + self.addCleanup(outside.unlink) + (self.tmp / "sub").mkdir() + (self.tmp / "sub" / "inner.md").write_text("nested") + for name in ["../kr0ki-outside-skill", "sub/inner", "..", ""]: + self.assertIsNone(server.mbse_skill(name), name) + + def test_max_chars_cap(self): + skill = self.tmp / "long.md" + skill.write_text("x" * (server.MAX_SKILL_CHARS + 100)) + result = server.mbse_skill("long") + self.assertEqual(len(result), server.MAX_SKILL_CHARS) + + +if __name__ == "__main__": + unittest.main() diff --git a/containers/kr0ki-storyb00k-agent/test_planner.py b/containers/kr0ki-storyb00k-agent/test_planner.py index 17c742e..3482092 100644 --- a/containers/kr0ki-storyb00k-agent/test_planner.py +++ b/containers/kr0ki-storyb00k-agent/test_planner.py @@ -115,7 +115,7 @@ def test_planner_runs_use_the_planner_prompt_and_tool_set(self): self.run_thread("planner-mine", {"view": "gallery"}) messages, tools = self.client.chat_completion.call_args_list[0].args self.assertTrue(messages[0]["content"].startswith(server.PLANNER_PREAMBLE)) - self.assertEqual({t["function"]["name"] for t in tools} - {"ask_user"}, {"list_diagram_types", "suggest_diagram_type", "navigate_ui"}) + self.assertEqual({t["function"]["name"] for t in tools} - {"ask_user"}, {"list_diagram_types", "suggest_diagram_type", "navigate_ui", "load_mbse_skill"}) def test_a_regular_thread_cannot_call_navigate_ui(self): self.run_thread("regular-1", {"view": "gallery"}) @@ -329,3 +329,31 @@ def test_no_image_is_sent_to_a_text_only_model_and_the_model_is_told_why(self): def test_a_vision_model_still_gets_the_image(self): images, _ = self.run_png_render(vision=True) self.assertEqual(len(images), 1) + + +class LoadMbseSkillRunTest(PlannerRunTest): + def run_load(self, thread_id, name): + call = {"id": "m1", "function": {"name": "load_mbse_skill", "arguments": json.dumps({"name": name})}} + self.client.chat_completion.side_effect = [ + {"model": "m", "choices": [{"message": {"content": "", "tool_calls": [call]}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}, + {"model": "m", "choices": [{"message": {"content": "Done."}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}, + ] + body = json.dumps({"threadId": thread_id, "runId": "rm", "messages": [{"role": "user", "content": "are my requirements traced?"}]}).encode() + req = urllib.request.Request(f"http://127.0.0.1:{self.port}/run", data=body, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + stream = resp.read().decode() + sent = self.client.chat_completion.call_args_list[1].args[0] + return stream, [m for m in sent if m.get("role") == "tool"] + + def test_the_planner_can_load_an_mbse_skill_and_the_model_is_told_it_is_recalled_not_verified(self): + stream, tool_msgs = self.run_load("planner-mbse", "v-model-traceability") + self.assertEqual(len(tool_msgs), 1) + self.assertTrue(tool_msgs[0]["content"].startswith("[confidence: recalled"), tool_msgs[0]["content"][:80]) + self.assertIn("The V, as questions to ask of the model", tool_msgs[0]["content"]) + self.assertIn("TOOL_CALL_RESULT", stream) + self.assertFalse(self.calls, "a local skill load must not call the kr0ki server") + + def test_a_regular_drawing_thread_can_load_it_too_and_an_unknown_name_lists_what_exists(self): + _, tool_msgs = self.run_load("regular-mbse", "no-such-skill") + self.assertIn("unknown MBSE skill 'no-such-skill'", tool_msgs[0]["content"]) + self.assertIn("requirement-writing", tool_msgs[0]["content"]) diff --git a/docs/PATTERN-pi-subagents.md b/docs/PATTERN-pi-subagents.md new file mode 100644 index 0000000..01783aa --- /dev/null +++ b/docs/PATTERN-pi-subagents.md @@ -0,0 +1,47 @@ +# Pattern: pi sub-agents on the local Qwen3.8 (always delegate) + +**Rule (owner, 2026-10-02): errands are always delegated to Qwen3.8 through pi.** Reviews, first drafts, test +scaffolding, summaries, log triage, "is this claim right?" checks: send them to the local NEO-CODER (vision LLM, +`127.0.0.1:8002`), then triage the answer. The expensive model plans, decides and verifies; the local model does the +legwork. The model is a first-pass worker, never an authority. + +## The one command +```bash +scripts/delegate-qwen38.sh "Review this for factual errors: ..." # read-only (pi --no-tools) +scripts/delegate-qwen38.sh -f errand.txt -o answer.txt # prompt from a file, answer to a file +scripts/delegate-qwen38.sh --tools -C ../wt-errand -t 600 "Add tests for ..." # may edit: throwaway worktree ONLY +just delegate "one-line prompt" +``` +Exit codes: 0 ok, 2 usage, 3 model not serving, 4 pi provider missing, 124 timeout. + +## Lifecycle of an errand +1. **Scope it small and closed.** One question or one file-sized change; put every fact the model needs in the prompt + (it has no memory of this repo). Say exactly what output shape you want ("list only concrete errors, quote the words"). +2. **Pick the mode.** Default is read-only: the model can only answer from the prompt. Use `--tools` only inside a + throwaway `git worktree`, never the main checkout, and read `git diff` afterwards. +3. **Run it.** ~35 s for a 3k-token prompt; the model is warm. A cold start after boot takes 2-5 minutes (exit 3 until + `/health` answers). +4. **Triage, always.** Treat the output as a lead, not a result. In the first real errand (reviewing the MBSE skills), + 5 findings produced ~1 valid nit; the rest were wrong or contradicted something already verified live. + Verify each claim against the source, run the tests, and only then apply. Record what you rejected and why. +5. **Never delegate the verification.** The model may draft a test; a human or the main agent runs it and checks it + fails when the code is broken (mutation check). + +## Don't +- Put secrets, tokens or private keys in a prompt (it is logged by pi and the server). +- Let it decide architecture, security posture or what ships. +- Run `--tools` in a checkout you care about, or without `-t`. +- Use `localhost` for the endpoint: podman's pasta forward answers IPv6 `localhost` with empty replies. Use `127.0.0.1`. + +## Plumbing facts (verified 2026-10-02) +- pi 0.87.1 provider `neo-coder` in `~/.pi/agent/models.json` -> `http://127.0.0.1:8002/v1`. The script prints the exact + JSON to add if it is missing. The old `llama-cpp/ch0nky` (:8001) entry is dead; pi only warns about it. +- **`< /dev/null` is load-bearing.** Off a TTY, pi waits on stdin forever (a 7-minute hang; the model had answered in 3 s). +- Boot default LLM: `b00t-hive-inference-heretic-neo-coder.service` (`deploy/systemd/`), Conflicts= every other GPU model. +- `b00t agent invoke neo-coder` still reads `~/.b00t/_b00t_/neo-coder.agent.toml` whose endpoint is the dead :8001. + It is b00t's shared datum; repoint it there (to `http://127.0.0.1:8002/v1`) rather than around it. + +## For b00t +To make this a first-class b00t pattern: a `pi-subagent` datum whose `ExecStart` is `scripts/delegate-qwen38.sh` +semantics (preflight `/health`, `--no-tools` default, `&2; exit 2; } + +# Preflight: fail fast and loudly if the model is not serving (cold start after boot takes minutes). +if ! curl -fsS -m 5 "$ENDPOINT/health" >/dev/null 2>&1; then + echo "Qwen3.8 is not answering at $ENDPOINT/health (cold start takes ~2-5 min after boot: systemctl --user status b00t-hive-inference-heretic-neo-coder)" >&2 + exit 3 +fi +if ! grep -q "\"$PROVIDER\"" "$PI_MODELS" 2>/dev/null; then + echo "pi has no '$PROVIDER' provider in $PI_MODELS; add: {\"providers\":{\"$PROVIDER\":{\"baseUrl\":\"$ENDPOINT/v1\",\"api\":\"openai-completions\",\"apiKey\":\"local-b00t\",\"models\":[{\"id\":\"$MODEL\"}]}}}" >&2 + exit 4 +fi + +flags=(-p --provider "$PROVIDER" --model "$MODEL" --no-session) +[ "$tools" = 1 ] || flags+=(--no-tools) + +# `< /dev/null` is load-bearing: off a TTY pi otherwise waits on stdin forever (observed: a 7 minute hang, model answered in 3 s). +run() { cd "$dir" && timeout "$secs" pi "${flags[@]}" "$prompt" < /dev/null 2> >(grep -v -e "No models match pattern" -e "MCP: Project servers blocked" >&2 || true); } +if [ -n "$out" ]; then run > "$out"; else run; fi From 02e96052619f78359469564369138b98b76cace5 Mon Sep 17 00:00:00 2001 From: Brian H Date: Fri, 2 Oct 2026 10:20:41 +0000 Subject: [PATCH 2/3] feat: project-local neo-coder datum so 'b00t agent invoke neo-coder' runs on Qwen3.8 via pi Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- _b00t_/neo-coder.agent.toml | 46 ++++++++++++++++++++++++++++++++++++ docs/PATTERN-pi-subagents.md | 7 ++++-- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 _b00t_/neo-coder.agent.toml diff --git a/_b00t_/neo-coder.agent.toml b/_b00t_/neo-coder.agent.toml new file mode 100644 index 0000000..51893c2 --- /dev/null +++ b/_b00t_/neo-coder.agent.toml @@ -0,0 +1,46 @@ +# b00t:map v1 +# summary: NEO-CODER (local Qwen3.8 + vision, :8002) as a pi-driven errand agent; project-local override of the global datum +# tags: agent, neo-coder, qwen38, pi, kr0ki, local-override +# tier: worker +# cmds: b00t agent invoke neo-coder "task" (run from the kr0ki repo root) +# complexity: 2 +# +# πŸ€“ Why this file exists: ~/.b00t/_b00t_/neo-coder.agent.toml points at the retired Qwen3.6 endpoint (http://sm3lly:8001) +# and has no [b00t.agent.executor], so `b00t agent invoke neo-coder` could not run. `b00t agent invoke` resolves +# `./_b00t_/.agent.toml` from the CURRENT DIRECTORY and never consults the global datum, so this project-local +# datum overrides it whenever invoke runs from this repo's root. The global one is b00t's shared file and is untouched. +# πŸ€“ Read-only by default (pi --no-tools): the model answers from the prompt. See docs/PATTERN-pi-subagents.md; for edits use +# scripts/delegate-qwen38.sh --tools in a throwaway worktree. The model is a first-pass worker: triage what it returns. +# πŸ€“ The pi provider `neo-coder` (-> http://127.0.0.1:8002/v1) lives in ~/.pi/agent/models.json. Use 127.0.0.1, not localhost. + +[b00t] +name = "neo-coder" +type = "agent" +hint = "Local Qwen3.8 NEO-CODER (vision) via pi, read-only errand agent for kr0ki" + +[b00t.agent] +pid = "neo-coder-kr0ki" +model = "neo-coder" +model_endpoint = "http://127.0.0.1:8002/v1" +skills = ["rust", "b00t-cli"] +personality = "precise" +humor = "none" +role = "worker" +context_budget = 32768 + +[b00t.agent.executor] +cli_path = "pi" +# prompt arrives on stdin from `b00t agent invoke`; --no-tools keeps it read-only; one pass, no tool loop +cli_args = ["-p", "--provider", "neo-coder", "--model", "neo-coder", "--no-session", "--no-tools"] +supports_tools = [] +max_iterations = 1 + +[b00t.agent.ipc] +# transient, per-invocation: no socket, no pub/sub +socket = "" +pubsub = false +protocol = "stdio" + +[b00t.agent.crew] +role = "specialist" +captain = false diff --git a/docs/PATTERN-pi-subagents.md b/docs/PATTERN-pi-subagents.md index 01783aa..eca2567 100644 --- a/docs/PATTERN-pi-subagents.md +++ b/docs/PATTERN-pi-subagents.md @@ -38,8 +38,11 @@ Exit codes: 0 ok, 2 usage, 3 model not serving, 4 pi provider missing, 124 timeo JSON to add if it is missing. The old `llama-cpp/ch0nky` (:8001) entry is dead; pi only warns about it. - **`< /dev/null` is load-bearing.** Off a TTY, pi waits on stdin forever (a 7-minute hang; the model had answered in 3 s). - Boot default LLM: `b00t-hive-inference-heretic-neo-coder.service` (`deploy/systemd/`), Conflicts= every other GPU model. -- `b00t agent invoke neo-coder` still reads `~/.b00t/_b00t_/neo-coder.agent.toml` whose endpoint is the dead :8001. - It is b00t's shared datum; repoint it there (to `http://127.0.0.1:8002/v1`) rather than around it. +- `b00t agent invoke neo-coder "task"` works from the kr0ki repo root: `./_b00t_/neo-coder.agent.toml` (tracked here) is a + pi-driven, read-only executor against :8002. `invoke` resolves `./_b00t_/.agent.toml` from the CURRENT directory and + never consults `~/.b00t/_b00t_/neo-coder.agent.toml` (global, still names the retired `sm3lly:8001` and has no executor), + so the project-local datum overrides it with no change to b00t. A datum needs `[b00t.agent.ipc]` and `[b00t.agent.crew]` + even for a one-shot agent, or it fails to parse. Verified 2026-10-02. ## For b00t To make this a first-class b00t pattern: a `pi-subagent` datum whose `ExecStart` is `scripts/delegate-qwen38.sh` From c38a27771b725ab9245b0ec085e3e9c9dd08d43f Mon Sep 17 00:00:00 2001 From: Brian H Date: Fri, 2 Oct 2026 21:16:25 +0000 Subject: [PATCH 3/3] feat(assurance): versioned assurance thread - KR-A01..A05, audit log, service, CLI, MCP tools (partial) Baseline (docs/assurance) of the brief's own 8 requirements and its source obligations, system elements, controls and cases; typed profile from ufo-types (PromptExecution/ufo-types#31). - KR-A01 ReqIF export now carries typed attributes (enumerations for the profile vocabularies, strings otherwise); reqif_roundtrip compares semantic content and reports unsupported content explicitly. Previously every attribute was silently dropped. - KR-A02 assurance_trace: links resolved against a named model revision; dangling reported. - KR-A03 assurance_view: satisfaction assertions vs verification results drawn apart. - KR-A04 verification_runner + evidence_store: results bound to model/implementation/ configuration revisions; zero tests run is an error; allowlisted argv, no shell. - KR-A05 change_service: propose/validate/commit with an expected base revision; a stale base is rejected, never rebased. Live finding: the OMG pilot server does not enforce previousCommit, so this service is the only guard. - audit_log (KR-A08 building block): hash-chained, append-only. - assurance_service + kr0ki-assurance CLI + just recipes; 10 MCP tools; mcp_surface (resources/prompts); skills/requirements-authoring (Agent Skills format) and SKILLS.md. Not finished (see PR): gateway wiring (KR-A06), audit calls, sandbox (KR-A07), /assurance routes, bridge resources/prompts, Playb00k. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- .gitignore | 2 + Cargo.lock | 2 +- SKILLS.md | 14 + crates/kr0ki-core/Cargo.toml | 2 +- crates/kr0ki-core/src/assurance_baseline.rs | 443 +++++++++ crates/kr0ki-core/src/assurance_service.rs | 576 +++++++++++ crates/kr0ki-core/src/assurance_trace.rs | 589 +++++++++++ crates/kr0ki-core/src/assurance_view.rs | 304 ++++++ crates/kr0ki-core/src/audit_log.rs | 589 +++++++++++ crates/kr0ki-core/src/bin/kr0ki-assurance.rs | 297 ++++++ crates/kr0ki-core/src/change_service.rs | 916 +++++++++++++++++ crates/kr0ki-core/src/evidence_store.rs | 346 +++++++ crates/kr0ki-core/src/lib.rs | 10 + crates/kr0ki-core/src/mcp_surface.rs | 368 +++++++ crates/kr0ki-core/src/mcp_tool.rs | 291 +++++- crates/kr0ki-core/src/reqif_export.rs | 318 +++++- crates/kr0ki-core/src/reqif_roundtrip.rs | 404 ++++++++ crates/kr0ki-core/src/sync_engine.rs | 3 +- crates/kr0ki-core/src/verification_runner.rs | 919 ++++++++++++++++++ crates/kr0ki-core/tests/assurance_a01.rs | 324 ++++++ crates/kr0ki-core/tests/assurance_a02.rs | 307 ++++++ crates/kr0ki-core/tests/assurance_a03.rs | 299 ++++++ crates/kr0ki-core/tests/assurance_a04.rs | 210 ++++ crates/kr0ki-core/tests/assurance_a05.rs | 191 ++++ crates/kr0ki-core/tests/assurance_a05_live.rs | 168 ++++ crates/kr0ki-core/tests/assurance_baseline.rs | 225 +++++ crates/kr0ki-core/tests/assurance_service.rs | 358 +++++++ crates/kr0ki-server/src/gateway.rs | 634 ++++++++++++ crates/kr0ki-server/tests/http.rs | 2 +- docs/assurance/BRIEF-assurance-thread.md | 177 ++++ docs/assurance/kr0ki-assurance.sysml | 44 + docs/assurance/kr0ki.assurance.toml | 365 +++++++ justfile | 19 + skills/requirements-authoring/SKILL.md | 77 ++ .../references/profile.md | 48 + .../references/statement-patterns.md | 25 + .../requirements-authoring/scripts/check.sh | 6 + 37 files changed, 9818 insertions(+), 54 deletions(-) create mode 100644 SKILLS.md create mode 100644 crates/kr0ki-core/src/assurance_baseline.rs create mode 100644 crates/kr0ki-core/src/assurance_service.rs create mode 100644 crates/kr0ki-core/src/assurance_trace.rs create mode 100644 crates/kr0ki-core/src/assurance_view.rs create mode 100644 crates/kr0ki-core/src/audit_log.rs create mode 100644 crates/kr0ki-core/src/bin/kr0ki-assurance.rs create mode 100644 crates/kr0ki-core/src/change_service.rs create mode 100644 crates/kr0ki-core/src/evidence_store.rs create mode 100644 crates/kr0ki-core/src/mcp_surface.rs create mode 100644 crates/kr0ki-core/src/reqif_roundtrip.rs create mode 100644 crates/kr0ki-core/src/verification_runner.rs create mode 100644 crates/kr0ki-core/tests/assurance_a01.rs create mode 100644 crates/kr0ki-core/tests/assurance_a02.rs create mode 100644 crates/kr0ki-core/tests/assurance_a03.rs create mode 100644 crates/kr0ki-core/tests/assurance_a04.rs create mode 100644 crates/kr0ki-core/tests/assurance_a05.rs create mode 100644 crates/kr0ki-core/tests/assurance_a05_live.rs create mode 100644 crates/kr0ki-core/tests/assurance_baseline.rs create mode 100644 crates/kr0ki-core/tests/assurance_service.rs create mode 100644 crates/kr0ki-server/src/gateway.rs create mode 100644 docs/assurance/BRIEF-assurance-thread.md create mode 100644 docs/assurance/kr0ki-assurance.sysml create mode 100644 docs/assurance/kr0ki.assurance.toml create mode 100644 skills/requirements-authoring/SKILL.md create mode 100644 skills/requirements-authoring/references/profile.md create mode 100644 skills/requirements-authoring/references/statement-patterns.md create mode 100755 skills/requirements-authoring/scripts/check.sh diff --git a/.gitignore b/.gitignore index 342514a..8e154de 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ __pycache__/ .venv/ /.kr0ki-run/ containers/kr0ki-sysml-mcp/node_modules/ +/.kr0ki-evidence/ +/.kr0ki-audit/ diff --git a/Cargo.lock b/Cargo.lock index 8f45250..660e15c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3485,7 +3485,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ufo-types" version = "0.15.0" -source = "git+https://github.com/PromptExecution/ufo-types?rev=ee873488f31a390073d5f8ab38d91654a3504bfa#ee873488f31a390073d5f8ab38d91654a3504bfa" +source = "git+https://github.com/PromptExecution/ufo-types?rev=db8e22fc2c09ff1b58cc4effce6096d17a89f3e4#db8e22fc2c09ff1b58cc4effce6096d17a89f3e4" dependencies = [ "anyhow", "chrono", diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 0000000..f9ae6e3 --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,14 @@ +# Skills + +A short index. Each portable skill is a directory with a `SKILL.md` (metadata + instructions) +and optional `scripts/` and `references/`, per the [Agent Skills +specification](https://agentskills.io/specification). Skill text **guides**; it never grants +permission. The tool gateway and the sandbox enforce what a caller may do. + +| skill | what it is for | +|---|---| +| [`skills/requirements-authoring`](skills/requirements-authoring/SKILL.md) | Author, review and trace requirements through the assurance thread; keep satisfaction assertions apart from revision-bound verification results. | + +The storyb00k agent's own prompt-sized skills (diagram types and MBSE guidance) live in +[`containers/kr0ki-storyb00k-agent/skills/`](containers/kr0ki-storyb00k-agent/skills) and are loaded +with its `load_mbse_skill` tool. They are a different, flatter format and are not Agent Skills packages. diff --git a/crates/kr0ki-core/Cargo.toml b/crates/kr0ki-core/Cargo.toml index f995317..c6936d1 100644 --- a/crates/kr0ki-core/Cargo.toml +++ b/crates/kr0ki-core/Cargo.toml @@ -32,7 +32,7 @@ kr0ki-sysmlv2-client = { path = "../kr0ki-sysmlv2-client" } # exists after ufo-types#29's squash-merge into main (2026-09-20). Re-pinned # to that merge commit on main -- bump to a `tag =` pin once ufo-types cuts # a release past v0.15.0 including it. -ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "ee873488f31a390073d5f8ab38d91654a3504bfa", features = ["reqif"] } +ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "db8e22fc2c09ff1b58cc4effce6096d17a89f3e4", features = ["reqif"] } # kr0ki#13 β€” box 5 (b00t-graph Turtle -> D2 route): the typed TypeRelationshipGraph / # CytoscapeGraph the D2 emitter targets. No tagged release exists yet (checked # 2026-09-16); pinned by git rev per this issue's own operator decision, to be diff --git a/crates/kr0ki-core/src/assurance_baseline.rs b/crates/kr0ki-core/src/assurance_baseline.rs new file mode 100644 index 0000000..e7fb012 --- /dev/null +++ b/crates/kr0ki-core/src/assurance_baseline.rs @@ -0,0 +1,443 @@ +//! The assurance baseline's authoring format β†’ [`RequirementGraph`]. +//! +//! `docs/assurance/kr0ki.assurance.toml` is the diffable authoring source (what the +//! requirements agent proposes edits to). This module lowers it onto the existing +//! requirement graph using the `ufo_types::mbse::assurance` profile, so ReqIF export, the +//! requirements views, Flexo sync, MCP and Playb00k keep consuming one graph type. +//! +//! The loader is deterministic and strict: unknown keys are errors (a typo must not silently +//! drop a field), every reference inside the file must resolve, and every requirement must +//! carry the nine required profile fields. It never invents data. +//! +//! What is *not* checked here: whether a system element exists in a model revision +//! (`assurance_trace`, KR-A02) and whether a control's implementation actually works +//! (the verification cases). + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use ufo_types::mbse::assurance::{ + statement_issues, NodeKind, ProfileIssue, ProfileRequirement, SourceKind, StatementIssue, + NODE_KIND_ATTR, +}; +use ufo_types::mbse::requirements::{ + BaselineIdentity, Provenance, RelationAuthority, Requirement, RequirementError, + RequirementGraph, RequirementRelation, RequirementRelationKind, +}; + +/// Why a baseline file could not be loaded. +#[derive(Debug, thiserror::Error)] +pub enum BaselineError { + #[error("baseline TOML: {0}")] + Toml(#[from] toml::de::Error), + #[error("{field} `{value}` referenced by `{owner}` is not declared in the baseline")] + UnknownReference { + owner: String, + field: &'static str, + value: String, + }, + #[error("`{id}`: invalid {field} `{value}`")] + InvalidValue { + id: String, + field: &'static str, + value: String, + }, + #[error("requirement profile problems: {0:?}")] + Profile(Vec), + #[error(transparent)] + Graph(#[from] RequirementError), +} + +/// A lowered baseline plus the findings that do not stop it loading. +#[derive(Debug, Clone)] +pub struct LoadedBaseline { + pub graph: RequirementGraph, + /// Deterministic statement lint, per requirement id. Empty for a clean baseline. + pub statement_lints: Vec<(String, StatementIssue)>, + /// Path of the SysML model the `element` locators resolve against, as written in the file. + pub model: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Doc { + baseline: BaselineDoc, + #[serde(default)] + obligation: Vec, + #[serde(default)] + element: Vec, + #[serde(default)] + control: Vec, + #[serde(default)] + case: Vec, + #[serde(default)] + requirement: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BaselineDoc { + id: String, + #[allow(dead_code)] + title: String, + model: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ObligationDoc { + id: String, + title: String, + source: String, + version: String, + kind: String, + summary: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ElementDoc { + id: String, + title: String, + description: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ControlDoc { + id: String, + title: String, + component: String, + #[serde(default)] + implementation: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CaseDoc { + id: String, + title: String, + command: Vec, + acceptance: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DerivesDoc { + obligation: String, + locator: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RequirementDoc { + id: String, + title: String, + statement: String, + source: String, + source_kind: String, + owner: String, + rationale: String, + element: String, + control: String, + verification_id: String, + acceptance: String, + status: String, + #[serde(default)] + derives: Vec, +} + +/// Lowercase hex SHA-256 of `bytes`. +pub fn sha256_hex(bytes: &[u8]) -> String { + Digest::finalize(Sha256::new().chain_update(bytes)) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// Lower the baseline file onto a [`RequirementGraph`]. +/// +/// - `toml_text`: the file's content. +/// - `source_uri`: where it lives (repo-relative path or URL), recorded as provenance. +/// - `revision`: the immutable revision the baseline is read at (a git commit id). +/// - `model_revision`: the model revision the `system_element` locators belong to, recorded +/// on each element node when known. +pub fn load_baseline( + toml_text: &str, + source_uri: &str, + revision: &str, + model_revision: Option<&str>, +) -> Result { + let doc: Doc = toml::from_str(toml_text)?; + let digest = sha256_hex(toml_text.as_bytes()); + let baseline = BaselineIdentity { + id: doc.baseline.id.clone(), + revision: revision.to_string(), + import_artifact_sha256: Some(digest.clone()), + exported_baseline_sha256: None, + }; + let prov = |locator: String| Provenance { + source_uri: source_uri.to_string(), + artifact_sha256: Some(digest.clone()), + locator: Some(locator), + }; + let node = |id: &str, + title: &str, + text: &str, + locator: String, + attributes: BTreeMap| Requirement { + id: id.to_string(), + title: title.to_string(), + text: text.to_string(), + baseline: baseline.clone(), + provenance: prov(locator), + attributes, + evidence: Vec::new(), + }; + let kind_attr = |kind: NodeKind| (NODE_KIND_ATTR.to_string(), kind.as_str().to_string()); + + let mut nodes: Vec = Vec::new(); + let mut declared: BTreeMap = BTreeMap::new(); + + for o in &doc.obligation { + let kind = SourceKind::parse(&o.kind).ok_or_else(|| BaselineError::InvalidValue { + id: o.id.clone(), + field: "kind", + value: o.kind.clone(), + })?; + declared.insert(o.id.clone(), NodeKind::SourceObligation); + nodes.push(node( + &o.id, + &o.title, + &o.summary, + format!("obligation[{}]", o.id), + BTreeMap::from([ + kind_attr(NodeKind::SourceObligation), + ("source".into(), o.source.clone()), + ("source_kind".into(), kind.as_str().into()), + ("version".into(), o.version.clone()), + ]), + )); + } + for e in &doc.element { + declared.insert(e.id.clone(), NodeKind::SystemElement); + let mut attrs = BTreeMap::from([ + kind_attr(NodeKind::SystemElement), + ("qualified_name".into(), e.id.clone()), + ]); + if let Some(m) = model_revision { + attrs.insert("model_revision".into(), m.to_string()); + } + nodes.push(node( + &e.id, + &e.title, + &e.description, + format!("element[{}]", e.id), + attrs, + )); + } + for c in &doc.control { + if c.component != "kr0ki" && c.component != "deployment" { + return Err(BaselineError::InvalidValue { + id: c.id.clone(), + field: "component", + value: c.component.clone(), + }); + } + declared.insert(c.id.clone(), NodeKind::Control); + nodes.push(node( + &c.id, + &c.title, + &c.title, + format!("control[{}]", c.id), + BTreeMap::from([ + kind_attr(NodeKind::Control), + ("component".into(), c.component.clone()), + ("implementation".into(), c.implementation.clone()), + ]), + )); + } + for c in &doc.case { + declared.insert(c.id.clone(), NodeKind::VerificationCase); + let command = serde_json::to_string(&c.command).expect("a Vec serializes"); + nodes.push(node( + &c.id, + &c.title, + &c.acceptance, + format!("case[{}]", c.id), + BTreeMap::from([ + kind_attr(NodeKind::VerificationCase), + ("command".into(), command), + ]), + )); + } + + let mut relations: Vec = Vec::new(); + let mut seen_relations: BTreeSet = BTreeSet::new(); + let mut relate = |kind: RequirementRelationKind, + source: &str, + target: &str, + locator: String, + relations: &mut Vec| { + let id = format!("{}:{source}->{target}", relation_slug(kind)); + if seen_relations.insert(id.clone()) { + relations.push(RequirementRelation { + id, + source: source.to_string(), + target: target.to_string(), + kind, + authority: RelationAuthority::Asserted, + provenance: prov(locator), + promotion: None, + }); + } + }; + + let mut profile_issues = Vec::new(); + let mut statement_lints = Vec::new(); + for r in &doc.requirement { + reference( + &declared, + &r.id, + "element", + &r.element, + NodeKind::SystemElement, + )?; + reference(&declared, &r.id, "control", &r.control, NodeKind::Control)?; + reference( + &declared, + &r.id, + "verification_id", + &r.verification_id, + NodeKind::VerificationCase, + )?; + for d in &r.derives { + reference( + &declared, + &r.id, + "derives.obligation", + &d.obligation, + NodeKind::SourceObligation, + )?; + } + if declared.contains_key(&r.id) { + return Err(RequirementError::DuplicateNode(r.id.clone()).into()); + } + declared.insert(r.id.clone(), NodeKind::Requirement); + + // Build the node, then read it back through the profile so a malformed field is + // reported exactly as it would be for any other producer of these nodes. + let attributes = BTreeMap::from([ + kind_attr(NodeKind::Requirement), + ("source".into(), r.source.clone()), + ("source_kind".into(), r.source_kind.clone()), + ("owner".into(), r.owner.clone()), + ("rationale".into(), r.rationale.clone()), + ("verification_id".into(), r.verification_id.clone()), + ("acceptance".into(), r.acceptance.clone()), + ("status".into(), r.status.clone()), + ]); + let n = node( + &r.id, + &r.title, + &r.statement, + format!("requirement[{}]", r.id), + attributes, + ); + match ProfileRequirement::from_node(&n) { + Ok(_) => {} + Err(mut issues) => profile_issues.append(&mut issues), + } + for issue in statement_issues(&n.text) { + statement_lints.push((n.id.clone(), issue)); + } + nodes.push(n); + + for d in &r.derives { + relate( + RequirementRelationKind::Derives, + &d.obligation, + &r.id, + d.locator.clone(), + &mut relations, + ); + } + relate( + RequirementRelationKind::Satisfies, + &r.element, + &r.id, + format!("requirement[{}].element", r.id), + &mut relations, + ); + relate( + RequirementRelationKind::Implements, + &r.control, + &r.id, + format!("requirement[{}].control", r.id), + &mut relations, + ); + relate( + RequirementRelationKind::AllocatedTo, + &r.control, + &r.element, + format!("requirement[{}]", r.id), + &mut relations, + ); + relate( + RequirementRelationKind::Verifies, + &r.verification_id, + &r.id, + format!("requirement[{}].verification_id", r.id), + &mut relations, + ); + } + if !profile_issues.is_empty() { + return Err(BaselineError::Profile(profile_issues)); + } + let graph = RequirementGraph { + baseline, + requirements: nodes, + evidence: Vec::new(), + relations, + }; + graph.validate()?; + Ok(LoadedBaseline { + graph, + statement_lints, + model: doc.baseline.model, + }) +} + +fn reference( + declared: &BTreeMap, + owner: &str, + field: &'static str, + value: &str, + want: NodeKind, +) -> Result<(), BaselineError> { + if declared.get(value) == Some(&want) { + Ok(()) + } else { + Err(BaselineError::UnknownReference { + owner: owner.to_string(), + field, + value: value.to_string(), + }) + } +} + +fn relation_slug(kind: RequirementRelationKind) -> &'static str { + match kind { + RequirementRelationKind::Contains => "contains", + RequirementRelationKind::Derives => "derives", + RequirementRelationKind::Refines => "refines", + RequirementRelationKind::Requires => "requires", + RequirementRelationKind::Satisfies => "satisfies", + RequirementRelationKind::Verifies => "verifies", + RequirementRelationKind::Implements => "implements", + RequirementRelationKind::Traces => "traces", + RequirementRelationKind::AllocatedTo => "allocated_to", + RequirementRelationKind::Precedes => "precedes", + } +} diff --git a/crates/kr0ki-core/src/assurance_service.rs b/crates/kr0ki-core/src/assurance_service.rs new file mode 100644 index 0000000..39d559c --- /dev/null +++ b/crates/kr0ki-core/src/assurance_service.rs @@ -0,0 +1,576 @@ +//! The assurance thread as a service: the one place the CLI, the HTTP routes and the MCP tools +//! get their answers from. +//! +//! Every answer is **structured** and **revision-qualified**: it carries the model revision +//! and implementation revision it was computed at, so a caller can tell what it is looking at +//! and whether it has gone stale. Every answer is also **targeted**: a caller asks for one +//! requirement, one trace, one case's evidence, instead of loading the whole baseline into +//! context. [`AssuranceService::list_requirements`] returns one short row per requirement, and +//! [`AssuranceService::get_requirement`] returns one requirement in full. +//! +//! The baseline and the model are re-read on every call, so an edit is visible immediately and +//! there is no cache to go stale. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use serde::Serialize; +use ufo_types::mbse::assurance::{ + statement_issues, Assurance, CurrentRevisions, EvidenceRecord, Freshness, GapKind, NodeKind, + ProfileRequirement, RequirementThread, ThreadError, VerificationResult, NODE_KIND_ATTR, +}; +use ufo_types::mbse::requirements::RequirementGraph; + +use crate::assurance_baseline::{load_baseline, BaselineError, LoadedBaseline}; +use crate::assurance_trace::{ + resolve_links, ArtifactResolver, DirResolver, LinkResolution, ModelRevisionIndex, +}; +use crate::assurance_view::{assurance_view, AssuranceView}; +use crate::evidence_store::{ArtifactCheck, EvidenceStoreError, FsEvidenceStore}; +use crate::verification_runner::{ + cases_from_graph, current_revisions, CommandExecutor, RevisionProbe, RunnerConfig, RunnerError, + VerificationRunner, +}; + +#[derive(Debug, Clone)] +pub struct AssuranceConfig { + /// The baseline authoring file. Its `[baseline] model` is resolved relative to its directory. + pub baseline_path: PathBuf, + /// The repository: implementation paths, test targets and `git` revisions resolve here. + pub repo_root: PathBuf, + pub evidence_dir: PathBuf, + pub run_timeout: Duration, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error("cannot read {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("the baseline declares no `[baseline] model`, so links cannot be resolved against a model revision")] + NoModel, + #[error(transparent)] + Baseline(#[from] BaselineError), + #[error("implementation revision: {0}")] + Probe(String), + #[error(transparent)] + Evidence(#[from] EvidenceStoreError), + #[error(transparent)] + Thread(#[from] ThreadError), + #[error("unknown requirement `{0}`")] + UnknownRequirement(String), + #[error("unknown verification case `{0}`")] + UnknownCase(String), + /// The caller asked to verify one implementation revision, the repository is at another. + #[error("revision mismatch: you asked to verify {expected}, the repository is at {actual}")] + RevisionMismatch { expected: String, actual: String }, + #[error(transparent)] + Runner(#[from] RunnerError), +} + +/// The revisions every answer is qualified by. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Revisions { + pub model_revision: String, + pub implementation_revision: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RequirementSummary { + pub id: String, + pub title: String, + pub status: String, + pub owner: String, + pub source_kind: String, + pub assurance: Assurance, + pub gaps: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RequirementList { + pub revisions: Revisions, + /// How many requirements matched; the list is exactly these. + pub total: usize, + pub requirements: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ObligationRef { + pub id: String, + pub title: String, + pub source: String, + pub source_kind: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ControlRef { + pub id: String, + pub title: String, + pub component: String, + /// Empty = no implementation recorded (an implementation gap). + pub implementation: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CaseRef { + pub id: String, + pub title: String, + pub command: Vec, + pub acceptance: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RequirementDetail { + pub revisions: Revisions, + pub requirement: ProfileRequirement, + pub statement_lints: Vec, + pub obligations: Vec, + pub elements: Vec, + pub controls: Vec, + pub cases: Vec, + pub thread: RequirementThread, + pub gaps: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TraceReport { + pub revisions: Revisions, + pub requirement_id: String, + pub links: Vec, + pub dangling: usize, + pub gaps: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvidenceItem { + pub record: EvidenceRecord, + pub evidence_key: String, + pub freshness: Freshness, + /// `intact`, `missing`, `digest_mismatch` or `external`. + pub artifact: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvidenceList { + pub revisions: Revisions, + pub records: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct VerificationOutcome { + pub revisions: Revisions, + pub case_id: String, + pub result: VerificationResult, + pub detail: String, + pub evidence_keys: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct RequirementFilter { + pub status: Option, + pub owner: Option, + /// `unsatisfied`, `satisfied_untested`, `verified`, `failing`, `stale`. + pub state: Option, + /// A gap kind as serialised (e.g. `control_not_implemented`). + pub gap: Option, +} + +/// Everything computed at one moment from the files and the store. +pub struct Snapshot { + pub loaded: LoadedBaseline, + pub index: ModelRevisionIndex, + pub current: CurrentRevisions, + pub evidence: Vec, + pub links: LinkResolution, + pub view: AssuranceView, +} + +impl Snapshot { + pub fn graph(&self) -> &RequirementGraph { + &self.loaded.graph + } + + pub fn revisions(&self) -> Revisions { + Revisions { + model_revision: self.current.model_revision.clone(), + implementation_revision: self.current.implementation_revision.clone(), + } + } +} + +pub struct AssuranceService { + cfg: AssuranceConfig, + store: FsEvidenceStore, + probe: Arc, + executor: Arc, +} + +fn gap_name(g: &GapKind) -> String { + serde_json::to_value(g) + .ok() + .and_then(|v| v.get("gap").and_then(|n| n.as_str().map(str::to_owned))) + .unwrap_or_default() +} + +fn attr<'a>(graph: &'a RequirementGraph, id: &str, key: &str) -> &'a str { + graph + .requirements + .iter() + .find(|n| n.id == id) + .and_then(|n| n.attributes.get(key)) + .map(String::as_str) + .unwrap_or("") +} + +impl AssuranceService { + pub fn new( + cfg: AssuranceConfig, + probe: Arc, + executor: Arc, + ) -> Self { + let store = FsEvidenceStore::new(&cfg.evidence_dir); + Self { + cfg, + store, + probe, + executor, + } + } + + pub fn config(&self) -> &AssuranceConfig { + &self.cfg + } + + pub fn store(&self) -> &FsEvidenceStore { + &self.store + } + + fn read(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|source| ServiceError::Io { + path: path.to_path_buf(), + source, + }) + } + + /// The model revision alone (content address of the model file), without the rest. + pub fn model_revision(&self) -> Option { + let text = Self::read(&self.cfg.baseline_path).ok()?; + let loaded = load_baseline(&text, "baseline", "-", None).ok()?; + let model = self.cfg.baseline_path.parent()?.join(loaded.model?); + Some( + ModelRevisionIndex::from_sysml_text(&Self::read(&model).ok()?) + .revision() + .to_string(), + ) + } + + /// Re-read everything and compute the thread at the current revisions. + pub fn snapshot(&self) -> Result { + let text = Self::read(&self.cfg.baseline_path)?; + let implementation = self + .probe + .implementation_revision() + .map_err(ServiceError::Probe)?; + let preliminary = load_baseline( + &text, + &self.cfg.baseline_path.to_string_lossy(), + &implementation, + None, + )?; + let model_rel = preliminary.model.clone().ok_or(ServiceError::NoModel)?; + let model_text = Self::read( + &self + .cfg + .baseline_path + .parent() + .unwrap_or(Path::new(".")) + .join(model_rel), + )?; + let index = ModelRevisionIndex::from_sysml_text(&model_text); + let loaded = load_baseline( + &text, + &self.cfg.baseline_path.to_string_lossy(), + &implementation, + Some(index.revision()), + )?; + let current = current_revisions(&loaded.graph, self.probe.as_ref(), index.revision()) + .map_err(ServiceError::Probe)?; + let evidence = self.store.list()?; + let artifacts = DirResolver::new(&self.cfg.repo_root); + let links = resolve_links(&loaded.graph, &index, &artifacts as &dyn ArtifactResolver); + let view = assurance_view(&loaded.graph, &evidence, ¤t, Some(index.locators()))?; + Ok(Snapshot { + loaded, + index, + current, + evidence, + links, + view, + }) + } + + fn gaps_of(snapshot: &Snapshot, id: &str) -> Vec { + snapshot.view.report.gaps_for(id).cloned().collect() + } + + pub fn list_requirements( + &self, + filter: &RequirementFilter, + ) -> Result { + let snap = self.snapshot()?; + let graph = snap.graph(); + let mut rows = Vec::new(); + for row in &snap.view.rows { + let gaps = Self::gaps_of(&snap, &row.requirement_id); + let summary = RequirementSummary { + id: row.requirement_id.clone(), + title: graph + .requirements + .iter() + .find(|n| n.id == row.requirement_id) + .map(|n| n.title.clone()) + .unwrap_or_default(), + status: attr(graph, &row.requirement_id, "status").to_string(), + owner: attr(graph, &row.requirement_id, "owner").to_string(), + source_kind: attr(graph, &row.requirement_id, "source_kind").to_string(), + assurance: row.assurance, + gaps, + }; + let keep = filter.status.as_ref().is_none_or(|s| &summary.status == s) + && filter.owner.as_ref().is_none_or(|o| &summary.owner == o) + && filter + .state + .as_ref() + .is_none_or(|s| crate::assurance_view::state_name(summary.assurance) == s) + && filter + .gap + .as_ref() + .is_none_or(|g| summary.gaps.iter().any(|k| &gap_name(k) == g)); + if keep { + rows.push(summary); + } + } + Ok(RequirementList { + revisions: snap.revisions(), + total: rows.len(), + requirements: rows, + }) + } + + pub fn get_requirement(&self, id: &str) -> Result { + let snap = self.snapshot()?; + let graph = snap.graph(); + let node = graph + .requirements + .iter() + .find(|n| { + n.id == id + && n.attributes.get(NODE_KIND_ATTR).map(String::as_str) == Some("requirement") + }) + .ok_or_else(|| ServiceError::UnknownRequirement(id.to_string()))?; + let requirement = ProfileRequirement::from_node(node) + .map_err(|issues| ServiceError::Baseline(BaselineError::Profile(issues)))?; + let thread = snap + .view + .report + .requirement(id) + .cloned() + .ok_or_else(|| ServiceError::UnknownRequirement(id.to_string()))?; + let by_id = |i: &str| graph.requirements.iter().find(|n| n.id == i); + let obligations = thread + .sources + .iter() + .filter_map(|i| by_id(i)) + .map(|n| ObligationRef { + id: n.id.clone(), + title: n.title.clone(), + source: n.attributes.get("source").cloned().unwrap_or_default(), + source_kind: n.attributes.get("source_kind").cloned().unwrap_or_default(), + version: n.attributes.get("version").cloned().unwrap_or_default(), + }) + .collect(); + let controls = thread + .enforced_by + .iter() + .filter_map(|i| by_id(i)) + .map(|n| ControlRef { + id: n.id.clone(), + title: n.title.clone(), + component: n.attributes.get("component").cloned().unwrap_or_default(), + implementation: n + .attributes + .get("implementation") + .cloned() + .unwrap_or_default(), + }) + .collect(); + let cases = thread + .verification_cases + .iter() + .filter_map(|i| by_id(i)) + .map(|n| CaseRef { + id: n.id.clone(), + title: n.title.clone(), + command: n + .attributes + .get("command") + .and_then(|c| serde_json::from_str(c).ok()) + .unwrap_or_default(), + acceptance: n.text.clone(), + }) + .collect(); + Ok(RequirementDetail { + revisions: snap.revisions(), + statement_lints: statement_issues(&requirement.statement) + .iter() + .map(|i| i.to_string()) + .collect(), + requirement, + obligations, + elements: thread.satisfied_by.clone(), + controls, + cases, + gaps: Self::gaps_of(&snap, id), + thread, + }) + } + + pub fn trace_requirement(&self, id: &str) -> Result { + let snap = self.snapshot()?; + if !snap.view.rows.iter().any(|r| r.requirement_id == id) { + return Err(ServiceError::UnknownRequirement(id.to_string())); + } + let links: Vec<_> = snap.links.for_requirement(id).cloned().collect(); + let dangling = links + .iter() + .filter(|l| { + matches!( + l.status, + crate::assurance_trace::LinkStatus::Dangling { .. } + ) + }) + .count(); + Ok(TraceReport { + revisions: snap.revisions(), + requirement_id: id.to_string(), + links, + dangling, + gaps: Self::gaps_of(&snap, id), + }) + } + + pub fn evidence( + &self, + requirement: Option<&str>, + case: Option<&str>, + ) -> Result { + let snap = self.snapshot()?; + let checks = self.store.verify()?; + let records = checks + .into_iter() + .filter(|(r, _)| requirement.is_none_or(|q| r.requirement_id == q)) + .filter(|(r, _)| case.is_none_or(|c| r.verification_id == c)) + .map(|(record, check)| EvidenceItem { + evidence_key: record.key(), + freshness: record.freshness(&snap.current), + artifact: match check { + ArtifactCheck::Intact => "intact", + ArtifactCheck::Missing => "missing", + ArtifactCheck::DigestMismatch { .. } => "digest_mismatch", + ArtifactCheck::External => "external", + }, + record, + }) + .collect(); + Ok(EvidenceList { + revisions: snap.revisions(), + records, + }) + } + + /// Run one declared case (blocking). `expected_implementation_revision` is what the caller + /// believes it is verifying; a different repository revision refuses the run. + pub fn run_verification( + &self, + case_id: &str, + expected_implementation_revision: &str, + ) -> Result { + let snap = self.snapshot()?; + let case = cases_from_graph(snap.graph()) + .into_iter() + .find(|c| c.id == case_id) + .ok_or_else(|| ServiceError::UnknownCase(case_id.to_string()))?; + let actual = self + .probe + .implementation_revision() + .map_err(ServiceError::Probe)?; + if actual != expected_implementation_revision { + return Err(ServiceError::RevisionMismatch { + expected: expected_implementation_revision.to_string(), + actual, + }); + } + let runner = VerificationRunner { + store: &self.store, + executor: self.executor.as_ref(), + probe: self.probe.as_ref(), + config: RunnerConfig { + repo_root: self.cfg.repo_root.clone(), + timeout: self.cfg.run_timeout, + model_revision: snap.index.revision().to_string(), + }, + }; + let report = runner.run_case(&case)?; + Ok(VerificationOutcome { + revisions: Revisions { + model_revision: report.revisions.model_revision.clone(), + implementation_revision: report.revisions.implementation_revision.clone(), + }, + case_id: report.case_id, + result: report.result, + detail: report.detail, + evidence_keys: report.records.iter().map(|r| r.key()).collect(), + }) + } + + /// Run every declared case whose test target exists, in id order. + pub fn verify_all(&self) -> Result, ServiceError> { + let snap = self.snapshot()?; + let implementation = snap.current.implementation_revision.clone(); + let artifacts = DirResolver::new(&self.cfg.repo_root); + let mut out = Vec::new(); + for case in cases_from_graph(snap.graph()) { + let runnable = + crate::assurance_trace::cargo_test_target(&case.argv).is_some_and(|(pkg, test)| { + artifacts.exists(&format!("crates/{pkg}/tests/{test}.rs")) + }); + if runnable { + out.push(self.run_verification(&case.id, &implementation)?); + } + } + Ok(out) + } + + /// Which node kinds the baseline defines, for diagnostics. + pub fn node_kinds(snapshot: &Snapshot) -> Vec<(NodeKind, usize)> { + NodeKind::ALL + .iter() + .map(|k| { + let n = snapshot + .graph() + .requirements + .iter() + .filter(|r| { + r.attributes.get(NODE_KIND_ATTR).map(String::as_str) == Some(k.as_str()) + }) + .count(); + (*k, n) + }) + .collect() + } +} diff --git a/crates/kr0ki-core/src/assurance_trace.rs b/crates/kr0ki-core/src/assurance_trace.rs new file mode 100644 index 0000000..e1444e3 --- /dev/null +++ b/crates/kr0ki-core/src/assurance_trace.rs @@ -0,0 +1,589 @@ +//! KR-A02: resolve the links of an assurance thread against a **specified model revision**. +//! +//! A link is only as good as the revision it was checked at: an element that exists at one +//! commit can be gone at the next. Resolution therefore always names the revision it ran +//! against ([`LinkResolution::revision`]) and reports every unresolved link as *dangling* +//! with the reason, instead of assuming it resolves. +//! +//! Three classes of link are resolved, each against the right authority: +//! +//! | class | what it links | resolved against | +//! |---|---|---| +//! | [`LinkClass::Element`] | requirement β†’ satisfying system element | a [`ModelRevisionIndex`] (model revision) | +//! | [`LinkClass::Implementation`] | requirement β†’ enforcing control's implementation path | the repository ([`ArtifactResolver`]) | +//! | [`LinkClass::VerificationTarget`] | requirement β†’ its verification case's test target | the repository ([`ArtifactResolver`]) | +//! +//! A control that records *no* implementation is not a dangling link β€” it is an implementation +//! gap, reported by `ufo_types::mbse::assurance::analyze`. Dangling means a link was *made* and +//! does not resolve. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use kr0ki_sysmlv2_client::ModelSnapshot; +use serde::Serialize; +use ufo_types::mbse::assurance::{NodeKind, NODE_KIND_ATTR}; +use ufo_types::mbse::requirements::{ + RelationAuthority, Requirement, RequirementGraph, RequirementRelationKind, +}; + +use crate::assurance_baseline::sha256_hex; + +// ── Model revision index ──────────────────────────────────────────────────────── + +/// The element locators (qualified names and ids) that exist at one model revision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelRevisionIndex { + revision: String, + locators: BTreeSet, +} + +impl ModelRevisionIndex { + pub fn new(revision: impl Into, locators: impl IntoIterator) -> Self { + Self { + revision: revision.into(), + locators: locators.into_iter().collect(), + } + } + + /// The index of a model-server snapshot. The revision is the commit id; locators are each + /// element's id, `qualifiedName` and `name` (a name alone is only unambiguous for a + /// root-level element, which is why qualified names are indexed too). + pub fn from_snapshot(snapshot: &ModelSnapshot) -> Self { + let mut locators = BTreeSet::new(); + for element in &snapshot.elements { + locators.insert(element.id().to_string()); + if let Some(name) = element.name() { + locators.insert(name.to_string()); + } + if let Some(qualified) = element.get("qualifiedName").and_then(|v| v.as_str()) { + locators.insert(qualified.to_string()); + } + } + Self { + revision: snapshot.commit_id.clone(), + locators, + } + } + + /// The index of a SysML v2 text, content-addressed: its revision is `sha256:`. + /// + /// This is a *declaration scanner*, not a parser: it reads the names of declarations + /// (`package P`, `part def D`, `part u : T`, …) and their nesting. It is exact for the + /// plain declaration forms the assurance model uses and is checked against the real + /// parser in `tests/assurance_a02.rs`. For an authoritative index of a model that lives on + /// a server, use [`Self::from_snapshot`]. + pub fn from_sysml_text(text: &str) -> Self { + Self { + revision: format!("sha256:{}", sha256_hex(text.as_bytes())), + locators: scan_declarations(text).into_iter().collect(), + } + } + + pub fn revision(&self) -> &str { + &self.revision + } + + pub fn contains(&self, locator: &str) -> bool { + self.locators.contains(locator) + } + + pub fn locators(&self) -> &BTreeSet { + &self.locators + } +} + +/// Declaration keywords whose next identifier names an element. +const DECLARATION_KEYWORDS: &[&str] = &[ + "package", + "part", + "port", + "item", + "attribute", + "action", + "state", + "requirement", + "constraint", + "connection", + "interface", + "view", + "viewpoint", + "rendering", + "occurrence", + "enum", + "calc", + "case", + "analysis", + "verification", + "concern", + "allocation", + "flow", + "metadata", + "use", + "individual", + "snapshot", + "timeslice", +]; + +/// Qualified names of the declarations in `text`, `::`-joined by nesting. +fn scan_declarations(text: &str) -> Vec { + #[derive(Debug, PartialEq)] + enum Tok { + Word(String), + Open, + Close, + Semi, + Other, + } + + // Tokenise: drop comments and strings; keep identifiers (bare or 'quoted'), braces, `;`. + let mut toks = Vec::new(); + let b = text.as_bytes(); + let mut i = 0; + while i < b.len() { + match b[i] { + b'/' if b.get(i + 1) == Some(&b'/') => { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } + b'/' if b.get(i + 1) == Some(&b'*') => { + i += 2; + while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') { + i += 1; + } + i += 2; + } + b'"' => { + i += 1; + while i < b.len() && b[i] != b'"' { + if b[i] == b'\\' { + i += 1; + } + i += 1; + } + i += 1; + toks.push(Tok::Other); + } + b'\'' => { + let start = i + 1; + i += 1; + while i < b.len() && b[i] != b'\'' { + i += 1; + } + toks.push(Tok::Word(text[start..i.min(b.len())].to_string())); + i += 1; + } + b'{' => { + toks.push(Tok::Open); + i += 1; + } + b'}' => { + toks.push(Tok::Close); + i += 1; + } + b';' => { + toks.push(Tok::Semi); + i += 1; + } + c if c.is_ascii_whitespace() => i += 1, + c if c.is_ascii_alphabetic() || c == b'_' => { + let start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') { + i += 1; + } + toks.push(Tok::Word(text[start..i].to_string())); + } + _ => { + toks.push(Tok::Other); + i += 1; + } + } + } + + let mut names = Vec::new(); + let mut stack: Vec> = Vec::new(); + // The element whose body the next `{` opens, if any. + let mut pending: Option = None; + let qualified = |stack: &[Option], name: &str| { + let mut parts: Vec<&str> = stack.iter().flatten().map(String::as_str).collect(); + parts.push(name); + parts.join("::") + }; + let mut k = 0; + while k < toks.len() { + match &toks[k] { + Tok::Word(w) if DECLARATION_KEYWORDS.contains(&w.as_str()) => { + let mut j = k + 1; + if matches!(toks.get(j), Some(Tok::Word(d)) if d == "def") { + j += 1; + } + if let Some(Tok::Word(name)) = toks.get(j) { + // `part x`, not `part : T` or `part def :> X`; and not a following keyword. + if !DECLARATION_KEYWORDS.contains(&name.as_str()) && name != "def" { + let q = qualified(&stack, name); + names.push(q.clone()); + pending = Some(name.clone()); + k = j; + } + } + } + Tok::Open => { + stack.push(pending.take()); + } + Tok::Close => { + stack.pop(); + pending = None; + } + Tok::Semi => pending = None, + _ => {} + } + k += 1; + } + names +} + +// ── Artifact resolution ───────────────────────────────────────────────────────── + +/// Resolves repository-relative paths. +pub trait ArtifactResolver { + fn exists(&self, repo_path: &str) -> bool; +} + +/// Resolves paths under a repository root. Absolute paths and `..` are refused: a link must +/// point inside the repository. +#[derive(Debug, Clone)] +pub struct DirResolver { + root: PathBuf, +} + +impl DirResolver { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl ArtifactResolver for DirResolver { + fn exists(&self, repo_path: &str) -> bool { + let p = std::path::Path::new(repo_path); + if p.is_absolute() + || p.components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return false; + } + self.root.join(p).is_file() + } +} + +/// `cargo test -p --test ` β†’ the package and test target. +/// The only command shape a verification case may declare (see `verification_runner`). +pub fn cargo_test_target(argv: &[String]) -> Option<(String, String)> { + let a: Vec<&str> = argv.iter().map(String::as_str).collect(); + match a.as_slice() { + ["cargo", "test", "-p", package, "--test", test] if is_ident(package) && is_ident(test) => { + Some(((*package).to_string(), (*test).to_string())) + } + _ => None, + } +} + +fn is_ident(s: &str) -> bool { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +// ── Resolution ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LinkClass { + Element, + Implementation, + VerificationTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum LinkStatus { + Resolved, + Dangling { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResolvedLink { + pub requirement_id: String, + pub class: LinkClass, + /// The thread node the link goes through (element, control or case id). + pub via: String, + /// What was looked up: a qualified name or a repository path. + pub locator: String, + #[serde(flatten)] + pub status: LinkStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LinkResolution { + /// The model revision the element links were resolved against. + pub revision: String, + pub links: Vec, +} + +impl LinkResolution { + pub fn dangling(&self) -> impl Iterator { + self.links + .iter() + .filter(|l| matches!(l.status, LinkStatus::Dangling { .. })) + } + + pub fn for_requirement<'a>(&'a self, id: &'a str) -> impl Iterator { + self.links.iter().filter(move |l| l.requirement_id == id) + } +} + +fn node<'a>(graph: &'a RequirementGraph, id: &str) -> Option<&'a Requirement> { + graph.requirements.iter().find(|n| n.id == id) +} + +fn kind_of(n: &Requirement) -> Option { + n.attributes + .get(NODE_KIND_ATTR) + .and_then(|k| NodeKind::parse(k)) +} + +/// Resolve every link of every requirement in `graph`. +/// +/// Only asserted edges are followed. Output order is deterministic: by requirement id, then +/// class, then the node id. +pub fn resolve_links( + graph: &RequirementGraph, + index: &ModelRevisionIndex, + artifacts: &dyn ArtifactResolver, +) -> LinkResolution { + let asserted = |kind: RequirementRelationKind, target: &str, want: NodeKind| { + let mut ids: Vec<&str> = graph + .relations + .iter() + .filter(|r| { + matches!(r.authority, RelationAuthority::Asserted) + && r.kind == kind + && r.target == target + }) + .map(|r| r.source.as_str()) + .filter(|s| node(graph, s).and_then(kind_of) == Some(want)) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + + let mut requirement_ids: Vec<&str> = graph + .requirements + .iter() + .filter(|n| kind_of(n) == Some(NodeKind::Requirement)) + .map(|n| n.id.as_str()) + .collect(); + requirement_ids.sort_unstable(); + + let mut links = Vec::new(); + for rid in requirement_ids { + // Element links, against the model revision. + for eid in asserted( + RequirementRelationKind::Satisfies, + rid, + NodeKind::SystemElement, + ) { + let n = node(graph, eid).expect("filtered to existing nodes"); + let locator = n + .attributes + .get("qualified_name") + .cloned() + .unwrap_or_else(|| eid.into()); + let status = if index.contains(&locator) { + LinkStatus::Resolved + } else { + LinkStatus::Dangling { + reason: format!( + "`{locator}` does not exist at model revision {}", + index.revision() + ), + } + }; + links.push(ResolvedLink { + requirement_id: rid.into(), + class: LinkClass::Element, + via: eid.into(), + locator, + status, + }); + } + // Implementation links, against the repository. + for cid in asserted(RequirementRelationKind::Implements, rid, NodeKind::Control) { + let n = node(graph, cid).expect("filtered to existing nodes"); + let recorded = n + .attributes + .get("implementation") + .map(String::as_str) + .unwrap_or(""); + for path in recorded.split(';').map(str::trim).filter(|p| !p.is_empty()) { + let status = if artifacts.exists(path) { + LinkStatus::Resolved + } else { + LinkStatus::Dangling { + reason: format!("`{path}` is not a file in the repository"), + } + }; + links.push(ResolvedLink { + requirement_id: rid.into(), + class: LinkClass::Implementation, + via: cid.into(), + locator: path.into(), + status, + }); + } + } + // Verification targets, against the repository. + for vid in asserted( + RequirementRelationKind::Verifies, + rid, + NodeKind::VerificationCase, + ) { + let n = node(graph, vid).expect("filtered to existing nodes"); + let argv: Vec = n + .attributes + .get("command") + .and_then(|c| serde_json::from_str(c).ok()) + .unwrap_or_default(); + let (locator, status) = match cargo_test_target(&argv) { + Some((package, test)) => { + let path = format!("crates/{package}/tests/{test}.rs"); + let status = if artifacts.exists(&path) { + LinkStatus::Resolved + } else { + LinkStatus::Dangling { + reason: format!("test target `{path}` does not exist"), + } + }; + (path, status) + } + None => ( + argv.join(" "), + LinkStatus::Dangling { + reason: "the command is not `cargo test -p --test `".into(), + }, + ), + }; + links.push(ResolvedLink { + requirement_id: rid.into(), + class: LinkClass::VerificationTarget, + via: vid.into(), + locator, + status, + }); + } + } + LinkResolution { + revision: index.revision().to_string(), + links, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scanner_reads_nested_declarations_and_ignores_comments_and_strings() { + let text = r#" + // part def Commented; + /* part def AlsoCommented; */ + package P { + part def A; + part def B { + part inner : A; + attribute label = "part def NotAThing"; + } + part 'quoted name' : A; + } + part def TopLevel; + "#; + let names = scan_declarations(text); + assert_eq!( + names, + vec![ + "P", + "P::A", + "P::B", + "P::B::inner", + "P::B::label", + "P::quoted name", + "TopLevel" + ] + ); + } + + #[test] + fn scanner_does_not_name_anonymous_usages_but_still_tracks_their_scope() { + let names = scan_declarations("package P { part : T { part x; } part y; }"); + assert_eq!(names, vec!["P", "P::x", "P::y"]); + } + + #[test] + fn revision_is_content_addressed() { + let a = ModelRevisionIndex::from_sysml_text("package P { part def A; }"); + let b = ModelRevisionIndex::from_sysml_text("package P { part def A; }"); + let c = ModelRevisionIndex::from_sysml_text("package P { part def B; }"); + assert_eq!(a.revision(), b.revision()); + assert_ne!(a.revision(), c.revision()); + assert!(a.contains("P::A") && !a.contains("P::B")); + assert!(a.revision().starts_with("sha256:")); + } + + #[test] + fn cargo_test_target_accepts_only_the_declared_shape() { + let ok: Vec = [ + "cargo", + "test", + "-p", + "kr0ki-core", + "--test", + "assurance_a01", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!( + cargo_test_target(&ok), + Some(("kr0ki-core".to_string(), "assurance_a01".to_string())) + ); + for bad in [ + vec!["sh", "-c", "rm -rf /"], + vec!["cargo", "test", "-p", "kr0ki-core", "--test", "a; rm -rf /"], + vec!["cargo", "test", "-p", "../evil", "--test", "x"], + vec!["cargo", "test", "--workspace"], + vec![ + "cargo", + "test", + "-p", + "kr0ki-core", + "--test", + "x", + "--", + "--nocapture", + ], + ] { + let argv: Vec = bad.iter().map(|s| s.to_string()).collect(); + assert_eq!(cargo_test_target(&argv), None, "{bad:?}"); + } + } + + #[test] + fn dir_resolver_refuses_paths_outside_the_repository() { + let r = DirResolver::new(env!("CARGO_MANIFEST_DIR")); + assert!(r.exists("Cargo.toml")); + assert!(!r.exists("../../Cargo.toml")); + assert!(!r.exists("/etc/passwd")); + assert!(!r.exists("src"), "a directory is not a file artifact"); + } +} diff --git a/crates/kr0ki-core/src/assurance_view.rs b/crates/kr0ki-core/src/assurance_view.rs new file mode 100644 index 0000000..0cd0512 --- /dev/null +++ b/crates/kr0ki-core/src/assurance_view.rs @@ -0,0 +1,304 @@ +//! KR-A03: the requirements view that **distinguishes satisfaction assertions from +//! verification results**. +//! +//! The pre-existing `VerificationCoverage` view counts any `verifies` edge to evidence as +//! covered. That conflates two different claims, so this view never collapses them: +//! +//! - **satisfaction** is an architectural assertion (`system_element β€”satisfiesβ†’ requirement`); +//! it is drawn as a *dashed* edge and says nothing about whether a case ran; +//! - **verification** is revision-bound evidence; it is drawn as an evidence node carrying the +//! result and whether it is still fresh at the current revisions. +//! +//! A requirement is therefore exactly one of [`Assurance::Unsatisfied`], +//! [`Assurance::SatisfiedUntested`], [`Assurance::Verified`], [`Assurance::Failing`], +//! [`Assurance::Stale`], and each has its own fill colour and label in the diagram and its own +//! column values in the table. The computation is `ufo_types::mbse::assurance::analyze`; this +//! module only presents it. + +use std::collections::BTreeMap; + +use serde::Serialize; +use ufo_types::mbse::assurance::{ + analyze, Assurance, CurrentRevisions, EvidenceRecord, Freshness, NodeKind, ThreadError, + ThreadReport, VerificationResult, NODE_KIND_ATTR, +}; +use ufo_types::mbse::requirements::{RelationAuthority, Requirement, RequirementGraph}; + +use crate::b00t_graph::{d2_escape_label, d2_quote}; + +/// One requirement's row: both axes, side by side. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Row { + pub requirement_id: String, + pub statement: String, + /// Satisfaction axis: the elements asserted to satisfy it (empty = nothing asserts it). + pub satisfied_by: Vec, + /// Verification axis, derived from evidence at the current revisions. + pub verification: VerificationAxis, + pub assurance: Assurance, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum VerificationAxis { + /// No evidence exists for any linked case. + NoEvidence, + /// Evidence exists; the newest fresh result decides, otherwise it is stale. + Evidence { + fresh_pass: usize, + fresh_fail: usize, + stale: usize, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AssuranceView { + pub revisions: CurrentRevisions, + pub rows: Vec, + pub report: ThreadReport, +} + +impl AssuranceView { + pub fn row(&self, id: &str) -> Option<&Row> { + self.rows.iter().find(|r| r.requirement_id == id) + } + + /// Requirements per assurance state, for a one-line summary. + pub fn counts(&self) -> BTreeMap<&'static str, usize> { + let mut out = BTreeMap::new(); + for r in &self.rows { + *out.entry(state_name(r.assurance)).or_insert(0) += 1; + } + out + } +} + +pub fn state_name(a: Assurance) -> &'static str { + match a { + Assurance::Unsatisfied => "unsatisfied", + Assurance::SatisfiedUntested => "satisfied_untested", + Assurance::Verified => "verified", + Assurance::Failing => "failing", + Assurance::Stale => "stale", + } +} + +fn label(a: Assurance) -> &'static str { + match a { + Assurance::Unsatisfied => "not satisfied", + Assurance::SatisfiedUntested => "satisfied Β· untested", + Assurance::Verified => "verified", + Assurance::Failing => "FAILING", + Assurance::Stale => "satisfied Β· result stale", + } +} + +fn fill(a: Assurance) -> &'static str { + match a { + Assurance::Unsatisfied => "#e0e0e0", + Assurance::SatisfiedUntested => "#ffe9a8", + Assurance::Verified => "#b7ebc6", + Assurance::Failing => "#f6b3b3", + Assurance::Stale => "#ffd2a1", + } +} + +/// Build the view: analyse the graph against `evidence` at the `current` revisions. +pub fn assurance_view( + graph: &RequirementGraph, + evidence: &[EvidenceRecord], + current: &CurrentRevisions, + known_elements: Option<&std::collections::BTreeSet>, +) -> Result { + let report = analyze(graph, evidence, current, known_elements)?; + let statements: BTreeMap<&str, &str> = graph + .requirements + .iter() + .map(|n| (n.id.as_str(), n.text.as_str())) + .collect(); + let rows = report + .requirements + .iter() + .map(|t| { + let (mut fresh_pass, mut fresh_fail, mut stale) = (0, 0, 0); + for e in &t.evidence { + match (e.freshness, e.result) { + (Freshness::Stale { .. }, _) => stale += 1, + (Freshness::Fresh, VerificationResult::Pass) => fresh_pass += 1, + (Freshness::Fresh, _) => fresh_fail += 1, + } + } + Row { + requirement_id: t.requirement_id.clone(), + statement: statements + .get(t.requirement_id.as_str()) + .copied() + .unwrap_or("") + .to_string(), + satisfied_by: t.satisfied_by.clone(), + verification: if t.evidence.is_empty() { + VerificationAxis::NoEvidence + } else { + VerificationAxis::Evidence { + fresh_pass, + fresh_fail, + stale, + } + }, + assurance: t.assurance, + } + }) + .collect(); + Ok(AssuranceView { + revisions: current.clone(), + rows, + report, + }) +} + +fn kind(n: &Requirement) -> Option { + n.attributes + .get(NODE_KIND_ATTR) + .and_then(|k| NodeKind::parse(k)) +} + +/// Deterministic D2 source. Satisfaction edges are dashed; evidence is its own node. +pub fn to_d2(graph: &RequirementGraph, view: &AssuranceView) -> String { + let mut out = String::new(); + out.push_str("direction: right\n"); + out.push_str(&format!( + "# model {} Β· implementation {} Β· {} case configuration(s)\n", + view.revisions.model_revision, + view.revisions.implementation_revision, + view.revisions.configuration_digests.len() + )); + for node in &graph.requirements { + let id = d2_quote(&node.id); + match kind(node) { + Some(NodeKind::Requirement) => { + let a = view + .row(&node.id) + .map(|r| r.assurance) + .unwrap_or(Assurance::Unsatisfied); + out.push_str(&format!( + "{id}: {} {{ shape: rectangle; style.fill: \"{}\" }}\n", + d2_escape_label(&format!("{} Β· [{}]", node.title, label(a))), + fill(a) + )); + } + Some(NodeKind::SourceObligation) => out.push_str(&format!( + "{id}: {} {{ shape: page }}\n", + d2_escape_label(&node.title) + )), + Some(NodeKind::SystemElement) => out.push_str(&format!( + "{id}: {} {{ shape: package }}\n", + d2_escape_label(&node.title) + )), + Some(NodeKind::Control) => out.push_str(&format!( + "{id}: {} {{ shape: hexagon }}\n", + d2_escape_label(&node.title) + )), + Some(NodeKind::VerificationCase) => out.push_str(&format!( + "{id}: {} {{ shape: diamond }}\n", + d2_escape_label(&node.title) + )), + None => out.push_str(&format!("{id}: {}\n", d2_escape_label(&node.title))), + } + } + // Evidence nodes: one per assessed record, carrying result and freshness. + for t in &view.report.requirements { + for e in &t.evidence { + let ev = d2_quote(&e.key); + let (result, colour) = match e.result { + VerificationResult::Pass => ("pass", "#b7ebc6"), + VerificationResult::Fail => ("fail", "#f6b3b3"), + VerificationResult::Error => ("error", "#f6b3b3"), + }; + let fresh = match e.freshness { + Freshness::Fresh => "fresh", + Freshness::Stale { .. } => "STALE", + }; + let fill = if matches!(e.freshness, Freshness::Stale { .. }) { + "#ffd2a1" + } else { + colour + }; + out.push_str(&format!( + "{ev}: {} {{ shape: cloud; style.fill: \"{fill}\" }}\n", + d2_escape_label(&format!("{} Β· {result} Β· {fresh}", e.verification_id)) + )); + out.push_str(&format!( + "{} -> {ev}: produced\n{ev} -> {}: verification result\n", + d2_quote(&e.verification_id), + d2_quote(&t.requirement_id) + )); + } + } + for r in graph + .relations + .iter() + .filter(|r| matches!(r.authority, RelationAuthority::Asserted)) + { + let (text, dashed) = match r.kind { + ufo_types::mbse::requirements::RequirementRelationKind::Satisfies => { + ("satisfies (assertion)", true) + } + ufo_types::mbse::requirements::RequirementRelationKind::Derives => ("derives", false), + ufo_types::mbse::requirements::RequirementRelationKind::Implements => { + ("enforces", false) + } + ufo_types::mbse::requirements::RequirementRelationKind::AllocatedTo => { + ("lives in", false) + } + ufo_types::mbse::requirements::RequirementRelationKind::Verifies => { + ("acceptance case", false) + } + _ => continue, + }; + if dashed { + out.push_str(&format!( + "{} -> {}: {text} {{ style.stroke-dash: 4 }}\n", + d2_quote(&r.source), + d2_quote(&r.target) + )); + } else { + out.push_str(&format!( + "{} -> {}: {text}\n", + d2_quote(&r.source), + d2_quote(&r.target) + )); + } + } + out +} + +/// A plain-text table with the two axes in separate columns β€” for terminals, agents and logs. +pub fn to_table(view: &AssuranceView) -> String { + let mut out = + String::from("requirement | satisfaction (asserted) | verification (evidence) | state\n"); + for r in &view.rows { + let satisfaction = if r.satisfied_by.is_empty() { + "none".to_string() + } else { + r.satisfied_by.join(", ") + }; + let verification = match &r.verification { + VerificationAxis::NoEvidence => "no evidence".to_string(), + VerificationAxis::Evidence { + fresh_pass, + fresh_fail, + stale, + } => { + format!("{fresh_pass} fresh pass, {fresh_fail} fresh fail, {stale} stale") + } + }; + out.push_str(&format!( + "{} | {} | {} | {}\n", + r.requirement_id, + satisfaction, + verification, + label(r.assurance) + )); + } + out +} diff --git a/crates/kr0ki-core/src/audit_log.rs b/crates/kr0ki-core/src/audit_log.rs new file mode 100644 index 0000000..ccdf7e5 --- /dev/null +++ b/crates/kr0ki-core/src/audit_log.rs @@ -0,0 +1,589 @@ +//! KR-A08: the audit log β€” one record per tool invocation, permitted **or denied**, with the +//! caller, the operation, the decision, the model revision and a correlation identifier. +//! +//! The log is what a reviewer reads to learn what an agent did and what it was refused, so it +//! must not depend on the agent's own account: +//! +//! - **Append-only JSONL**, one record per line, fsynced before the call returns. +//! - **Hash-chained.** Each record carries `prev_hash`, the SHA-256 of the previous line, so an +//! edited, deleted or reordered record breaks the chain at a known sequence number +//! ([`AuditLog::verify`]). That is tamper-*evident*, not tamper-proof: someone who can +//! rewrite the whole file can rewrite the chain, so the file belongs on storage the workload +//! cannot write (KR-A07). +//! - **Written before the operation runs** (phase [`Phase::Decision`]); the status of what +//! happened is a second record with the same correlation id ([`Phase::Outcome`]). A crash in +//! between still leaves the decision on record. +//! - **No secrets.** Records hold the path (never the query string), the caller's *identity +//! id* (never a token), and the revision. Request and response bodies are not recorded. +//! +//! Timestamps are operational wall-clock (`at`): this is an audit trail of when things +//! happened, unlike the modelled-time fields in `ufo-types`, which never read a clock. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::assurance_baseline::sha256_hex; + +pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + /// Written before the operation runs: who asked for what, and the decision. + Decision, + /// Written after: what status the operation produced. + Outcome, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Decision { + Permit, + Deny, +} + +/// What the caller of [`AuditLog::append`] supplies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditDraft { + pub correlation_id: String, + pub phase: Phase, + /// Identity id, or `unauthenticated`. + pub caller: String, + /// The operation's name (e.g. `commit_change`). + pub operation: String, + pub method: String, + /// Path only β€” never the query string. + pub path: String, + pub decision: Decision, + pub reason: Option, + pub model_revision: Option, + /// What the caller *claims* its transport is (`http`, `mcp`). Informational: nothing is + /// authorised on it. + pub transport: String, + pub status: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditRecord { + pub seq: u64, + /// RFC 3339, UTC. + pub at: String, + pub correlation_id: String, + pub phase: Phase, + pub caller: String, + pub operation: String, + pub method: String, + pub path: String, + pub decision: Decision, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_revision: Option, + pub transport: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// SHA-256 of the previous record's line (`GENESIS_HASH` for the first). + pub prev_hash: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum AuditError { + #[error("audit log I/O at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("audit log {path} line {line}: {message}")] + Malformed { + path: PathBuf, + line: usize, + message: String, + }, + #[error("the audit log is unavailable: a previous write failed")] + Poisoned, +} + +/// Where the chain stops being valid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChainBreak { + pub line: usize, + pub seq: Option, + pub reason: String, +} + +struct Tail { + next_seq: u64, + last_hash: String, + healthy: bool, +} + +pub struct AuditLog { + path: PathBuf, + tail: Mutex, +} + +/// Filter for [`AuditLog::query`]; every set field must match. +#[derive(Debug, Clone, Default)] +pub struct AuditQuery { + pub caller: Option, + pub operation: Option, + pub decision: Option, + pub correlation_id: Option, + pub phase: Option, +} + +impl AuditLog { + /// Open (creating the parent directory) and resume the chain from the existing file's tail. + pub fn open(path: impl Into) -> Result { + let path = path.into(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| AuditError::Io { + path: parent.to_path_buf(), + source, + })?; + } + let (next_seq, last_hash) = match read_lines(&path)? { + lines if lines.is_empty() => (0, GENESIS_HASH.to_string()), + lines => { + let last = lines.last().expect("non-empty"); + let record: AuditRecord = + serde_json::from_str(last).map_err(|e| AuditError::Malformed { + path: path.clone(), + line: lines.len(), + message: e.to_string(), + })?; + (record.seq + 1, sha256_hex(last.as_bytes())) + } + }; + Ok(Self { + path, + tail: Mutex::new(Tail { + next_seq, + last_hash, + healthy: true, + }), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Append one record and fsync it. After a failed write the log refuses further appends + /// (the caller fails closed) rather than risk a gap that looks like tampering. + pub fn append(&self, draft: AuditDraft, at: &str) -> Result { + let mut tail = self.tail.lock().expect("audit tail"); + if !tail.healthy { + return Err(AuditError::Poisoned); + } + let record = AuditRecord { + seq: tail.next_seq, + at: at.to_string(), + correlation_id: draft.correlation_id, + phase: draft.phase, + caller: draft.caller, + operation: draft.operation, + method: draft.method, + path: draft.path, + decision: draft.decision, + reason: draft.reason, + model_revision: draft.model_revision, + transport: draft.transport, + status: draft.status, + prev_hash: tail.last_hash.clone(), + }; + let line = serde_json::to_string(&record).expect("an AuditRecord serializes"); + let write = || -> io::Result<()> { + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + f.write_all(line.as_bytes())?; + f.write_all(b"\n")?; + f.sync_data() + }; + if let Err(source) = write() { + tail.healthy = false; + return Err(AuditError::Io { + path: self.path.clone(), + source, + }); + } + tail.next_seq += 1; + tail.last_hash = sha256_hex(line.as_bytes()); + Ok(record) + } + + /// Every record, in order. + pub fn read_all(&self) -> Result, AuditError> { + read_lines(&self.path)? + .iter() + .enumerate() + .map(|(i, l)| { + serde_json::from_str(l).map_err(|e| AuditError::Malformed { + path: self.path.clone(), + line: i + 1, + message: e.to_string(), + }) + }) + .collect() + } + + pub fn query(&self, q: &AuditQuery) -> Result, AuditError> { + Ok(self + .read_all()? + .into_iter() + .filter(|r| q.caller.as_ref().is_none_or(|c| &r.caller == c)) + .filter(|r| q.operation.as_ref().is_none_or(|o| &r.operation == o)) + .filter(|r| q.decision.is_none_or(|d| r.decision == d)) + .filter(|r| { + q.correlation_id + .as_ref() + .is_none_or(|c| &r.correlation_id == c) + }) + .filter(|r| q.phase.is_none_or(|p| r.phase == p)) + .collect()) + } + + /// Re-walk the chain. `Ok(n)` is the number of records verified. + pub fn verify(&self) -> Result, AuditError> { + let lines = read_lines(&self.path)?; + let mut expected_prev = GENESIS_HASH.to_string(); + for (i, line) in lines.iter().enumerate() { + let record: AuditRecord = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => { + return Ok(Err(ChainBreak { + line: i + 1, + seq: None, + reason: format!("not a record: {e}"), + })); + } + }; + if record.seq != i as u64 { + return Ok(Err(ChainBreak { + line: i + 1, + seq: Some(record.seq), + reason: format!( + "sequence {} where {} was expected (a record was removed or reordered)", + record.seq, i + ), + })); + } + if record.prev_hash != expected_prev { + return Ok(Err(ChainBreak { + line: i + 1, + seq: Some(record.seq), + reason: "prev_hash does not match the previous record (a record was edited or removed)".into(), + })); + } + expected_prev = sha256_hex(line.as_bytes()); + } + Ok(Ok(lines.len())) + } +} + +fn read_lines(path: &Path) -> Result, AuditError> { + let file = match File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => { + return Err(AuditError::Io { + path: path.to_path_buf(), + source, + }) + } + }; + BufReader::new(file) + .lines() + .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true)) + .collect::, _>>() + .map_err(|source| AuditError::Io { + path: path.to_path_buf(), + source, + }) +} + +/// `now` as RFC 3339 UTC (`2026-10-02T14:03:07Z`). +pub fn now_rfc3339() -> String { + rfc3339( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ) +} + +/// Seconds since the Unix epoch as RFC 3339 UTC. +pub fn rfc3339(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let rem = secs % 86_400; + // Civil-from-days (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", + rem / 3600, + (rem % 3600) / 60, + rem % 60 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(tag: &str) -> PathBuf { + let p = std::env::temp_dir() + .join(format!("kr0ki-audit-{tag}-{}", std::process::id())) + .join("audit.jsonl"); + let _ = fs::remove_dir_all(p.parent().unwrap()); + p + } + + fn draft(corr: &str, caller: &str, op: &str, decision: Decision, phase: Phase) -> AuditDraft { + AuditDraft { + correlation_id: corr.into(), + phase, + caller: caller.into(), + operation: op.into(), + method: "POST".into(), + path: "/x".into(), + decision, + reason: (decision == Decision::Deny).then(|| "no grant".to_string()), + model_revision: Some("rev-1".into()), + transport: "http".into(), + status: None, + } + } + + #[test] + fn rfc3339_matches_known_instants() { + assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z"); + assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z"); // a leap day + assert_eq!(rfc3339(1_790_908_026), "2026-10-02T02:27:06Z"); // matches the pilot's created stamp + assert_eq!(rfc3339(4_102_444_799), "2099-12-31T23:59:59Z"); + assert!(now_rfc3339().ends_with('Z') && now_rfc3339().len() == 20); + } + + #[test] + fn records_are_appended_in_order_and_chained() { + let log = AuditLog::open(path("chain")).unwrap(); + let a = log + .append( + draft( + "c1", + "reader", + "get_requirement", + Decision::Permit, + Phase::Decision, + ), + "t1", + ) + .unwrap(); + let b = log + .append( + draft( + "c2", + "reader", + "commit_change", + Decision::Deny, + Phase::Decision, + ), + "t2", + ) + .unwrap(); + assert_eq!((a.seq, b.seq), (0, 1)); + assert_eq!(a.prev_hash, GENESIS_HASH); + assert_ne!(b.prev_hash, GENESIS_HASH); + assert_eq!(log.verify().unwrap(), Ok(2)); + assert_eq!(log.read_all().unwrap(), vec![a, b]); + } + + #[test] + fn the_chain_survives_a_restart() { + let p = path("restart"); + { + let log = AuditLog::open(&p).unwrap(); + log.append( + draft("c1", "u", "op", Decision::Permit, Phase::Decision), + "t1", + ) + .unwrap(); + } + let log = AuditLog::open(&p).unwrap(); + let r = log + .append( + draft("c2", "u", "op", Decision::Permit, Phase::Decision), + "t2", + ) + .unwrap(); + assert_eq!(r.seq, 1); + assert_eq!(log.verify().unwrap(), Ok(2)); + } + + #[test] + fn editing_removing_or_reordering_a_record_breaks_the_chain_at_a_known_place() { + let p = path("tamper"); + let log = AuditLog::open(&p).unwrap(); + for i in 0..4 { + log.append( + draft( + &format!("c{i}"), + "u", + "op", + Decision::Permit, + Phase::Decision, + ), + "t", + ) + .unwrap(); + } + let original = fs::read_to_string(&p).unwrap(); + let lines: Vec<&str> = original.lines().collect(); + + // edit record 1's caller + let edited = original.replacen("\"caller\":\"u\"", "\"caller\":\"admin\"", 2); + fs::write(&p, &edited).unwrap(); + let broken = AuditLog::open(&p).unwrap().verify().unwrap().unwrap_err(); + assert!(broken.line >= 2, "{broken:?}"); + + // delete a middle record + fs::write(&p, format!("{}\n{}\n{}\n", lines[0], lines[2], lines[3])).unwrap(); + assert_eq!( + AuditLog::open(&p) + .unwrap() + .verify() + .unwrap() + .unwrap_err() + .line, + 2 + ); + + // reorder + fs::write( + &p, + format!("{}\n{}\n{}\n{}\n", lines[0], lines[2], lines[1], lines[3]), + ) + .unwrap(); + assert!(AuditLog::open(&p).unwrap().verify().unwrap().is_err()); + + // restored: valid again + fs::write(&p, &original).unwrap(); + assert_eq!(AuditLog::open(&p).unwrap().verify().unwrap(), Ok(4)); + } + + #[test] + fn query_filters_by_caller_decision_and_correlation() { + let log = AuditLog::open(path("query")).unwrap(); + log.append( + draft( + "c1", + "reader", + "get_requirement", + Decision::Permit, + Phase::Decision, + ), + "t", + ) + .unwrap(); + log.append( + draft( + "c2", + "reader", + "commit_change", + Decision::Deny, + Phase::Decision, + ), + "t", + ) + .unwrap(); + log.append( + draft( + "c2", + "reader", + "commit_change", + Decision::Deny, + Phase::Outcome, + ), + "t", + ) + .unwrap(); + log.append( + draft( + "c3", + "committer", + "commit_change", + Decision::Permit, + Phase::Decision, + ), + "t", + ) + .unwrap(); + let q = |f: &dyn Fn(&mut AuditQuery)| { + let mut q = AuditQuery::default(); + f(&mut q); + log.query(&q).unwrap().len() + }; + assert_eq!(q(&|q| q.caller = Some("reader".into())), 3); + assert_eq!(q(&|q| q.decision = Some(Decision::Deny)), 2); + assert_eq!(q(&|q| q.correlation_id = Some("c2".into())), 2); + assert_eq!( + q(&|q| { + q.operation = Some("commit_change".into()); + q.phase = Some(Phase::Decision); + }), + 2 + ); + assert_eq!(q(&|_| {}), 4); + } + + #[test] + fn a_missing_log_is_empty_and_valid() { + let log = AuditLog::open(path("empty")).unwrap(); + assert_eq!(log.read_all().unwrap(), vec![]); + assert_eq!(log.verify().unwrap(), Ok(0)); + } + + #[test] + fn a_failed_write_poisons_the_log_so_the_caller_fails_closed() { + let p = path("poison"); + let log = AuditLog::open(&p).unwrap(); + log.append( + draft("c1", "u", "op", Decision::Permit, Phase::Decision), + "t", + ) + .unwrap(); + // Make the file unwritable by replacing it with a directory. + fs::remove_file(&p).unwrap(); + fs::create_dir(&p).unwrap(); + assert!(matches!( + log.append( + draft("c2", "u", "op", Decision::Permit, Phase::Decision), + "t" + ), + Err(AuditError::Io { .. }) + )); + assert!(matches!( + log.append( + draft("c3", "u", "op", Decision::Permit, Phase::Decision), + "t" + ), + Err(AuditError::Poisoned) + )); + } +} diff --git a/crates/kr0ki-core/src/bin/kr0ki-assurance.rs b/crates/kr0ki-core/src/bin/kr0ki-assurance.rs new file mode 100644 index 0000000..f9f7c57 --- /dev/null +++ b/crates/kr0ki-core/src/bin/kr0ki-assurance.rs @@ -0,0 +1,297 @@ +//! `kr0ki-assurance` β€” the assurance thread from a shell. A thin front end over +//! `kr0ki_core::assurance_service::AssuranceService`, the same code the HTTP routes and MCP +//! tools call, so a result here is the result there. +//! +//! ```text +//! kr0ki-assurance lint [--strict] load, profile + statement lint, links, ReqIF round trip, gaps +//! kr0ki-assurance verify [CASE-ID ...] run declared cases, store revision-bound evidence +//! kr0ki-assurance view [--format table|d2|json] +//! kr0ki-assurance evidence [--requirement ID] [--case ID] +//! kr0ki-assurance gaps +//! +//! common: --baseline PATH (default docs/assurance/kr0ki.assurance.toml) +//! --repo-root PATH (default .) +//! --evidence-dir PATH (default .kr0ki-evidence) +//! ``` +//! +//! Exit status: 0 success; 1 a defect (load error, ReqIF round trip lost content, a case that +//! did not pass, or under `--strict` a dangling link or lint finding); 2 usage. + +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::Arc; +use std::time::Duration; + +use kr0ki_core::assurance_service::{AssuranceConfig, AssuranceService, RequirementFilter}; +use kr0ki_core::assurance_trace::LinkStatus; +use kr0ki_core::assurance_view::{to_d2, to_table}; +use kr0ki_core::reqif_roundtrip::round_trip; +use kr0ki_core::verification_runner::{GitRevisionProbe, ProcessExecutor}; +use ufo_types::mbse::assurance::VerificationResult; + +struct Args { + command: String, + positional: Vec, + baseline: PathBuf, + repo_root: PathBuf, + evidence_dir: PathBuf, + format: String, + strict: bool, + requirement: Option, + case: Option, +} + +fn parse() -> Result { + let mut it = std::env::args().skip(1); + let command = it.next().ok_or("missing command")?; + let mut a = Args { + command, + positional: Vec::new(), + baseline: PathBuf::from("docs/assurance/kr0ki.assurance.toml"), + repo_root: PathBuf::from("."), + evidence_dir: PathBuf::from(".kr0ki-evidence"), + format: "table".into(), + strict: false, + requirement: None, + case: None, + }; + while let Some(arg) = it.next() { + let mut value = |name: &str| it.next().ok_or_else(|| format!("{name} needs a value")); + match arg.as_str() { + "--baseline" => a.baseline = PathBuf::from(value("--baseline")?), + "--repo-root" => a.repo_root = PathBuf::from(value("--repo-root")?), + "--evidence-dir" => a.evidence_dir = PathBuf::from(value("--evidence-dir")?), + "--format" => a.format = value("--format")?, + "--requirement" => a.requirement = Some(value("--requirement")?), + "--case" => a.case = Some(value("--case")?), + "--strict" => a.strict = true, + flag if flag.starts_with("--") => return Err(format!("unknown flag {flag}")), + other => a.positional.push(other.to_string()), + } + } + Ok(a) +} + +fn service(a: &Args) -> AssuranceService { + let root = a + .repo_root + .canonicalize() + .unwrap_or_else(|_| a.repo_root.clone()); + AssuranceService::new( + AssuranceConfig { + baseline_path: if a.baseline.is_absolute() { + a.baseline.clone() + } else { + root.join(&a.baseline) + }, + repo_root: root.clone(), + evidence_dir: if a.evidence_dir.is_absolute() { + a.evidence_dir.clone() + } else { + root.join(&a.evidence_dir) + }, + run_timeout: Duration::from_secs(1800), + }, + Arc::new(GitRevisionProbe::new(root)), + Arc::new(ProcessExecutor), + ) +} + +fn lint(svc: &AssuranceService, strict: bool) -> Result { + let snap = svc.snapshot().map_err(|e| e.to_string())?; + let mut defects = 0usize; + let mut warnings = 0usize; + + println!( + "baseline {} (model {}, implementation {})", + snap.graph().baseline.id, + snap.current.model_revision, + snap.current.implementation_revision + ); + for (kind, n) in AssuranceService::node_kinds(&snap) { + println!(" {:>2} {}", n, kind.as_str()); + } + + for (id, lint) in &snap.loaded.statement_lints { + println!("lint {id}: {lint}"); + warnings += 1; + } + + let rt = round_trip(snap.graph()).map_err(|e| e.to_string())?; + println!( + "reqif {} nodes, {} attributes, {} relations compared; {}", + rt.report.requirements_compared, + rt.report.attributes_compared, + rt.report.relations_compared, + if rt.report.is_lossless() { + "lossless".to_string() + } else { + format!("LOST {}", rt.report.differences.len()) + } + ); + for d in &rt.report.differences { + println!(" defect: {d:?}"); + defects += 1; + } + for u in &rt.report.unsupported { + println!( + " unsupported ({:?}): {} id(s) - {}", + u.kind, + u.ids.len(), + u.note + ); + } + + let dangling: Vec<_> = snap.links.dangling().collect(); + println!( + "links {} resolved, {} dangling at model revision {}", + snap.links.links.len() - dangling.len(), + dangling.len(), + snap.links.revision + ); + for l in &dangling { + if let LinkStatus::Dangling { reason } = &l.status { + println!( + " dangling: {} {:?} {} - {reason}", + l.requirement_id, l.class, l.locator + ); + } + } + if strict { + warnings += dangling.len(); + } + + println!( + "gaps {} across {} requirements", + snap.view.report.gaps.len(), + snap.view.rows.len() + ); + let mut by_kind: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &snap.view.report.gaps { + let name = serde_json::to_value(&g.kind) + .ok() + .and_then(|v| v["gap"].as_str().map(str::to_owned)) + .unwrap_or_default(); + *by_kind.entry(name).or_default() += 1; + } + for (k, n) in by_kind { + println!(" {n:>2} {k}"); + } + println!("state {:?}", snap.view.counts()); + + println!( + "result {defects} defect(s), {warnings} warning(s){}", + if strict { " (strict)" } else { "" } + ); + Ok(defects == 0 && !(strict && warnings > 0)) +} + +fn main() -> ExitCode { + let args = match parse() { + Ok(a) => a, + Err(e) => { + eprintln!("kr0ki-assurance: {e}\nusage: kr0ki-assurance lint|verify|view|evidence|gaps [flags] (see the file header)"); + return ExitCode::from(2); + } + }; + let svc = service(&args); + let outcome: Result = (|| match args.command.as_str() { + "lint" => lint(&svc, args.strict), + "verify" => { + let outcomes = if args.positional.is_empty() { + svc.verify_all().map_err(|e| e.to_string())? + } else { + let snap = svc.snapshot().map_err(|e| e.to_string())?; + let mut v = Vec::new(); + for case in &args.positional { + v.push( + svc.run_verification(case, &snap.current.implementation_revision) + .map_err(|e| e.to_string())?, + ); + } + v + }; + let mut all_pass = true; + for o in &outcomes { + println!( + "{:<8} {:?} {} (implementation {})", + o.case_id, o.result, o.detail, o.revisions.implementation_revision + ); + all_pass &= o.result == VerificationResult::Pass; + } + println!("ran {} case(s)", outcomes.len()); + Ok(all_pass) + } + "view" => { + let snap = svc.snapshot().map_err(|e| e.to_string())?; + match args.format.as_str() { + "table" => print!("{}", to_table(&snap.view)), + "d2" => print!("{}", to_d2(snap.graph(), &snap.view)), + "json" => println!( + "{}", + serde_json::to_string_pretty(&snap.view).map_err(|e| e.to_string())? + ), + other => return Err(format!("unknown --format {other} (table|d2|json)")), + } + Ok(true) + } + "evidence" => { + let list = svc + .evidence(args.requirement.as_deref(), args.case.as_deref()) + .map_err(|e| e.to_string())?; + println!( + "at model {} / implementation {}", + list.revisions.model_revision, list.revisions.implementation_revision + ); + for r in &list.records { + println!( + "{:<8} {:<8} {:?} {:?} artifact {} {}", + r.record.requirement_id, + r.record.verification_id, + r.record.result, + r.freshness, + r.artifact, + r.evidence_key + ); + } + println!("{} record(s)", list.records.len()); + Ok(true) + } + "gaps" => { + let list = svc + .list_requirements(&RequirementFilter::default()) + .map_err(|e| e.to_string())?; + for r in &list.requirements { + let gaps: Vec = r + .gaps + .iter() + .filter_map(|g| { + serde_json::to_value(g) + .ok() + .and_then(|v| v["gap"].as_str().map(str::to_owned)) + }) + .collect(); + println!( + "{:<8} {:<20} {}", + r.id, + crate_state(&r.assurance), + gaps.join(", ") + ); + } + Ok(true) + } + other => Err(format!("unknown command {other}")), + })(); + match outcome { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(1), + Err(e) => { + eprintln!("kr0ki-assurance: {e}"); + ExitCode::from(1) + } + } +} + +fn crate_state(a: &ufo_types::mbse::assurance::Assurance) -> &'static str { + kr0ki_core::assurance_view::state_name(*a) +} diff --git a/crates/kr0ki-core/src/change_service.rs b/crates/kr0ki-core/src/change_service.rs new file mode 100644 index 0000000..50a6767 --- /dev/null +++ b/crates/kr0ki-core/src/change_service.rs @@ -0,0 +1,916 @@ +//! KR-A05: the model commit service β€” **propose, validate, commit with an expected base +//! revision, reject when it is stale**. +//! +//! `sync_engine::sync_managed` retries on a moved head: it re-reads, re-diffs and posts again. +//! That is right for reconciling *derived* state, where the desired content is a pure function +//! of the world. It is wrong for an agent's *proposed* edit, which was computed against a +//! specific base and may no longer mean what its author intended on a different one. So this +//! service never rebases: +//! +//! 1. [`ChangeService::propose`] reads the head, diffs the desired elements against it, and +//! returns a [`ProposedChange`] naming that **base revision**. Nothing is written. +//! 2. [`ChangeService::validate`] reports whether the proposal is still current. +//! 3. [`ChangeService::commit`]`(change_id, expected_revision)` applies it only if the head +//! **is** `expected_revision`. Otherwise it returns [`ChangeError::StaleBase`] naming both +//! revisions and writes nothing, so a change committed in between is never overwritten +//! (`DataVersion.payload` is a full replacement; a diff against a stale base is destructive). +//! +//! Commits to one branch are serialised in-process, so two concurrent callers with the same +//! expected revision cannot both pass the check. +//! +//! **Known limit (observed live, 2026-10-02): the OMG pilot server does not enforce +//! `previousCommit`.** A commit that names a stale previous commit is *accepted* +//! (`tests/assurance_a05_live.rs` records this on every run). So the server provides no +//! optimistic concurrency, and this service's check is the only guard: it holds for every writer +//! that goes through one `ChangeService`, but a writer that bypasses it (another process, a +//! direct API call) can still interleave between the head check and the write. Closing that +//! window needs either a server that enforces `previousCommit` (Flexo may) or a single writer in +//! front of the model. If a server does report a conflict, that is returned as a stale base too, +//! never retried. + +use std::collections::{BTreeMap, BTreeSet}; +use std::future::Future; +use std::sync::{Arc, Mutex}; + +use kr0ki_sysmlv2_client::{CommitRequest, DataVersion, Element, Ref, SysmlV2Client}; +use serde::Serialize; +use serde_json::Value; + +use crate::assurance_baseline::sha256_hex; +use crate::sync_engine::{ + diff_managed, element_identifier, resolve_head, DesiredElement, SyncConfig, SyncError, +}; + +// ── Backend ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum BackendError { + /// The server refused the commit because its `previousCommit` is not the head (409/412). + #[error("the model server reports a conflicting head")] + Conflict, + #[error("model backend: {0}")] + Other(String), +} + +/// The slice of a model server the commit service needs. +pub trait ModelBackend: Send + Sync { + /// The head commit id of the branch (`None`: no commits yet). + fn head( + &self, + project: &str, + branch: Option<&str>, + ) -> impl Future, BackendError>> + Send; + + fn elements_at( + &self, + project: &str, + commit: &str, + ) -> impl Future, BackendError>> + Send; + + /// Post a commit whose `previousCommit` is `previous`. Returns the new commit id. + fn commit( + &self, + project: &str, + branch: Option<&str>, + previous: Option<&str>, + changes: Vec, + ) -> impl Future> + Send; +} + +/// The real thing: an OMG Systems Modeling API server through `kr0ki-sysmlv2-client`. +#[derive(Clone)] +pub struct SysmlBackend { + client: Arc, +} + +impl SysmlBackend { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +impl ModelBackend for SysmlBackend { + async fn head( + &self, + project: &str, + branch: Option<&str>, + ) -> Result, BackendError> { + let config = SyncConfig { + project_id: project.into(), + branch_id: branch.map(str::to_owned), + }; + resolve_head(&self.client, &config) + .await + .map(|c| c.map(|c| c.at_id)) + .map_err(|e| BackendError::Other(e.to_string())) + } + + async fn elements_at(&self, project: &str, commit: &str) -> Result, BackendError> { + self.client + .all_elements(project, commit) + .await + .map_err(|e| BackendError::Other(e.to_string())) + } + + async fn commit( + &self, + project: &str, + branch: Option<&str>, + previous: Option<&str>, + changes: Vec, + ) -> Result { + let request = CommitRequest { + type_: "Commit", + change: changes, + previous_commit: previous.map(|p| Ref { + at_id: p.to_string(), + extra: Default::default(), + }), + }; + match self.client.create_commit(project, branch, request).await { + Ok(c) => Ok(c.at_id), + Err(kr0ki_sysmlv2_client::ClientError::Status { + code: 409 | 412, .. + }) => Err(BackendError::Conflict), + Err(e) => Err(BackendError::Other(e.to_string())), + } + } +} + +// ── Proposals ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + Create, + Update, + Delete, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ChangeSummary { + pub operation: Operation, + /// The managed identifier (or the element id for a delete of an unidentified element). + pub identifier: String, +} + +/// A computed, uncommitted change and the revision it was computed against. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProposedChange { + pub change_id: String, + pub project_id: String, + pub branch_id: Option, + /// The head the diff was computed against. `None`: the branch had no commits. + pub base_revision: Option, + pub summary: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Validation { + pub change_id: String, + pub base_revision: Option, + pub current_revision: Option, + /// `true` when the head is still the proposal's base. + pub current: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Committed { + pub change_id: String, + /// The revision the change was applied on top of. + pub base_revision: Option, + pub commit_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ChangeError { + /// The head is not the revision the caller expected. Nothing was written. Re-read, re-propose. + #[error("stale base: expected revision {expected:?} but the head is {current:?}; nothing was written")] + StaleBase { + expected: Option, + current: Option, + }, + /// The caller's expected revision is not the one the proposal was computed against. + #[error( + "expected revision {expected:?} is not the proposal's base {base:?}; nothing was written" + )] + ProposalMismatch { + expected: Option, + base: Option, + }, + #[error("unknown or already-committed change `{0}`")] + UnknownChange(String), + #[error("the desired state already matches revision {0:?}: nothing to commit")] + NoChanges(Option), + #[error(transparent)] + Sync(SyncErrorText), + #[error(transparent)] + Backend(BackendError), +} + +/// `SyncError` is not `Clone`/`Eq`; keep its message. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct SyncErrorText(String); + +impl From for ChangeError { + fn from(e: SyncError) -> Self { + ChangeError::Sync(SyncErrorText(e.to_string())) + } +} + +struct Stored { + proposal: ProposedChange, + changes: Vec, +} + +pub struct ChangeService { + backend: B, + proposals: Mutex>, + branch_locks: Mutex>>>, +} + +impl ChangeService { + pub fn new(backend: B) -> Self { + Self { + backend, + proposals: Mutex::default(), + branch_locks: Mutex::default(), + } + } + + /// The backend this service writes through (read access, for diagnostics and tests). + pub fn backend(&self) -> &B { + &self.backend + } + + fn lock_for(&self, project: &str, branch: Option<&str>) -> Arc> { + let key = format!("{project}\u{0}{}", branch.unwrap_or("")); + self.branch_locks + .lock() + .expect("lock map") + .entry(key) + .or_default() + .clone() + } + + /// Diff `desired` against the head, restricted to elements whose identifier starts with + /// `managed_prefix`. Writes nothing. + pub async fn propose( + &self, + project: &str, + branch: Option<&str>, + managed_prefix: &str, + desired: &[DesiredElement], + ) -> Result { + let head = self + .backend + .head(project, branch) + .await + .map_err(ChangeError::Backend)?; + let existing: Vec = match &head { + Some(commit) => self + .backend + .elements_at(project, commit) + .await + .map_err(ChangeError::Backend)? + .into_iter() + .filter(|e| element_identifier(e).is_some_and(|i| i.starts_with(managed_prefix))) + .collect(), + None => Vec::new(), + }; + let changes = diff_managed(&existing, desired)?; + if changes.is_empty() { + return Err(ChangeError::NoChanges(head)); + } + + let by_id: BTreeMap<&str, &str> = existing + .iter() + .filter_map(|e| element_identifier(e).map(|i| (e.id(), i))) + .collect(); + let summary: Vec = changes + .iter() + .map(|c| { + let identity = c.identity.as_ref().map(|r| r.at_id.as_str()); + match (&c.payload, identity) { + (Some(p), None) => ChangeSummary { + operation: Operation::Create, + identifier: payload_identifier(p), + }, + (Some(p), Some(id)) => ChangeSummary { + operation: Operation::Update, + identifier: by_id + .get(id) + .map(|s| s.to_string()) + .unwrap_or_else(|| payload_identifier(p)), + }, + (None, id) => ChangeSummary { + operation: Operation::Delete, + identifier: id + .and_then(|i| by_id.get(i).map(|s| s.to_string())) + .or_else(|| id.map(str::to_owned)) + .unwrap_or_default(), + }, + } + }) + .collect(); + + let change_id = { + let material = serde_json::json!({ + "project": project, + "branch": branch, + "base": head, + "changes": serde_json::to_value(&changes).unwrap_or(Value::Null), + }); + format!("chg-{}", &sha256_hex(material.to_string().as_bytes())[..24]) + }; + let proposal = ProposedChange { + change_id: change_id.clone(), + project_id: project.into(), + branch_id: branch.map(str::to_owned), + base_revision: head, + summary, + }; + self.proposals.lock().expect("proposal map").insert( + change_id, + Stored { + proposal: proposal.clone(), + changes, + }, + ); + Ok(proposal) + } + + /// Is the proposal still based on the head? + pub async fn validate(&self, change_id: &str) -> Result { + let proposal = self.proposal(change_id)?; + let current = self + .backend + .head(&proposal.project_id, proposal.branch_id.as_deref()) + .await + .map_err(ChangeError::Backend)?; + Ok(Validation { + change_id: change_id.into(), + current: current == proposal.base_revision, + base_revision: proposal.base_revision, + current_revision: current, + }) + } + + /// Apply `change_id` iff the head is `expected_revision`. Never rebases, never retries. + pub async fn commit( + &self, + change_id: &str, + expected_revision: Option<&str>, + ) -> Result { + let (proposal, changes) = { + let map = self.proposals.lock().expect("proposal map"); + let s = map + .get(change_id) + .ok_or_else(|| ChangeError::UnknownChange(change_id.into()))?; + (s.proposal.clone(), s.changes.clone()) + }; + let expected = expected_revision.map(str::to_owned); + if expected != proposal.base_revision { + return Err(ChangeError::ProposalMismatch { + expected, + base: proposal.base_revision, + }); + } + + // One committer per branch at a time: the check and the write are not interleaved + // with another caller's check and write. + let lock = self.lock_for(&proposal.project_id, proposal.branch_id.as_deref()); + let _guard = lock.lock().await; + + // A concurrent commit may already have consumed this proposal. + if !self + .proposals + .lock() + .expect("proposal map") + .contains_key(change_id) + { + return Err(ChangeError::UnknownChange(change_id.into())); + } + let current = self + .backend + .head(&proposal.project_id, proposal.branch_id.as_deref()) + .await + .map_err(ChangeError::Backend)?; + if current != expected { + return Err(ChangeError::StaleBase { expected, current }); + } + match self + .backend + .commit( + &proposal.project_id, + proposal.branch_id.as_deref(), + expected.as_deref(), + changes, + ) + .await + { + Ok(commit_id) => { + self.proposals + .lock() + .expect("proposal map") + .remove(change_id); + Ok(Committed { + change_id: change_id.into(), + base_revision: expected, + commit_id, + }) + } + Err(BackendError::Conflict) => { + // The server saw a head we did not: report it as the stale base it is. + let current = self + .backend + .head(&proposal.project_id, proposal.branch_id.as_deref()) + .await + .map_err(ChangeError::Backend)?; + Err(ChangeError::StaleBase { expected, current }) + } + Err(e) => Err(ChangeError::Backend(e)), + } + } + + fn proposal(&self, change_id: &str) -> Result { + self.proposals + .lock() + .expect("proposal map") + .get(change_id) + .map(|s| s.proposal.clone()) + .ok_or_else(|| ChangeError::UnknownChange(change_id.into())) + } + + /// Identifiers of proposals still open (drafts). + pub fn open_changes(&self) -> BTreeSet { + self.proposals + .lock() + .expect("proposal map") + .keys() + .cloned() + .collect() + } +} + +fn payload_identifier(payload: &Value) -> String { + payload + .get("identifier") + .and_then(Value::as_str) + .or_else(|| { + payload + .get("aliasIds") + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(Value::as_str) + }) + .unwrap_or_default() + .to_string() +} + +/// A stateful in-memory [`ModelBackend`] with the semantics of the real server: commits form +/// a chain, and each commit's elements are the previous commit's with the changes applied. +/// For tests and demos (including `kr0ki-server`'s), where no real model server is available; +/// the real server is exercised by the `#[ignore]`d live tests. +pub mod in_memory { + + use super::*; + use serde_json::json; + + #[derive(Default)] + struct State { + commits: Vec<(String, BTreeMap)>, + next_element: u32, + /// Conflict on the next commit even though `previous` was the head. + conflict_once: bool, + } + + pub struct InMemoryServer { + state: Mutex, + /// Mimic a server that ignores `previousCommit` (the service's own check must catch it). + pub enforces_previous: bool, + } + + impl InMemoryServer { + pub fn new(enforces_previous: bool) -> Self { + Self { + state: Mutex::default(), + enforces_previous, + } + } + + pub fn inject_conflict_once(&self) { + self.state.lock().unwrap().conflict_once = true; + } + + pub fn head_id(&self) -> Option { + self.state + .lock() + .unwrap() + .commits + .last() + .map(|(id, _)| id.clone()) + } + + pub fn commit_count(&self) -> usize { + self.state.lock().unwrap().commits.len() + } + + /// identifier -> `name` of every element at the head commit. + pub fn names_at_head(&self) -> BTreeMap { + let s = self.state.lock().unwrap(); + s.commits + .last() + .map(|(_, els)| { + els.values() + .filter_map(|v| { + let id = v.get("identifier").and_then(Value::as_str)?; + Some(( + id.to_string(), + v.get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + )) + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn identifiers_at_head(&self) -> BTreeSet { + let s = self.state.lock().unwrap(); + s.commits + .last() + .map(|(_, els)| { + els.values() + .filter_map(|v| { + v.get("identifier") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect() + }) + .unwrap_or_default() + } + } + + impl ModelBackend for InMemoryServer { + async fn head(&self, _p: &str, _b: Option<&str>) -> Result, BackendError> { + Ok(self.head_id()) + } + + async fn elements_at(&self, _p: &str, commit: &str) -> Result, BackendError> { + let s = self.state.lock().unwrap(); + let (_, els) = s + .commits + .iter() + .find(|(id, _)| id == commit) + .ok_or_else(|| BackendError::Other(format!("no commit {commit}")))?; + Ok(els + .iter() + .map(|(id, v)| { + let mut v = v.clone(); + v["@id"] = json!(id); + serde_json::from_value(v).unwrap() + }) + .collect()) + } + + async fn commit( + &self, + _p: &str, + _b: Option<&str>, + previous: Option<&str>, + changes: Vec, + ) -> Result { + let mut s = self.state.lock().unwrap(); + if std::mem::take(&mut s.conflict_once) { + return Err(BackendError::Conflict); + } + let head = s.commits.last().map(|(id, _)| id.clone()); + if self.enforces_previous && previous != head.as_deref() { + return Err(BackendError::Conflict); + } + let mut elements = s.commits.last().map(|(_, e)| e.clone()).unwrap_or_default(); + for c in changes { + match (c.payload, c.identity) { + (Some(p), None) => { + s.next_element += 1; + elements.insert(format!("el-{}", s.next_element), p); + } + (Some(p), Some(r)) => { + elements.insert(r.at_id, p); + } + (None, Some(r)) => { + elements.remove(&r.at_id); + } + (None, None) => {} + } + } + let id = format!("commit-{}", s.commits.len() + 1); + s.commits.push((id.clone(), elements)); + Ok(id) + } + } +} + +#[cfg(test)] +mod tests { + use super::in_memory::InMemoryServer; + use super::*; + use serde_json::{json, Map}; + + const PREFIX: &str = "kr0ki:assurance:"; + + fn desired(id: &str, name: &str) -> DesiredElement { + let mut fields = Map::new(); + fields.insert("name".into(), json!(name)); + fields.insert("custom".into(), json!(1)); + DesiredElement { + identifier: format!("{PREFIX}{id}"), + type_: "PartUsage", + fields, + } + } + + fn id(s: &str) -> String { + format!("{PREFIX}{s}") + } + + async fn seeded(enforces: bool) -> (ChangeService, String) { + let svc = ChangeService::new(InMemoryServer::new(enforces)); + let base = svc + .propose("p", None, PREFIX, &[desired("X", "X")]) + .await + .unwrap(); + let committed = svc.commit(&base.change_id, None).await.unwrap(); + (svc, committed.commit_id) + } + + #[tokio::test] + async fn two_edits_from_one_baseline_second_is_rejected_first_is_kept() { + let (svc, r0) = seeded(true).await; + let both = [desired("X", "X")]; + let edit1 = svc + .propose("p", None, PREFIX, &[both[0].clone(), desired("A", "A")]) + .await + .unwrap(); + let edit2 = svc + .propose("p", None, PREFIX, &[both[0].clone(), desired("B", "B")]) + .await + .unwrap(); + assert_eq!(edit1.base_revision.as_deref(), Some(r0.as_str())); + assert_eq!( + edit2.base_revision, edit1.base_revision, + "both edits start from one baseline" + ); + assert_ne!(edit1.change_id, edit2.change_id); + + let r1 = svc + .commit(&edit1.change_id, Some(&r0)) + .await + .unwrap() + .commit_id; + let before = svc.backend.commit_count(); + + let err = svc.commit(&edit2.change_id, Some(&r0)).await.unwrap_err(); + assert_eq!( + err, + ChangeError::StaleBase { + expected: Some(r0.clone()), + current: Some(r1.clone()) + } + ); + // Nothing was written, and the first edit is intact. + assert_eq!(svc.backend.commit_count(), before); + assert_eq!(svc.backend.head_id().as_deref(), Some(r1.as_str())); + let ids = svc.backend.identifiers_at_head(); + assert!( + ids.contains(&id("A")), + "the first edit must survive: {ids:?}" + ); + assert!( + !ids.contains(&id("B")), + "the stale edit must not be applied: {ids:?}" + ); + + // Re-proposing from the new head works and keeps both. + let redo = svc + .propose( + "p", + None, + PREFIX, + &[both[0].clone(), desired("A", "A"), desired("B", "B")], + ) + .await + .unwrap(); + assert_eq!(redo.base_revision.as_deref(), Some(r1.as_str())); + svc.commit(&redo.change_id, Some(&r1)).await.unwrap(); + let ids = svc.backend.identifiers_at_head(); + assert!(ids.contains(&id("A")) && ids.contains(&id("B"))); + } + + #[tokio::test] + async fn the_service_rejects_a_stale_base_even_when_the_server_ignores_previous_commit() { + let (svc, r0) = seeded(false).await; + let x = || desired("X", "X"); + let e1 = svc + .propose("p", None, PREFIX, &[x(), desired("A", "A")]) + .await + .unwrap(); + let e2 = svc + .propose("p", None, PREFIX, &[x(), desired("B", "B")]) + .await + .unwrap(); + svc.commit(&e1.change_id, Some(&r0)).await.unwrap(); + assert!(matches!( + svc.commit(&e2.change_id, Some(&r0)).await, + Err(ChangeError::StaleBase { .. }) + )); + assert!(!svc.backend.identifiers_at_head().contains(&id("B"))); + } + + #[tokio::test] + async fn concurrent_commits_with_one_expected_revision_let_exactly_one_through() { + let (svc, r0) = seeded(false).await; + let svc = Arc::new(svc); + let x = || desired("X", "X"); + let e1 = svc + .propose("p", None, PREFIX, &[x(), desired("A", "A")]) + .await + .unwrap(); + let e2 = svc + .propose("p", None, PREFIX, &[x(), desired("B", "B")]) + .await + .unwrap(); + let (a, b) = { + let (s1, s2) = (svc.clone(), svc.clone()); + let (r1, r2) = (r0.clone(), r0.clone()); + tokio::join!( + tokio::spawn(async move { s1.commit(&e1.change_id, Some(&r1)).await }), + tokio::spawn(async move { s2.commit(&e2.change_id, Some(&r2)).await }), + ) + }; + let results = [a.unwrap(), b.unwrap()]; + assert_eq!( + results.iter().filter(|r| r.is_ok()).count(), + 1, + "{results:?}" + ); + assert_eq!( + results + .iter() + .filter(|r| matches!(r, Err(ChangeError::StaleBase { .. }))) + .count(), + 1, + "{results:?}" + ); + assert_eq!( + svc.backend.commit_count(), + 2, + "the seed plus exactly one edit" + ); + } + + #[tokio::test] + async fn a_conflict_reported_by_the_server_is_a_stale_base_and_is_not_retried() { + let (svc, r0) = seeded(true).await; + let e = svc + .propose("p", None, PREFIX, &[desired("X", "X"), desired("A", "A")]) + .await + .unwrap(); + svc.backend.inject_conflict_once(); + let before = svc.backend.commit_count(); + let err = svc.commit(&e.change_id, Some(&r0)).await.unwrap_err(); + assert!(matches!(err, ChangeError::StaleBase { .. }), "{err:?}"); + assert_eq!(svc.backend.commit_count(), before, "no retry, no write"); + // The proposal is still open and can be re-validated. + assert!(svc.open_changes().contains(&e.change_id)); + } + + #[tokio::test] + async fn an_expected_revision_that_is_not_the_proposals_base_is_refused() { + let (svc, r0) = seeded(true).await; + let e = svc + .propose("p", None, PREFIX, &[desired("X", "X"), desired("A", "A")]) + .await + .unwrap(); + let err = svc + .commit(&e.change_id, Some("commit-999")) + .await + .unwrap_err(); + assert_eq!( + err, + ChangeError::ProposalMismatch { + expected: Some("commit-999".into()), + base: Some(r0) + } + ); + } + + #[tokio::test] + async fn validate_reports_whether_the_proposal_is_still_current() { + let (svc, r0) = seeded(true).await; + let x = || desired("X", "X"); + let e1 = svc + .propose("p", None, PREFIX, &[x(), desired("A", "A")]) + .await + .unwrap(); + let e2 = svc + .propose("p", None, PREFIX, &[x(), desired("B", "B")]) + .await + .unwrap(); + assert!(svc.validate(&e1.change_id).await.unwrap().current); + svc.commit(&e1.change_id, Some(&r0)).await.unwrap(); + let v = svc.validate(&e2.change_id).await.unwrap(); + assert!(!v.current); + assert_eq!(v.base_revision.as_deref(), Some(r0.as_str())); + assert_ne!(v.current_revision, v.base_revision); + } + + #[tokio::test] + async fn a_committed_proposal_cannot_be_committed_twice() { + let (svc, r0) = seeded(true).await; + let e = svc + .propose("p", None, PREFIX, &[desired("X", "X"), desired("A", "A")]) + .await + .unwrap(); + svc.commit(&e.change_id, Some(&r0)).await.unwrap(); + assert_eq!( + svc.commit(&e.change_id, Some(&r0)).await.unwrap_err(), + ChangeError::UnknownChange(e.change_id.clone()) + ); + } + + #[tokio::test] + async fn an_unknown_change_and_a_no_op_are_distinct_errors() { + let (svc, r0) = seeded(true).await; + assert!(matches!( + svc.commit("chg-nope", Some(&r0)).await, + Err(ChangeError::UnknownChange(_)) + )); + assert_eq!( + svc.propose("p", None, PREFIX, &[desired("X", "X")]) + .await + .unwrap_err(), + ChangeError::NoChanges(Some(r0)) + ); + } + + #[tokio::test] + async fn the_proposal_names_what_it_would_do() { + let (svc, _) = seeded(true).await; + let e = svc + .propose( + "p", + None, + PREFIX, + &[desired("X", "Renamed"), desired("A", "A")], + ) + .await + .unwrap(); + let ops: BTreeMap<_, _> = e + .summary + .iter() + .map(|s| (s.identifier.clone(), s.operation)) + .collect(); + assert_eq!(ops[&id("X")], Operation::Update); + assert_eq!(ops[&id("A")], Operation::Create); + // Dropping X from the desired set deletes it. + let d = svc + .propose("p", None, PREFIX, &[desired("A", "A")]) + .await + .unwrap(); + let ops: BTreeMap<_, _> = d + .summary + .iter() + .map(|s| (s.identifier.clone(), s.operation)) + .collect(); + assert_eq!(ops[&id("X")], Operation::Delete); + } + + #[tokio::test] + async fn elements_outside_the_managed_prefix_are_never_touched() { + let svc = ChangeService::new(InMemoryServer::new(true)); + // Someone else's element lives on the server. + let mut other = Map::new(); + other.insert("name".into(), json!("theirs")); + let theirs = DesiredElement { + identifier: "other:thing".into(), + type_: "PartUsage", + fields: other, + }; + let seed = svc.propose("p", None, "other:", &[theirs]).await.unwrap(); + let r0 = svc.commit(&seed.change_id, None).await.unwrap().commit_id; + // Our prefix sees nothing of it, so it is not deleted. + let e = svc + .propose("p", None, PREFIX, &[desired("A", "A")]) + .await + .unwrap(); + assert!(e.summary.iter().all(|s| s.operation == Operation::Create)); + svc.commit(&e.change_id, Some(&r0)).await.unwrap(); + assert!(svc.backend.identifiers_at_head().contains("other:thing")); + } +} diff --git a/crates/kr0ki-core/src/evidence_store.rs b/crates/kr0ki-core/src/evidence_store.rs new file mode 100644 index 0000000..0c6404d --- /dev/null +++ b/crates/kr0ki-core/src/evidence_store.rs @@ -0,0 +1,346 @@ +//! Revision-bound evidence storage. +//! +//! One JSON file per [`EvidenceRecord`] plus the artifact it points at, both content-addressed: +//! +//! ```text +//! /records/.json +//! /artifacts/.log +//! ``` +//! +//! - **Append-only.** A record's file name is the digest of its content, so storing the same +//! record twice is a no-op, and two *different* results for the same case at the same +//! revisions (a flaky case) both survive. Nothing is overwritten and there is no delete. +//! - **Verifiable.** [`FsEvidenceStore::verify`] re-hashes every artifact against the digest in +//! its record, so tampering or loss is reported, not assumed away. +//! - **Deterministic reads.** [`FsEvidenceStore::list`] is sorted, so a view regenerated from +//! the same store is byte-identical. +//! +//! The store neither decides freshness nor interprets results; that is +//! `ufo_types::mbse::assurance`. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use ufo_types::mbse::assurance::{EvidenceError, EvidenceRecord}; + +use crate::assurance_baseline::sha256_hex; + +#[derive(Debug, thiserror::Error)] +pub enum EvidenceStoreError { + #[error("evidence store I/O at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("invalid evidence record: {0}")] + Invalid(#[from] EvidenceError), + #[error("malformed evidence record file {path}: {message}")] + Malformed { path: PathBuf, message: String }, +} + +fn io_err(path: &Path) -> impl FnOnce(io::Error) -> EvidenceStoreError + '_ { + move |source| EvidenceStoreError::Io { + path: path.to_path_buf(), + source, + } +} + +/// What [`FsEvidenceStore::verify`] found for one record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArtifactCheck { + Intact, + /// The artifact file is missing or unreadable. + Missing, + /// The artifact exists but its digest differs from the record's. + DigestMismatch { + actual: String, + }, + /// The artifact is not in this store (e.g. a remote URI): nothing to check here. + External, +} + +#[derive(Debug, Clone)] +pub struct FsEvidenceStore { + root: PathBuf, +} + +impl FsEvidenceStore { + /// A store rooted at `root`. Directories are created on first write. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + fn records_dir(&self) -> PathBuf { + self.root.join("records") + } + + fn artifacts_dir(&self) -> PathBuf { + self.root.join("artifacts") + } + + /// Store `bytes` as an artifact; returns `(uri, digest)` for the record. + /// Idempotent: the same bytes are the same artifact. + pub fn put_artifact(&self, bytes: &[u8]) -> Result<(String, String), EvidenceStoreError> { + let digest = sha256_hex(bytes); + let dir = self.artifacts_dir(); + fs::create_dir_all(&dir).map_err(io_err(&dir))?; + let path = dir.join(format!("{digest}.log")); + if !path.exists() { + write_atomic(&path, bytes)?; + } + Ok(( + format!("file://{}", path.display()), + format!("sha256:{digest}"), + )) + } + + /// Store one record. Returns its file stem (the digest of its content). Idempotent. + pub fn put(&self, record: &EvidenceRecord) -> Result { + record.validate()?; + let json = serde_json::to_vec_pretty(record).expect("an EvidenceRecord serializes"); + let stem = sha256_hex(&json); + let dir = self.records_dir(); + fs::create_dir_all(&dir).map_err(io_err(&dir))?; + let path = dir.join(format!("{stem}.json")); + if !path.exists() { + write_atomic(&path, &json)?; + } + Ok(stem) + } + + /// Every record, sorted by `(key, result, artifact_digest)`. + pub fn list(&self) -> Result, EvidenceStoreError> { + let dir = self.records_dir(); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(io_err(&dir)(e)), + }; + let mut out = Vec::new(); + for entry in entries { + let path = entry.map_err(io_err(&dir))?.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let bytes = fs::read(&path).map_err(io_err(&path))?; + let record: EvidenceRecord = + serde_json::from_slice(&bytes).map_err(|e| EvidenceStoreError::Malformed { + path: path.clone(), + message: e.to_string(), + })?; + record + .validate() + .map_err(|e| EvidenceStoreError::Malformed { + path: path.clone(), + message: e.to_string(), + })?; + out.push(record); + } + out.sort_by(|a, b| { + (a.key(), format!("{:?}", a.result), &a.artifact_digest).cmp(&( + b.key(), + format!("{:?}", b.result), + &b.artifact_digest, + )) + }); + Ok(out) + } + + pub fn for_requirement( + &self, + requirement_id: &str, + ) -> Result, EvidenceStoreError> { + Ok(self + .list()? + .into_iter() + .filter(|r| r.requirement_id == requirement_id) + .collect()) + } + + pub fn for_case( + &self, + verification_id: &str, + ) -> Result, EvidenceStoreError> { + Ok(self + .list()? + .into_iter() + .filter(|r| r.verification_id == verification_id) + .collect()) + } + + /// Re-hash each record's artifact and compare with the recorded digest. + pub fn verify(&self) -> Result, EvidenceStoreError> { + let own_prefix = format!("file://{}", self.artifacts_dir().display()); + Ok(self + .list()? + .into_iter() + .map(|r| { + let check = match r.artifact_uri.strip_prefix("file://") { + Some(path) if r.artifact_uri.starts_with(&own_prefix) => match fs::read(path) { + Ok(bytes) => { + let actual = format!("sha256:{}", sha256_hex(&bytes)); + if actual == r.artifact_digest { + ArtifactCheck::Intact + } else { + ArtifactCheck::DigestMismatch { actual } + } + } + Err(_) => ArtifactCheck::Missing, + }, + _ => ArtifactCheck::External, + }; + (r, check) + }) + .collect()) + } +} + +/// Write via a temporary sibling and rename, so a reader never sees a half-written file. +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), EvidenceStoreError> { + let tmp = path.with_extension(format!("tmp-{}", std::process::id())); + fs::write(&tmp, bytes).map_err(io_err(&tmp))?; + fs::rename(&tmp, path).map_err(io_err(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ufo_types::mbse::assurance::VerificationResult; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "kr0ki-evidence-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&d); + d + } + + fn record( + store: &FsEvidenceStore, + case: &str, + impl_rev: &str, + result: VerificationResult, + log: &str, + ) -> EvidenceRecord { + let (uri, digest) = store.put_artifact(log.as_bytes()).unwrap(); + EvidenceRecord { + requirement_id: "KR-X".into(), + verification_id: case.into(), + model_revision: "m1".into(), + implementation_revision: impl_rev.into(), + configuration_digest: format!("sha256:{}", "1".repeat(64)), + result, + artifact_uri: uri, + artifact_digest: digest, + } + } + + #[test] + fn put_then_list_round_trips_and_is_idempotent() { + let store = FsEvidenceStore::new(dir("rt")); + let r = record(&store, "VC-1", "i1", VerificationResult::Pass, "ok"); + let a = store.put(&r).unwrap(); + let b = store.put(&r).unwrap(); + assert_eq!(a, b); + assert_eq!(store.list().unwrap(), vec![r]); + } + + #[test] + fn an_empty_or_missing_store_lists_nothing() { + assert_eq!(FsEvidenceStore::new(dir("none")).list().unwrap(), vec![]); + } + + #[test] + fn two_results_for_the_same_revisions_both_survive() { + let store = FsEvidenceStore::new(dir("flaky")); + let pass = record(&store, "VC-1", "i1", VerificationResult::Pass, "ok"); + let fail = record(&store, "VC-1", "i1", VerificationResult::Fail, "boom"); + store.put(&pass).unwrap(); + store.put(&fail).unwrap(); + assert_eq!( + store.list().unwrap().len(), + 2, + "a flaky case must not hide its failure" + ); + } + + #[test] + fn invalid_records_are_refused_not_stored() { + let store = FsEvidenceStore::new(dir("bad")); + let mut r = record(&store, "VC-1", "i1", VerificationResult::Pass, "ok"); + r.artifact_digest = "md5:nope".into(); + assert!(matches!(store.put(&r), Err(EvidenceStoreError::Invalid(_)))); + assert_eq!(store.list().unwrap(), vec![]); + } + + #[test] + fn listing_is_sorted_and_filterable() { + let store = FsEvidenceStore::new(dir("sort")); + for (case, rev) in [("VC-2", "i1"), ("VC-1", "i2"), ("VC-1", "i1")] { + store + .put(&record(&store, case, rev, VerificationResult::Pass, case)) + .unwrap(); + } + let keys: Vec<_> = store.list().unwrap().iter().map(|r| r.key()).collect(); + let mut sorted = keys.clone(); + sorted.sort(); + assert_eq!(keys, sorted); + assert_eq!(store.for_case("VC-1").unwrap().len(), 2); + assert_eq!(store.for_requirement("KR-X").unwrap().len(), 3); + assert_eq!(store.for_requirement("KR-NOPE").unwrap().len(), 0); + } + + #[test] + fn verify_detects_tampering_and_loss() { + let store = FsEvidenceStore::new(dir("verify")); + let r = record( + &store, + "VC-1", + "i1", + VerificationResult::Pass, + "the real log", + ); + store.put(&r).unwrap(); + let checks = store.verify().unwrap(); + assert_eq!(checks[0].1, ArtifactCheck::Intact); + + let path = r.artifact_uri.strip_prefix("file://").unwrap().to_string(); + fs::write(&path, b"a forged log").unwrap(); + assert!(matches!( + store.verify().unwrap()[0].1, + ArtifactCheck::DigestMismatch { .. } + )); + + fs::remove_file(&path).unwrap(); + assert_eq!(store.verify().unwrap()[0].1, ArtifactCheck::Missing); + } + + #[test] + fn an_artifact_outside_this_store_is_external_not_intact() { + let store = FsEvidenceStore::new(dir("ext")); + let mut r = record(&store, "VC-1", "i1", VerificationResult::Pass, "ok"); + r.artifact_uri = "https://ci.example.test/run/1".into(); + store.put(&r).unwrap(); + assert_eq!(store.verify().unwrap()[0].1, ArtifactCheck::External); + } + + #[test] + fn a_malformed_record_file_is_an_error_not_silently_skipped() { + let store = FsEvidenceStore::new(dir("malformed")); + let d = store.records_dir(); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join("junk.json"), b"{not json").unwrap(); + assert!(matches!( + store.list(), + Err(EvidenceStoreError::Malformed { .. }) + )); + } +} diff --git a/crates/kr0ki-core/src/lib.rs b/crates/kr0ki-core/src/lib.rs index 11f9d4d..dc24d38 100644 --- a/crates/kr0ki-core/src/lib.rs +++ b/crates/kr0ki-core/src/lib.rs @@ -24,17 +24,25 @@ //! `ufo_types::iso_ir::{Node, Edge}`, a sibling of `docgen::harvest`'s //! `SymbolVisitor` producing relationships instead of doc symbols. +pub mod assurance_baseline; +pub mod assurance_service; +pub mod assurance_trace; +pub mod assurance_view; +pub mod audit_log; pub mod b00t_graph; pub mod cache; pub mod catalog; +pub mod change_service; pub mod digital_thread_sync; pub mod docgen; +pub mod evidence_store; pub mod examples; pub mod flatten; pub mod flexo_reqif_sync; pub mod format; pub mod graph_store; pub mod k8s_recognizer; +pub mod mcp_surface; pub mod mcp_tool; pub mod probe; pub mod rdf_store; @@ -43,9 +51,11 @@ pub mod render; pub mod reqif_export; pub mod reqif_fetch; pub mod reqif_import; +pub mod reqif_roundtrip; pub mod sync_engine; pub mod sysml_mcp; pub mod ui_bus; +pub mod verification_runner; /// Canonical home is `ufo_types::mbse::requirements` (kr0ki M1, /// 2026-09-19) β€” re-exported here so existing `kr0ki_core::requirements::*` /// call sites keep working without a local copy of the types. diff --git a/crates/kr0ki-core/src/mcp_surface.rs b/crates/kr0ki-core/src/mcp_surface.rs new file mode 100644 index 0000000..230ae2c --- /dev/null +++ b/crates/kr0ki-core/src/mcp_surface.rs @@ -0,0 +1,368 @@ +//! MCP **resources** and **prompts** for the assurance thread β€” the two surfaces +//! [`crate::mcp_tool`] does not cover. MCP separates the three on purpose: *resources* supply +//! context, *tools* execute operations, *prompts* are user-selected workflows. +//! +//! Like tools, both are data the server publishes (`GET /mcp/resources`, `GET /mcp/prompts`) and +//! the stdio bridge dispatches generically, so adding one needs no bridge change. +//! +//! - A **resource template** maps a URI (`kr0ki://requirement/KR-A01`) onto an existing +//! `GET` route. It carries no logic of its own, so the gateway authorises a resource read +//! exactly as it authorises the equivalent HTTP request, and a resource can never reach +//! something its route does not. +//! - A **prompt** is a text template with named arguments. It tells the model *which tools to +//! call in what order*; it grants nothing. +//! +//! Every resource answer is revision-qualified by the route that serves it, which is how an +//! agent retrieves only the context it needs, at a stated revision. + +use std::collections::BTreeMap; + +use crate::mcp_tool::ArgPlacement; + +#[derive(Debug, Clone, Copy)] +pub struct ResourceTemplate { + /// RFC 6570-style: `kr0ki://requirement/{id}`. + pub uri_template: &'static str, + pub name: &'static str, + pub description: &'static str, + pub mime_type: &'static str, + /// The `GET` route that serves it. + pub path_template: &'static str, + /// Every `{name}` of `uri_template`, and where it goes on the wire. + pub args: &'static [(&'static str, ArgPlacement)], +} + +pub const RESOURCES: &[ResourceTemplate] = &[ + ResourceTemplate { + uri_template: "kr0ki://requirement/{id}", + name: "requirement", + description: "One requirement in full: profile fields, thread, assurance state, gaps, qualified by revisions.", + mime_type: "application/json", + path_template: "/assurance/requirements/{id}", + args: &[("id", ArgPlacement::Path)], + }, + ResourceTemplate { + uri_template: "kr0ki://trace/{id}", + name: "requirement-trace", + description: "A requirement's links resolved at the current model revision, dangling ones named.", + mime_type: "application/json", + path_template: "/assurance/requirements/{id}/trace", + args: &[("id", ArgPlacement::Path)], + }, + ResourceTemplate { + uri_template: "kr0ki://evidence/{requirement}", + name: "requirement-evidence", + description: "Revision-bound evidence for one requirement, each record with its freshness now.", + mime_type: "application/json", + path_template: "/assurance/evidence", + args: &[("requirement", ArgPlacement::Query)], + }, + ResourceTemplate { + uri_template: "kr0ki://view/{format}", + name: "assurance-view", + description: "The assurance view (format: json, table, d2).", + mime_type: "text/plain", + path_template: "/assurance/view", + args: &[("format", ArgPlacement::Query)], + }, + ResourceTemplate { + uri_template: "kr0ki://skill/{name}", + name: "skill", + description: "A skill's instructions (SKILL.md), e.g. requirements-authoring.", + mime_type: "text/markdown", + path_template: "/assurance/skills/{name}", + args: &[("name", ArgPlacement::Path)], + }, + ResourceTemplate { + uri_template: "kr0ki://model/{project_id}/{commit_id}/elements", + name: "model-elements", + description: "Every element of a model-server project at one commit (a revision-qualified read).", + mime_type: "application/json", + path_template: "/model/projects/{project_id}/commits/{commit_id}/elements", + args: &[("project_id", ArgPlacement::Path), ("commit_id", ArgPlacement::Path)], + }, +]; + +#[derive(Debug, Clone, Copy)] +pub struct PromptArg { + pub name: &'static str, + pub description: &'static str, + pub required: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct Prompt { + pub name: &'static str, + pub description: &'static str, + pub arguments: &'static [PromptArg], + /// `{{arg}}` placeholders are replaced by [`render_prompt`]. + pub template: &'static str, +} + +pub const PROMPTS: &[Prompt] = &[ + Prompt { + name: "review_requirement", + description: "Review one requirement against the authoring rules, separating deterministic findings from your own judgment.", + arguments: &[PromptArg { name: "id", description: "Requirement id, e.g. KR-A01.", required: true }], + template: "Review requirement {{id}}.\n\ +1. Call get_requirement with id={{id}}.\n\ +2. Read the authoring rules: the resource kr0ki://skill/requirements-authoring.\n\ +3. Report two lists, kept apart.\n (a) Deterministic findings the tool gave you: statement_lints and gaps. Quote them as returned.\n (b) Your own judgment: ambiguity, contradiction with neighbouring requirements (use list_requirements), undefined terms, and whether the behaviour is observable. Quote the words you object to and propose a rewrite.\n\ +4. State both axes: satisfaction (elements asserted to satisfy it) and verification (assurance state and whether evidence is fresh), and the revisions the answer was computed at.\n\ +Do not propose or commit a change unless asked.", + }, + Prompt { + name: "explain_gap", + description: "Explain why a requirement is not yet verified and the smallest step that closes each gap.", + arguments: &[PromptArg { name: "id", description: "Requirement id, e.g. KR-A06.", required: true }], + template: "Explain why requirement {{id}} is not verified.\n\ +1. Call trace_requirement with id={{id}}, get_requirement with id={{id}}, and get_evidence with requirement={{id}}.\n\ +2. Name each gap by its kind and say in one sentence what it means (for example control_not_implemented: the control records no implementation yet; satisfied_untested: the design asserts it but no case has run; stale: evidence exists only for other revisions).\n\ +3. For each, give the smallest step that closes it. If evidence exists, compare its revisions with the current ones in the answer and say whether it is stale and which revision moved.\n\ +4. Never suggest closing a gap by editing an assertion or an implementation path that is not true.", + }, + Prompt { + name: "implement_next_slice", + description: "Pick the next unimplemented requirement and carry it through test, implementation, evidence and a regenerated view.", + arguments: &[], + template: "Implement the next slice of the assurance baseline.\n\ +1. list_requirements with gap=control_not_implemented; choose the lowest-numbered requirement whose predecessors are verified.\n\ +2. get_requirement and trace_requirement for it. State its acceptance case.\n\ +3. Write the failing test for that case first (the case's `cargo test -p --test ` target), then implement until it passes.\n\ +4. Run `just check` and `just test`. Run any live check the change needs before calling it verified.\n\ +5. Record the control's implementation path in the baseline (docs/assurance/kr0ki.assurance.toml) once the code exists.\n\ +6. run_verification with case_id and the repository's current implementation revision, then get_requirement again and report the new state and revisions.\n\ +Do not mark a requirement implemented, or verified, on anything but evidence at the current revisions.", + }, +]; + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum PromptError { + #[error("prompt `{prompt}` requires argument `{argument}`")] + MissingArgument { + prompt: &'static str, + argument: &'static str, + }, + #[error("unknown argument `{0}`")] + UnknownArgument(String), +} + +/// Fill a prompt's placeholders. A required argument must be present and non-empty; an +/// argument the prompt does not declare is refused rather than ignored. +pub fn render_prompt( + prompt: &Prompt, + args: &BTreeMap, +) -> Result { + if let Some(unknown) = args + .keys() + .find(|k| !prompt.arguments.iter().any(|a| a.name == k.as_str())) + { + return Err(PromptError::UnknownArgument(unknown.clone())); + } + let mut text = prompt.template.to_string(); + for a in prompt.arguments { + match args.get(a.name).map(|v| v.trim()).filter(|v| !v.is_empty()) { + Some(v) => text = text.replace(&format!("{{{{{}}}}}", a.name), v), + None if a.required => { + return Err(PromptError::MissingArgument { + prompt: prompt.name, + argument: a.name, + }); + } + None => {} + } + } + Ok(text) +} + +pub fn find_prompt(name: &str) -> Option<&'static Prompt> { + PROMPTS.iter().find(|p| p.name == name) +} + +fn placement(p: ArgPlacement) -> &'static str { + match p { + ArgPlacement::Path => "path", + ArgPlacement::Query => "query", + ArgPlacement::Body => "body", + } +} + +/// `GET /mcp/resources`. +pub fn resources_manifest_json() -> serde_json::Value { + serde_json::Value::Array( + RESOURCES + .iter() + .map(|r| { + serde_json::json!({ + "uriTemplate": r.uri_template, + "name": r.name, + "description": r.description, + "mimeType": r.mime_type, + "httpBinding": { + "method": "GET", + "pathTemplate": r.path_template, + "args": r.args.iter().map(|(n, p)| serde_json::json!({"name": n, "placement": placement(*p)})).collect::>(), + } + }) + }) + .collect(), + ) +} + +/// `GET /mcp/prompts`. +pub fn prompts_manifest_json() -> serde_json::Value { + serde_json::Value::Array( + PROMPTS + .iter() + .map(|p| { + serde_json::json!({ + "name": p.name, + "description": p.description, + "arguments": p.arguments.iter().map(|a| serde_json::json!({ + "name": a.name, "description": a.description, "required": a.required + })).collect::>(), + "template": p.template, + }) + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp_tool::McpTool; + + #[test] + fn every_uri_template_variable_is_a_bound_argument_and_vice_versa() { + for r in RESOURCES { + let mut vars: Vec<&str> = r + .uri_template + .split('{') + .skip(1) + .filter_map(|s| s.split('}').next()) + .collect(); + let mut bound: Vec<&str> = r.args.iter().map(|(n, _)| *n).collect(); + vars.sort_unstable(); + bound.sort_unstable(); + assert_eq!( + vars, bound, + "{}: URI variables and bound args differ", + r.name + ); + for (name, placement) in r.args { + if *placement == ArgPlacement::Path { + assert!( + r.path_template.contains(&format!("{{{name}}}")), + "{}: path arg {name}", + r.name + ); + } + assert_ne!( + *placement, + ArgPlacement::Body, + "{}: a resource read has no body", + r.name + ); + } + } + } + + #[test] + fn resource_and_prompt_names_are_unique() { + let mut r: Vec<_> = RESOURCES.iter().map(|r| r.name).collect(); + let mut u: Vec<_> = RESOURCES.iter().map(|r| r.uri_template).collect(); + let mut p: Vec<_> = PROMPTS.iter().map(|p| p.name).collect(); + for v in [&mut r, &mut u, &mut p] { + let n = v.len(); + v.sort_unstable(); + v.dedup(); + assert_eq!(v.len(), n); + } + } + + #[test] + fn prompts_only_name_tools_that_exist() { + let tools: Vec<&str> = McpTool::ALL.iter().map(|t| t.name()).collect(); + for p in PROMPTS { + // every `identifier(` or `identifier with` that looks like a tool call + for tool in [ + "get_requirement", + "trace_requirement", + "get_evidence", + "list_requirements", + "run_verification", + "propose_change", + "commit_change", + "validate_change", + ] { + if p.template.contains(tool) { + assert!( + tools.contains(&tool), + "{}: names unknown tool {tool}", + p.name + ); + } + } + } + assert!(PROMPTS + .iter() + .any(|p| p.template.contains("get_requirement"))); + } + + #[test] + fn prompts_render_required_arguments_and_refuse_missing_or_unknown_ones() { + let p = find_prompt("explain_gap").unwrap(); + let args = BTreeMap::from([("id".to_string(), "KR-A06".to_string())]); + let text = render_prompt(p, &args).unwrap(); + assert!(text.contains("trace_requirement with id=KR-A06")); + assert!(!text.contains("{{"), "no placeholder may survive rendering"); + assert_eq!( + render_prompt(p, &BTreeMap::new()), + Err(PromptError::MissingArgument { + prompt: "explain_gap", + argument: "id" + }) + ); + assert_eq!( + render_prompt(p, &BTreeMap::from([("id".to_string(), " ".to_string())])), + Err(PromptError::MissingArgument { + prompt: "explain_gap", + argument: "id" + }) + ); + assert!(matches!( + render_prompt( + p, + &BTreeMap::from([("id".into(), "x".into()), ("evil".into(), "y".into())]) + ), + Err(PromptError::UnknownArgument(_)) + )); + // A prompt with no arguments renders as is. + assert!(render_prompt( + find_prompt("implement_next_slice").unwrap(), + &BTreeMap::new() + ) + .unwrap() + .contains("run_verification")); + } + + #[test] + fn a_prompt_argument_cannot_inject_another_placeholder() { + let p = find_prompt("review_requirement").unwrap(); + let args = BTreeMap::from([("id".to_string(), "{{id}}".to_string())]); + // The value is inserted once; it is not re-expanded. + let text = render_prompt(p, &args).unwrap(); + assert!(text.contains("id={{id}}")); + } + + #[test] + fn manifests_are_json_arrays_with_bindings_and_templates() { + let r = resources_manifest_json(); + assert_eq!(r.as_array().unwrap().len(), RESOURCES.len()); + assert_eq!(r[0]["httpBinding"]["method"], "GET"); + let p = prompts_manifest_json(); + assert_eq!(p.as_array().unwrap().len(), PROMPTS.len()); + assert!(p[0]["template"].as_str().unwrap().len() > 50); + } +} diff --git a/crates/kr0ki-core/src/mcp_tool.rs b/crates/kr0ki-core/src/mcp_tool.rs index f352080..c5f34b7 100644 --- a/crates/kr0ki-core/src/mcp_tool.rs +++ b/crates/kr0ki-core/src/mcp_tool.rs @@ -34,6 +34,16 @@ pub enum McpTool { ValidateSysml, SysmlSymbols, SysmlSummary, + ListRequirements, + GetRequirement, + TraceRequirement, + RenderView, + GetEvidence, + ProposeChange, + ValidateChange, + CommitChange, + RunVerification, + GetAuditRecords, } impl McpTool { @@ -60,6 +70,16 @@ impl McpTool { Self::ValidateSysml, Self::SysmlSymbols, Self::SysmlSummary, + Self::ListRequirements, + Self::GetRequirement, + Self::TraceRequirement, + Self::RenderView, + Self::GetEvidence, + Self::ProposeChange, + Self::ValidateChange, + Self::CommitChange, + Self::RunVerification, + Self::GetAuditRecords, ]; pub const fn name(self) -> &'static str { @@ -86,6 +106,16 @@ impl McpTool { Self::ValidateSysml => "validate_sysml", Self::SysmlSymbols => "sysml_symbols", Self::SysmlSummary => "sysml_summary", + Self::ListRequirements => "list_requirements", + Self::GetRequirement => "get_requirement", + Self::TraceRequirement => "trace_requirement", + Self::RenderView => "render_view", + Self::GetEvidence => "get_evidence", + Self::ProposeChange => "propose_change", + Self::ValidateChange => "validate_change", + Self::CommitChange => "commit_change", + Self::RunVerification => "run_verification", + Self::GetAuditRecords => "get_audit_records", } } @@ -162,6 +192,36 @@ impl McpTool { Self::SysmlSummary => { "Summarise SysML v2 text: counts of elements by kind (part def, requirement, ...), via the SysML MCP sidecar." } + Self::ListRequirements => { + "List the assurance baseline's requirements, each with its status, owner, assurance state (unsatisfied / satisfied_untested / verified / failing / stale) and gap kinds. Filters narrow the result so only what is needed enters your context; call get_requirement for one in full." + } + Self::GetRequirement => { + "Get one requirement in full: its nine profile fields, its thread (source obligations, satisfying elements, enforcing controls, verification cases, evidence with result and freshness), its assurance state and its gaps, all qualified by the model, implementation and configuration revisions they were computed at." + } + Self::TraceRequirement => { + "Resolve one requirement's links - satisfying system elements against the current model revision, implementation paths and test targets against the repository - and report each as resolved or dangling with the reason. Use this to see implementation gaps." + } + Self::RenderView => { + "Render the assurance view. Satisfaction assertions (dashed edges) and verification results (evidence nodes with result and freshness) are drawn apart. format: json (default), table, d2, or svg." + } + Self::GetEvidence => { + "List revision-bound evidence records (result, the three revisions, artifact uri and digest, artifact integrity, freshness now). Filter by requirement and/or verification case." + } + Self::ProposeChange => { + "Draft a change to the requirement baseline and return its diff and diagnostics WITHOUT committing. The body is JSON: {project_id, branch_id?, requirements:[{id,title,statement,source,source_kind,owner,rationale,verification_id,acceptance,status}]}. Each requirement is validated against the profile and linted (one 'shall', a responsible component, no vague terms); a profile error means no change is proposed. The result names the base revision the diff was computed against." + } + Self::ValidateChange => { + "Check a drafted change: is it still based on the current model revision (current=true), or has the model moved on (stale)? Does not commit." + } + Self::CommitChange => { + "Apply a drafted change IF AND ONLY IF the model head is expected_revision. A stale expected_revision is rejected (409, stale_base) with both revisions named and nothing written - re-read, then propose again. Requires the model.commit grant, checked by the service for every transport." + } + Self::RunVerification => { + "Run a declared verification case and store revision-bound evidence. `revision` is the implementation revision you expect to be verifying; if the repository is at a different revision the run is refused (409). Only `cargo test -p --test ` cases run; a run that executed zero tests is an error, not a pass." + } + Self::GetAuditRecords => { + "Read the audit log: one record per tool invocation, permitted or denied, with caller, operation, decision, model revision and correlation id. Filter by caller, operation, decision, correlation_id or phase. Requires the audit.read grant. The result says whether the hash chain verifies." + } } } @@ -172,6 +232,61 @@ impl McpTool { "required": ["code"], "properties": {"code": {"type": "string", "description": "SysML v2 textual notation (max 256 KiB)."}} }), + Self::ListRequirements => serde_json::json!({ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["draft", "review", "validated", "gated", "implemented"]}, + "owner": {"type": "string"}, + "state": {"type": "string", "enum": ["unsatisfied", "satisfied_untested", "verified", "failing", "stale"]}, + "gap": {"type": "string", "description": "Only requirements with this gap kind (e.g. control_not_implemented, satisfied_untested, stale, dangling_element)."} + } + }), + Self::GetRequirement | Self::TraceRequirement => serde_json::json!({ + "type": "object", "required": ["id"], + "properties": {"id": {"type": "string", "description": "Requirement id, e.g. KR-A01."}} + }), + Self::RenderView => serde_json::json!({ + "type": "object", + "properties": {"format": {"type": "string", "enum": ["json", "table", "d2", "svg"], "default": "json"}} + }), + Self::GetEvidence => serde_json::json!({ + "type": "object", + "properties": { + "requirement": {"type": "string", "description": "Requirement id."}, + "case": {"type": "string", "description": "Verification case id, e.g. VC-A01."} + } + }), + Self::ProposeChange => serde_json::json!({ + "type": "object", "required": ["change"], + "properties": {"change": {"type": "string", "description": "JSON: {project_id, branch_id?, requirements:[{id,title,statement,source,source_kind,owner,rationale,verification_id,acceptance,status}]}."}} + }), + Self::ValidateChange => serde_json::json!({ + "type": "object", "required": ["change_id"], + "properties": {"change_id": {"type": "string"}} + }), + Self::CommitChange => serde_json::json!({ + "type": "object", "required": ["change_id", "expected_revision"], + "properties": { + "change_id": {"type": "string"}, + "expected_revision": {"type": "string", "description": "The model revision you believe is current: the base_revision the proposal named. Rejected if the head differs."} + } + }), + Self::RunVerification => serde_json::json!({ + "type": "object", "required": ["case_id", "revision"], + "properties": { + "case_id": {"type": "string", "description": "Verification case id, e.g. VC-A01."}, + "revision": {"type": "string", "description": "The implementation revision you expect to verify."} + } + }), + Self::GetAuditRecords => serde_json::json!({ + "type": "object", + "properties": { + "caller": {"type": "string"}, "operation": {"type": "string"}, + "decision": {"type": "string", "enum": ["permit", "deny"]}, + "correlation_id": {"type": "string"}, + "phase": {"type": "string", "enum": ["decision", "outcome"]} + } + }), Self::ListDiagramTypes => serde_json::json!({ "type": "object", "properties": { @@ -467,6 +582,86 @@ impl McpTool { placement: ArgPlacement::Path, }], }, + Self::ListRequirements => HttpBinding { + method: HttpMethod::Get, + path_template: "/assurance/requirements", + args: &[ + ArgBinding { name: "status", placement: ArgPlacement::Query }, + ArgBinding { name: "owner", placement: ArgPlacement::Query }, + ArgBinding { name: "state", placement: ArgPlacement::Query }, + ArgBinding { name: "gap", placement: ArgPlacement::Query } + ], + }, + Self::GetRequirement => HttpBinding { + method: HttpMethod::Get, + path_template: "/assurance/requirements/{id}", + args: &[ + ArgBinding { name: "id", placement: ArgPlacement::Path } + ], + }, + Self::TraceRequirement => HttpBinding { + method: HttpMethod::Get, + path_template: "/assurance/requirements/{id}/trace", + args: &[ + ArgBinding { name: "id", placement: ArgPlacement::Path } + ], + }, + Self::RenderView => HttpBinding { + method: HttpMethod::Get, + path_template: "/assurance/view", + args: &[ + ArgBinding { name: "format", placement: ArgPlacement::Query } + ], + }, + Self::GetEvidence => HttpBinding { + method: HttpMethod::Get, + path_template: "/assurance/evidence", + args: &[ + ArgBinding { name: "requirement", placement: ArgPlacement::Query }, + ArgBinding { name: "case", placement: ArgPlacement::Query } + ], + }, + Self::ProposeChange => HttpBinding { + method: HttpMethod::Post, + path_template: "/assurance/changes", + args: &[ + ArgBinding { name: "change", placement: ArgPlacement::Body } + ], + }, + Self::ValidateChange => HttpBinding { + method: HttpMethod::Get, + path_template: "/assurance/changes/{change_id}", + args: &[ + ArgBinding { name: "change_id", placement: ArgPlacement::Path } + ], + }, + Self::CommitChange => HttpBinding { + method: HttpMethod::Post, + path_template: "/assurance/changes/{change_id}/commit", + args: &[ + ArgBinding { name: "change_id", placement: ArgPlacement::Path }, + ArgBinding { name: "expected_revision", placement: ArgPlacement::Query } + ], + }, + Self::RunVerification => HttpBinding { + method: HttpMethod::Post, + path_template: "/assurance/verify/{case_id}", + args: &[ + ArgBinding { name: "case_id", placement: ArgPlacement::Path }, + ArgBinding { name: "revision", placement: ArgPlacement::Query } + ], + }, + Self::GetAuditRecords => HttpBinding { + method: HttpMethod::Get, + path_template: "/assurance/audit", + args: &[ + ArgBinding { name: "caller", placement: ArgPlacement::Query }, + ArgBinding { name: "operation", placement: ArgPlacement::Query }, + ArgBinding { name: "decision", placement: ArgPlacement::Query }, + ArgBinding { name: "correlation_id", placement: ArgPlacement::Query }, + ArgBinding { name: "phase", placement: ArgPlacement::Query } + ], + }, Self::SyncDigitalThread => HttpBinding { method: HttpMethod::Post, path_template: "/model/projects/{project_id}/sync", @@ -525,7 +720,7 @@ pub enum HttpMethod { } /// Where in the HTTP request a `tools/call` argument by this name lands. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArgPlacement { /// Substituted into the path template at `{name}`. Path, @@ -581,6 +776,98 @@ mod tests { .iter() .any(|a| a.name == "url" && matches!(a.placement, ArgPlacement::Body))); } + #[test] + fn assurance_tools_bind_to_the_assurance_routes() { + let b = |t: McpTool| { + let h = t.http_binding(); + let m = match h.method { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + }; + let args: Vec<(&str, &str)> = h + .args + .iter() + .map(|a| { + ( + a.name, + match a.placement { + ArgPlacement::Path => "path", + ArgPlacement::Query => "query", + ArgPlacement::Body => "body", + }, + ) + }) + .collect(); + (m, h.path_template, args) + }; + assert_eq!( + b(McpTool::GetRequirement), + ("GET", "/assurance/requirements/{id}", vec![("id", "path")]) + ); + assert_eq!( + b(McpTool::TraceRequirement), + ( + "GET", + "/assurance/requirements/{id}/trace", + vec![("id", "path")] + ) + ); + assert_eq!( + b(McpTool::RenderView), + ("GET", "/assurance/view", vec![("format", "query")]) + ); + assert_eq!( + b(McpTool::CommitChange), + ( + "POST", + "/assurance/changes/{change_id}/commit", + vec![("change_id", "path"), ("expected_revision", "query")] + ), + "the commit tool carries the expected revision, so a stale caller can be refused" + ); + assert_eq!( + b(McpTool::RunVerification), + ( + "POST", + "/assurance/verify/{case_id}", + vec![("case_id", "path"), ("revision", "query")] + ) + ); + assert_eq!( + b(McpTool::ProposeChange), + ("POST", "/assurance/changes", vec![("change", "body")]) + ); + assert_eq!( + b(McpTool::ValidateChange).0, + "GET", + "validating a draft must not be a write" + ); + assert_eq!(b(McpTool::GetAuditRecords).1, "/assurance/audit"); + assert_eq!(b(McpTool::GetEvidence).1, "/assurance/evidence"); + assert_eq!(b(McpTool::ListRequirements).1, "/assurance/requirements"); + } + + #[test] + fn every_required_argument_in_a_schema_has_a_binding() { + // A required argument with nowhere to go on the wire would be silently dropped. + for t in McpTool::ALL { + let schema = t.input_schema(); + let bound: Vec<&str> = t.http_binding().args.iter().map(|a| a.name).collect(); + for r in schema["required"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + { + assert!( + bound.contains(&r), + "{}: required `{r}` has no HTTP binding", + t.name() + ); + } + } + } + #[test] fn all_tools_have_unique_names() { let mut names: Vec<&str> = McpTool::ALL.iter().map(|t| t.name()).collect(); @@ -588,7 +875,7 @@ mod tests { names.sort_unstable(); names.dedup(); assert_eq!(names.len(), before, "duplicate McpTool name in ALL"); - assert_eq!(McpTool::ALL.len(), 22); + assert_eq!(McpTool::ALL.len(), 32); } #[test] diff --git a/crates/kr0ki-core/src/reqif_export.rs b/crates/kr0ki-core/src/reqif_export.rs index 15d09c9..ad54261 100644 --- a/crates/kr0ki-core/src/reqif_export.rs +++ b/crates/kr0ki-core/src/reqif_export.rs @@ -30,25 +30,41 @@ //! XML that `ufo_types::reqif::bundle_to_requirement_graph` rejects on //! re-import ("relation has an unknown endpoint"). //! -//! **What is not preserved through export.** Only ids/titles/texts/ -//! asserted relations round-trip (design doc Β§1/Β§4). Specifically dropped, -//! deliberately: -//! - `Requirement.attributes` -- all vendor/original attributes beyond -//! title/text. Re-import always derives `subtypes = "requirement"` since -//! only one `SpecObjectType` is synthesized here. -//! - `RequirementRelation.promotion` -- a promoted relation's audit trail. -//! It re-imports looking like it was always `Asserted`. -//! - `BaselineIdentity.revision` and other provenance fields -- not part -//! of ReqIF's own schema; there is no field to carry them in. +//! **What is preserved through export (KR-A01).** Identifiers, titles, texts, *typed +//! attributes* and asserted relations. Every `Requirement.attributes` entry becomes a ReqIF +//! attribute on the single `SpecObjectType`, named by its key. An attribute whose key has a +//! closed profile vocabulary (`ufo_types::mbse::assurance::attribute_vocabulary`: `node_kind`, +//! `source_kind`, `status`) is declared as an ENUMERATION when *every* value in the graph is in +//! that vocabulary, and as a STRING otherwise -- so an off-vocabulary value is carried, never +//! dropped or coerced. `tests/assurance_a01.rs` and `reqif_roundtrip` compare original and +//! reimported content semantically. +//! +//! **What is not preserved, deliberately and reported by `reqif_roundtrip`:** +//! - attribute keys ReqIF import would rewrite (`normalize_key`: upper case, `-`, space) or that +//! collide with the reserved `text` attribute -- they are not exported; +//! - `RequirementRelation.promotion` -- a promoted relation's audit trail. It re-imports looking +//! like it was always `Asserted`; +//! - non-asserted (inferred/proposed) relations, and any relation touching a non-requirement +//! endpoint (evidence); +//! - `graph.evidence` and `Requirement.evidence` -- evidence is revision-bound and lives in the +//! evidence store, not in the exchange file; +//! - per-node provenance and `BaselineIdentity.revision` -- not part of ReqIF's own schema; +//! re-import regenerates provenance from its config. + +use std::collections::{BTreeMap, BTreeSet}; use crate::requirements::{Requirement, RequirementGraph, RequirementRelationKind}; +use reqrs::ids::EnumValueId; use reqrs::model::{ - AttributeDefCommon, AttributeDefinition, AttributeDefinitionString, AttributeValue, - AttributeValueString, CoreContent, DataType, DataTypeCommon, DataTypeString, - DefaultValuePresence, ListForms, NamespaceInfo, ObjectLookup, ReqIfBundle, ReqIfContent, - ReqIfHeader, SpecObject, SpecObjectType, SpecRelationType, SpecType, SpecTypeCommon, + AttributeDefCommon, AttributeDefinition, AttributeDefinitionEnumeration, + AttributeDefinitionString, AttributeValue, AttributeValueEnumeration, AttributeValueString, + CoreContent, DataType, DataTypeCommon, DataTypeEnumeration, DataTypeString, + DefaultValuePresence, EnumValue, ListForms, NamespaceInfo, ObjectLookup, ReqIfBundle, + ReqIfContent, ReqIfHeader, SpecObject, SpecObjectType, SpecRelationType, SpecType, + SpecTypeCommon, }; use reqrs::{AttributeDefId, DataTypeId, SpecObjectId, SpecRelationId, SpecTypeId}; +use ufo_types::mbse::assurance::attribute_vocabulary; #[derive(Debug, thiserror::Error)] pub enum ReqIfExportError { @@ -57,12 +73,12 @@ pub enum ReqIfExportError { } pub fn export_bundle(graph: &RequirementGraph) -> Result { - let layer = type_layer(); + let layer = type_layer(graph); let spec_objects: Vec = graph .requirements .iter() - .map(|r| requirement_to_spec_object(r, &layer.text_attr_def_id)) + .map(|r| requirement_to_spec_object(r, &layer)) .collect(); // Only `graph.requirements` become `SpecObject`s (never `graph.evidence` @@ -87,7 +103,7 @@ pub fn export_bundle(graph: &RequirementGraph) -> Result Result, + }, +} struct TypeLayer { - data_type: DataType, + data_types: Vec, text_attr_def_id: AttributeDefId, + /// Requirement attribute key -> its declaration. + attributes: BTreeMap, spec_object_type: SpecType, relation_types: Vec, } -fn data_type_common() -> DataTypeCommon { +fn data_type_common(was_self_closing: bool) -> DataTypeCommon { DataTypeCommon { description: None, last_change: None, long_name: None, - was_self_closing: true, + was_self_closing, comments_before: Vec::new(), } } -fn attr_def_common() -> AttributeDefCommon { +fn attr_def_common(long_name: &str) -> AttributeDefCommon { AttributeDefCommon { description: None, last_change: None, - long_name: Some("Text".to_string()), + long_name: Some(long_name.to_string()), is_editable: None, was_self_closing: false, } @@ -186,6 +216,12 @@ fn spec_type_common( } } +/// `true` when `key` survives ReqIF import's `normalize_key` unchanged and is not reserved. +/// `pub(crate)` so `reqif_roundtrip` classifies the same keys as unsupported. +pub(crate) fn is_exportable_attribute_key(key: &str) -> bool { + !key.is_empty() && key != TEXT_KEY && key == key.to_lowercase().replace([' ', '-'], "_") +} + /// Every `RequirementRelationKind` variant, purely for `type_layer()`'s /// iteration -- `ufo_types::mbse::requirements::RequirementRelationKind` is a /// foreign enum (from the `ufo-types` git dependency), so nothing here can @@ -252,25 +288,86 @@ fn relation_long_name(kind: RequirementRelationKind) -> &'static str { } } -fn type_layer() -> TypeLayer { - let data_type = DataType::String(DataTypeString { +fn type_layer(graph: &RequirementGraph) -> TypeLayer { + // Which keys, with which values, appear in this graph? + let mut values_by_key: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for req in &graph.requirements { + for (key, value) in &req.attributes { + if is_exportable_attribute_key(key) { + values_by_key.entry(key).or_default().insert(value); + } + } + } + + let mut data_types = vec![DataType::String(DataTypeString { identifier: DataTypeId::new(DATA_TYPE_STRING_ID), - common: data_type_common(), + common: data_type_common(true), max_length: None, - }); - let text_attr_def_id = AttributeDefId::new(TEXT_ATTR_DEF_ID); - let text_attr_def = AttributeDefinition::String(AttributeDefinitionString { - identifier: text_attr_def_id.clone(), - common: attr_def_common(), + })]; + let mut attribute_defs = vec![AttributeDefinition::String(AttributeDefinitionString { + identifier: AttributeDefId::new(TEXT_ATTR_DEF_ID), + common: attr_def_common("Text"), type_ref: DataTypeId::new(DATA_TYPE_STRING_ID), default_value: DefaultValuePresence::Absent, - }); + })]; + let mut attributes = BTreeMap::new(); + + for (key, seen) in &values_by_key { + let def_id = AttributeDefId::new(format!("AD-ATTR-{key}")); + let vocabulary = + attribute_vocabulary(key).filter(|vocab| seen.iter().all(|v| vocab.contains(v))); + match vocabulary { + Some(vocab) => { + let type_id = format!("DT-ENUM-{key}"); + let values: BTreeMap = vocab + .iter() + .map(|v| ((*v).to_string(), EnumValueId::new(format!("EV-{key}-{v}")))) + .collect(); + data_types.push(DataType::Enumeration(DataTypeEnumeration { + identifier: DataTypeId::new(type_id.as_str()), + common: data_type_common(false), + specified_values: Some( + vocab + .iter() + .map(|v| EnumValue { + identifier: values[*v].clone(), + long_name: Some((*v).to_string()), + description: None, + last_change: None, + key: (*v).to_string(), + other_content: None, + }) + .collect(), + ), + })); + attribute_defs.push(AttributeDefinition::Enumeration( + AttributeDefinitionEnumeration { + identifier: def_id.clone(), + common: attr_def_common(key), + type_ref: DataTypeId::new(type_id.as_str()), + default_value: DefaultValuePresence::Absent, + multi_valued: Some(false), + }, + )); + attributes.insert( + (*key).to_string(), + AttrBinding::Enumeration { def_id, values }, + ); + } + None => { + attribute_defs.push(AttributeDefinition::String(AttributeDefinitionString { + identifier: def_id.clone(), + common: attr_def_common(key), + type_ref: DataTypeId::new(DATA_TYPE_STRING_ID), + default_value: DefaultValuePresence::Absent, + })); + attributes.insert((*key).to_string(), AttrBinding::Text(def_id)); + } + } + } + let spec_object_type = SpecType::SpecObject(SpecObjectType { - common: spec_type_common( - SPEC_OBJECT_TYPE_ID, - "Requirement", - Some(vec![text_attr_def]), - ), + common: spec_type_common(SPEC_OBJECT_TYPE_ID, "Requirement", Some(attribute_defs)), }); let relation_types = ALL_RELATION_KINDS .iter() @@ -285,25 +382,52 @@ fn type_layer() -> TypeLayer { }) .collect(); TypeLayer { - data_type, - text_attr_def_id, + data_types, + text_attr_def_id: AttributeDefId::new(TEXT_ATTR_DEF_ID), + attributes, spec_object_type, relation_types, } } -fn requirement_to_spec_object(req: &Requirement, text_attr_def_id: &AttributeDefId) -> SpecObject { +fn requirement_to_spec_object(req: &Requirement, layer: &TypeLayer) -> SpecObject { + let mut attributes = vec![AttributeValue::String(AttributeValueString { + definition_ref: layer.text_attr_def_id.clone(), + value: req.text.clone(), + comments_before: Vec::new(), + })]; + for (key, value) in &req.attributes { + match layer.attributes.get(key) { + Some(AttrBinding::Text(def_id)) => { + attributes.push(AttributeValue::String(AttributeValueString { + definition_ref: def_id.clone(), + value: value.clone(), + comments_before: Vec::new(), + })); + } + Some(AttrBinding::Enumeration { def_id, values }) => { + // `type_layer` only declares an enumeration when every value is in the + // vocabulary, so this lookup cannot miss. + if let Some(id) = values.get(value) { + attributes.push(AttributeValue::Enumeration(AttributeValueEnumeration { + definition_ref: def_id.clone(), + values: vec![id.clone()], + was_definition_first: true, + comments_before: Vec::new(), + })); + } + } + // Not exportable (renamed by import, or reserved): reported by `reqif_roundtrip`. + None => {} + } + } SpecObject { identifier: SpecObjectId::new(req.id.as_str()), description: None, last_change: None, long_name: Some(req.title.clone()), spec_object_type: SpecTypeId::new(SPEC_OBJECT_TYPE_ID), - attributes: vec![AttributeValue::String(AttributeValueString { - definition_ref: text_attr_def_id.clone(), - value: req.text.clone(), - comments_before: Vec::new(), - })], + attributes, children_order: Vec::new(), comments_before: Vec::new(), values_trailing_comments: Vec::new(), @@ -372,6 +496,15 @@ mod tests { } } + fn graph_of(requirements: Vec) -> RequirementGraph { + RequirementGraph { + baseline: baseline(), + requirements, + evidence: Vec::new(), + relations: Vec::new(), + } + } + fn relation( id: &str, source: &str, @@ -392,13 +525,13 @@ mod tests { #[test] fn requirement_maps_title_to_long_name_and_text_to_the_text_attribute() { - let layer = super::type_layer(); let req = requirement( "REQ-1", "Encrypt at rest", "System shall encrypt data at rest.", ); - let spec_object = super::requirement_to_spec_object(&req, &layer.text_attr_def_id); + let layer = super::type_layer(&graph_of(vec![req.clone()])); + let spec_object = super::requirement_to_spec_object(&req, &layer); assert_eq!(spec_object.identifier.as_str(), "REQ-1"); assert_eq!(spec_object.long_name.as_deref(), Some("Encrypt at rest")); @@ -418,7 +551,7 @@ mod tests { #[test] fn type_layer_has_one_spec_object_type_and_ten_relation_types() { - let layer = super::type_layer(); + let layer = super::type_layer(&graph_of(vec![])); assert_eq!(layer.relation_types.len(), 10); match &layer.spec_object_type { reqrs::model::SpecType::SpecObject(t) => { @@ -429,6 +562,95 @@ mod tests { } } + fn with_attrs(id: &str, attrs: &[(&str, &str)]) -> Requirement { + let mut r = requirement(id, id, "The component shall behave."); + for (k, v) in attrs { + r.attributes.insert((*k).to_string(), (*v).to_string()); + } + r + } + + fn attr_binding<'a>(layer: &'a super::TypeLayer, key: &str) -> &'a super::AttrBinding { + layer + .attributes + .get(key) + .unwrap_or_else(|| panic!("no binding for {key}")) + } + + #[test] + fn attributes_become_declared_reqif_attributes_not_dropped() { + let req = with_attrs("R1", &[("owner", "team-a"), ("rationale", "because")]); + let layer = super::type_layer(&graph_of(vec![req.clone()])); + let so = super::requirement_to_spec_object(&req, &layer); + // text + owner + rationale + assert_eq!(so.attributes.len(), 3); + assert!(matches!( + attr_binding(&layer, "owner"), + super::AttrBinding::Text(_) + )); + match &layer.spec_object_type { + reqrs::model::SpecType::SpecObject(t) => { + // text + owner + rationale + assert_eq!(t.common.spec_attributes.as_ref().unwrap().len(), 3); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn a_profile_vocabulary_attribute_is_declared_as_an_enumeration() { + let req = with_attrs("R1", &[("status", "gated"), ("source_kind", "guidance")]); + let layer = super::type_layer(&graph_of(vec![req.clone()])); + assert!(matches!( + attr_binding(&layer, "status"), + super::AttrBinding::Enumeration { .. } + )); + assert!(matches!( + attr_binding(&layer, "source_kind"), + super::AttrBinding::Enumeration { .. } + )); + // string datatype + one enumeration datatype per enumerated key + assert_eq!(layer.data_types.len(), 3); + let so = super::requirement_to_spec_object(&req, &layer); + let enums = so + .attributes + .iter() + .filter(|a| matches!(a, reqrs::model::AttributeValue::Enumeration(_))) + .count(); + assert_eq!(enums, 2); + } + + #[test] + fn one_off_vocabulary_value_makes_the_whole_key_a_string_so_nothing_is_lost() { + let good = with_attrs("R1", &[("status", "gated")]); + let odd = with_attrs("R2", &[("status", "on-hold-pending-legal")]); + let layer = super::type_layer(&graph_of(vec![good.clone(), odd.clone()])); + assert!(matches!( + attr_binding(&layer, "status"), + super::AttrBinding::Text(_) + )); + let so = super::requirement_to_spec_object(&odd, &layer); + let carried = so.attributes.iter().any(|a| match a { + reqrs::model::AttributeValue::String(v) => v.value == "on-hold-pending-legal", + _ => false, + }); + assert!(carried, "the off-vocabulary value must still be exported"); + } + + #[test] + fn keys_that_import_would_rewrite_or_that_are_reserved_are_not_exported() { + for key in ["Owner", "has-dash", "has space", "text", ""] { + assert!(!super::is_exportable_attribute_key(key), "{key:?}"); + } + for key in ["owner", "node_kind", "verification_id", "a1"] { + assert!(super::is_exportable_attribute_key(key), "{key:?}"); + } + let req = with_attrs("R1", &[("Owner", "x"), ("text", "y"), ("owner", "z")]); + let layer = super::type_layer(&graph_of(vec![req.clone()])); + let so = super::requirement_to_spec_object(&req, &layer); + assert_eq!(so.attributes.len(), 2); // text (the statement) + owner + } + #[test] fn relation_type_id_covers_every_kind_with_the_recognized_name() { use RequirementRelationKind::*; @@ -447,7 +669,7 @@ mod tests { // Every kind must resolve to a SpecTypeId (no panic) -- and the // id must be present among type_layer()'s relation_types. let id = super::relation_type_id(kind); - let layer = super::type_layer(); + let layer = super::type_layer(&graph_of(vec![])); let found = layer.relation_types.iter().any(|st| match st { reqrs::model::SpecType::SpecRelation(t) => t.common.identifier == id, _ => false, diff --git a/crates/kr0ki-core/src/reqif_roundtrip.rs b/crates/kr0ki-core/src/reqif_roundtrip.rs new file mode 100644 index 0000000..d77b0cd --- /dev/null +++ b/crates/kr0ki-core/src/reqif_roundtrip.rs @@ -0,0 +1,404 @@ +//! KR-A01: export a `RequirementGraph` to ReqIF, reimport it, and compare **semantic +//! content** β€” not bytes β€” reporting exactly what did not survive. +//! +//! Three outcomes for anything in the original graph, kept apart on purpose: +//! +//! - **preserved** β€” present and equal after reimport (counted); +//! - **difference** β€” *should* have survived and did not: a defect. A lossless round trip has +//! none ([`RoundTripReport::is_lossless`]); +//! - **unsupported** β€” content the ReqIF export deliberately does not carry (see +//! `reqif_export`'s module doc). Listed explicitly with the ids affected, never dropped +//! silently and never counted as a defect. +//! +//! A fourth list, **normalised**, records content the *importer* adds that is not in the +//! original (`text` as an attribute, a synthesised `subtypes`), so the comparison can ignore +//! it without hiding it. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use ufo_types::mbse::requirements::{RelationAuthority, Requirement, RequirementGraph}; +use ufo_types::reqif::{parse_and_lower, ReqIfAdapterConfig, ReqIfAdapterError}; + +use crate::assurance_baseline::sha256_hex; +use crate::reqif_export::{export_bundle_to_xml, is_exportable_attribute_key, ReqIfExportError}; + +/// Why a round trip could not be run at all. +#[derive(Debug, thiserror::Error)] +pub enum RoundTripError { + #[error("ReqIF export: {0}")] + Export(#[from] ReqIfExportError), + #[error("ReqIF reimport: {0}")] + Import(#[from] ReqIfAdapterError), +} + +/// Something that should have survived the round trip and did not. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "difference", rename_all = "snake_case")] +pub enum Difference { + MissingRequirement { + id: String, + }, + ExtraRequirement { + id: String, + }, + TitleChanged { + id: String, + before: String, + after: String, + }, + TextChanged { + id: String, + before: String, + after: String, + }, + AttributeLost { + id: String, + key: String, + }, + AttributeAdded { + id: String, + key: String, + }, + AttributeChanged { + id: String, + key: String, + before: String, + after: String, + }, + RelationLost { + id: String, + }, + RelationAdded { + id: String, + }, + RelationChanged { + id: String, + field: &'static str, + before: String, + after: String, + }, +} + +/// Kinds of content the ReqIF export deliberately does not carry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnsupportedKind { + /// `graph.evidence`: revision-bound, lives in the evidence store. + EvidenceNodes, + /// `Requirement.evidence` references. + RequirementEvidenceRefs, + /// Inferred or proposed relations (review-only, not authoritative). + NonAssertedRelations, + /// Asserted relations whose endpoint is not a requirement (e.g. evidence). + RelationsToNonRequirements, + /// The audit trail of a promoted relation. + RelationPromotion, + /// Per-node provenance; re-import regenerates it from its config. + NodeProvenance, + /// An attribute key ReqIF import would rewrite (upper case, `-`, space) or that is reserved. + AttributeKeyNotRepresentable, +} + +/// One kind of unsupported content and the ids it touches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Unsupported { + pub kind: UnsupportedKind, + pub note: &'static str, + /// Requirement, relation or evidence ids; `id:key` for attribute keys. + pub ids: Vec, +} + +/// Content added by the importer that is ignored in the comparison. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Normalisation { + pub attribute: String, + pub note: &'static str, + pub ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct RoundTripReport { + pub requirements_compared: usize, + pub attributes_compared: usize, + pub relations_compared: usize, + pub differences: Vec, + pub unsupported: Vec, + pub normalised: Vec, +} + +impl RoundTripReport { + /// No defect: everything the export claims to carry came back equal. + pub fn is_lossless(&self) -> bool { + self.differences.is_empty() + } + + pub fn unsupported_of(&self, kind: UnsupportedKind) -> Option<&Unsupported> { + self.unsupported.iter().find(|u| u.kind == kind) + } +} + +/// The result of one export β†’ reimport. +#[derive(Debug, Clone)] +pub struct RoundTrip { + pub xml: String, + pub xml_sha256: String, + pub reimported: RequirementGraph, + pub report: RoundTripReport, +} + +/// Export `graph` to ReqIF, reimport it, and compare. +pub fn round_trip(graph: &RequirementGraph) -> Result { + let xml = export_bundle_to_xml(graph)?; + let xml_sha256 = sha256_hex(xml.as_bytes()); + let config = ReqIfAdapterConfig { + source_uri: "reqif-roundtrip".to_string(), + revision: graph.baseline.revision.clone(), + import_artifact_sha256: Some(xml_sha256.clone()), + }; + let reimported = parse_and_lower(xml.as_bytes(), &config)?; + let report = compare(graph, &reimported); + Ok(RoundTrip { + xml, + xml_sha256, + reimported, + report, + }) +} + +/// Compare an original graph with its reimported form. +pub fn compare(original: &RequirementGraph, reimported: &RequirementGraph) -> RoundTripReport { + let mut report = RoundTripReport::default(); + let after: BTreeMap<&str, &Requirement> = reimported + .requirements + .iter() + .map(|r| (r.id.as_str(), r)) + .collect(); + let original_ids: BTreeSet<&str> = original + .requirements + .iter() + .map(|r| r.id.as_str()) + .collect(); + + let mut unsupported: BTreeMap> = BTreeMap::new(); + let mut normalised: BTreeMap<&'static str, Vec> = BTreeMap::new(); + + for before in &original.requirements { + let Some(now) = after.get(before.id.as_str()) else { + report.differences.push(Difference::MissingRequirement { + id: before.id.clone(), + }); + continue; + }; + report.requirements_compared += 1; + if before.title != now.title { + report.differences.push(Difference::TitleChanged { + id: before.id.clone(), + before: before.title.clone(), + after: now.title.clone(), + }); + } + if before.text != now.text { + report.differences.push(Difference::TextChanged { + id: before.id.clone(), + before: before.text.clone(), + after: now.text.clone(), + }); + } + + // What the export is expected to carry, and what was never representable. + let mut expected: BTreeMap<&str, &str> = BTreeMap::new(); + for (key, value) in &before.attributes { + if is_exportable_attribute_key(key) { + expected.insert(key, value); + } else { + unsupported + .entry(UnsupportedKind::AttributeKeyNotRepresentable) + .or_default() + .push(format!("{}:{key}", before.id)); + } + } + // What came back, less what the importer adds by itself. + let mut actual: BTreeMap<&str, &str> = BTreeMap::new(); + for (key, value) in &now.attributes { + if key == "text" && value == &before.text { + normalised + .entry("text") + .or_default() + .push(before.id.clone()); + } else if key == "subtypes" + && value == "requirement" + && !before.attributes.contains_key("subtypes") + { + normalised + .entry("subtypes") + .or_default() + .push(before.id.clone()); + } else { + actual.insert(key, value); + } + } + for (key, want) in &expected { + report.attributes_compared += 1; + match actual.get(key) { + None => report.differences.push(Difference::AttributeLost { + id: before.id.clone(), + key: (*key).to_string(), + }), + Some(got) if got != want => report.differences.push(Difference::AttributeChanged { + id: before.id.clone(), + key: (*key).to_string(), + before: (*want).to_string(), + after: (*got).to_string(), + }), + Some(_) => {} + } + } + for key in actual.keys().filter(|k| !expected.contains_key(*k)) { + report.differences.push(Difference::AttributeAdded { + id: before.id.clone(), + key: (*key).to_string(), + }); + } + + if before.provenance != now.provenance { + unsupported + .entry(UnsupportedKind::NodeProvenance) + .or_default() + .push(before.id.clone()); + } + if !before.evidence.is_empty() { + unsupported + .entry(UnsupportedKind::RequirementEvidenceRefs) + .or_default() + .push(before.id.clone()); + } + } + for id in after.keys().filter(|id| !original_ids.contains(*id)) { + report.differences.push(Difference::ExtraRequirement { + id: (*id).to_string(), + }); + } + + if !original.evidence.is_empty() { + unsupported + .entry(UnsupportedKind::EvidenceNodes) + .or_default() + .extend(original.evidence.iter().map(|e| e.id.clone())); + } + + // Relations: only asserted, requirement-to-requirement edges are carried. + let reimported_relations: BTreeMap<&str, _> = reimported + .relations + .iter() + .map(|r| (r.id.as_str(), r)) + .collect(); + let mut expected_relation_ids: BTreeSet<&str> = BTreeSet::new(); + for r in &original.relations { + if !matches!(r.authority, RelationAuthority::Asserted) { + unsupported + .entry(UnsupportedKind::NonAssertedRelations) + .or_default() + .push(r.id.clone()); + continue; + } + if !original_ids.contains(r.source.as_str()) || !original_ids.contains(r.target.as_str()) { + unsupported + .entry(UnsupportedKind::RelationsToNonRequirements) + .or_default() + .push(r.id.clone()); + continue; + } + expected_relation_ids.insert(&r.id); + report.relations_compared += 1; + if r.promotion.is_some() { + unsupported + .entry(UnsupportedKind::RelationPromotion) + .or_default() + .push(r.id.clone()); + } + match reimported_relations.get(r.id.as_str()) { + None => report + .differences + .push(Difference::RelationLost { id: r.id.clone() }), + Some(now) => { + let mut changed = |field: &'static str, before: String, after: String| { + if before != after { + report.differences.push(Difference::RelationChanged { + id: r.id.clone(), + field, + before, + after, + }); + } + }; + changed("kind", format!("{:?}", r.kind), format!("{:?}", now.kind)); + changed("source", r.source.clone(), now.source.clone()); + changed("target", r.target.clone(), now.target.clone()); + } + } + } + for id in reimported_relations + .keys() + .filter(|id| !expected_relation_ids.contains(*id)) + { + report.differences.push(Difference::RelationAdded { + id: (*id).to_string(), + }); + } + + report.unsupported = unsupported + .into_iter() + .map(|(kind, mut ids)| { + ids.sort(); + ids.dedup(); + Unsupported { + kind, + note: unsupported_note(kind), + ids, + } + }) + .collect(); + report.normalised = normalised + .into_iter() + .map(|(attribute, mut ids)| { + ids.sort(); + ids.dedup(); + Normalisation { + attribute: attribute.to_string(), + note: match attribute { + "text" => "the statement travels as the `text` attribute; the importer exposes it again", + _ => "the importer derives `subtypes` from the spec-object type name", + }, + ids, + } + }) + .collect(); + report +} + +fn unsupported_note(kind: UnsupportedKind) -> &'static str { + match kind { + UnsupportedKind::EvidenceNodes => { + "evidence is revision-bound and lives in the evidence store, not the exchange file" + } + UnsupportedKind::RequirementEvidenceRefs => { + "per-requirement evidence references are not exported" + } + UnsupportedKind::NonAssertedRelations => { + "inferred and proposed relations are review-only and are not exported" + } + UnsupportedKind::RelationsToNonRequirements => { + "a relation with a non-requirement endpoint would dangle in ReqIF and is not exported" + } + UnsupportedKind::RelationPromotion => { + "the promotion audit trail has no ReqIF field; the relation reimports as plain asserted" + } + UnsupportedKind::NodeProvenance => { + "provenance is not part of ReqIF; reimport regenerates it from its config" + } + UnsupportedKind::AttributeKeyNotRepresentable => { + "the key would be rewritten by ReqIF import (upper case, '-', space) or is reserved" + } + } +} diff --git a/crates/kr0ki-core/src/sync_engine.rs b/crates/kr0ki-core/src/sync_engine.rs index 539ebb2..6a74e52 100644 --- a/crates/kr0ki-core/src/sync_engine.rs +++ b/crates/kr0ki-core/src/sync_engine.rs @@ -69,7 +69,8 @@ pub(crate) fn element_identifier(element: &Element) -> Option<&str> { /// An element a sync wants to exist, identified by `identifier`, together with the /// writable `fields` the sync owns (anything else on the server element is not ours and /// is preserved on update). -pub(crate) struct DesiredElement { +#[derive(Clone)] +pub struct DesiredElement { pub identifier: String, pub type_: &'static str, pub fields: Map, diff --git a/crates/kr0ki-core/src/verification_runner.rs b/crates/kr0ki-core/src/verification_runner.rs new file mode 100644 index 0000000..8165573 --- /dev/null +++ b/crates/kr0ki-core/src/verification_runner.rs @@ -0,0 +1,919 @@ +//! KR-A04: run a declared verification case and **bind the result to the model, +//! implementation and configuration revisions it ran against**. +//! +//! A result is only evidence for the revisions it ran at. The runner therefore never produces +//! a bare pass/fail: every [`EvidenceRecord`] carries +//! +//! - `model_revision` β€” supplied by the caller (a model-server commit id, or the content +//! address of a model file); +//! - `implementation_revision` β€” the repository's `HEAD`, plus a digest of any uncommitted +//! change, so a dirty tree is a *different* revision from the clean commit; +//! - `configuration_digest` β€” a digest of what the case checks: its command, its acceptance +//! text, the statement and acceptance of every requirement it verifies, and the toolchain. +//! +//! `ufo_types::mbse::assurance` then decides freshness by comparing those three with the +//! current ones; this module does not. +//! +//! # What may run +//! +//! A case's command is an argv, never a shell string, and only +//! `cargo test -p --test ` is accepted (`assurance_trace::cargo_test_target`). +//! The child gets a cleared environment and a time limit. This is a guard against a malformed +//! baseline, **not a sandbox**: the test code itself is the repository's, and confining what it +//! may write is the execution sandbox's job (KR-A07), in its deployment component. +//! +//! # What counts as a pass +//! +//! Exit status 0 **and** at least one test passed **and** none failed. A run that executed zero +//! tests (a typo in a test filter, a renamed target) exits 0 and proves nothing, so it is an +//! `Error`, never a `Pass`. A build failure or timeout is an `Error`; tests that ran and failed +//! are a `Fail`. + +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use ufo_types::mbse::assurance::{ + CurrentRevisions, EvidenceRecord, NodeKind, RevisionSet, VerificationResult, NODE_KIND_ATTR, +}; +use ufo_types::mbse::requirements::{RelationAuthority, RequirementGraph, RequirementRelationKind}; + +use crate::assurance_baseline::sha256_hex; +use crate::assurance_trace::cargo_test_target; +use crate::evidence_store::{EvidenceStoreError, FsEvidenceStore}; + +#[derive(Debug, thiserror::Error)] +pub enum RunnerError { + #[error("case `{0}` is not in the baseline")] + UnknownCase(String), + #[error("case `{case}`: command {argv:?} is not `cargo test -p --test `")] + NotAllowlisted { case: String, argv: Vec }, + #[error("case `{0}` verifies no requirement, so its result would be evidence for nothing")] + NoRequirement(String), + #[error("revision probe: {0}")] + Probe(String), + #[error(transparent)] + Store(#[from] EvidenceStoreError), +} + +/// A verification case read from the baseline. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerificationCase { + pub id: String, + pub argv: Vec, + pub acceptance: String, + /// `(requirement id, statement, acceptance)` of each requirement it verifies, sorted by id. + pub requirements: Vec<(String, String, String)>, +} + +/// The cases of a graph, sorted by id. +pub fn cases_from_graph(graph: &RequirementGraph) -> Vec { + let mut out: Vec = graph + .requirements + .iter() + .filter(|n| { + n.attributes.get(NODE_KIND_ATTR).map(String::as_str) + == Some(NodeKind::VerificationCase.as_str()) + }) + .map(|n| { + let mut requirements: Vec<(String, String, String)> = graph + .relations + .iter() + .filter(|r| { + matches!(r.authority, RelationAuthority::Asserted) + && r.kind == RequirementRelationKind::Verifies + && r.source == n.id + }) + .filter_map(|r| graph.requirements.iter().find(|q| q.id == r.target)) + .map(|q| { + ( + q.id.clone(), + q.text.clone(), + q.attributes.get("acceptance").cloned().unwrap_or_default(), + ) + }) + .collect(); + requirements.sort(); + requirements.dedup(); + VerificationCase { + id: n.id.clone(), + argv: n + .attributes + .get("command") + .and_then(|c| serde_json::from_str(c).ok()) + .unwrap_or_default(), + acceptance: n.text.clone(), + requirements, + } + }) + .collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + out +} + +impl VerificationCase { + /// Digest of what this case checks (see the module doc). Stable under unrelated edits to + /// the baseline; changes when the command, its acceptance, or any verified requirement's + /// statement or acceptance changes. + pub fn configuration_digest(&self, toolchain: &str) -> String { + let spec = serde_json::json!({ + "acceptance": self.acceptance, + "argv": self.argv, + "case": self.id, + "requirements": self.requirements, + "toolchain": toolchain, + }); + // `serde_json::Value` objects are key-sorted (BTreeMap), so this is canonical. + format!("sha256:{}", sha256_hex(spec.to_string().as_bytes())) + } +} + +/// What is current now: the supplied model revision, the probe's implementation revision, and +/// each declared case's own configuration digest. +pub fn current_revisions( + graph: &RequirementGraph, + probe: &dyn RevisionProbe, + model_revision: &str, +) -> Result { + let toolchain = probe.toolchain(); + let mut current = CurrentRevisions::new(model_revision, probe.implementation_revision()?); + for case in cases_from_graph(graph) { + let digest = case.configuration_digest(&toolchain); + current = current.with_case(case.id, digest); + } + Ok(current) +} + +// ── Execution ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct ExecRequest { + pub argv: Vec, + pub cwd: PathBuf, + pub timeout: Duration, + pub env: Vec<(String, String)>, +} + +#[derive(Debug, Clone, Default)] +pub struct ExecResult { + pub exit_code: Option, + pub timed_out: bool, + /// Combined stdout and stderr, capped to the last [`OUTPUT_CAP`] bytes. + pub output: Vec, + pub spawn_error: Option, +} + +pub const OUTPUT_CAP: u64 = 4 * 1024 * 1024; + +pub trait CommandExecutor: Send + Sync { + fn run(&self, req: &ExecRequest) -> ExecResult; +} + +/// Runs the argv as a child process: no shell, cleared environment (only `req.env`), its own +/// process group so a timeout kills the whole `cargo` tree, output captured to a file so a +/// chatty child cannot block on a full pipe. +#[derive(Debug, Clone, Default)] +pub struct ProcessExecutor; + +impl CommandExecutor for ProcessExecutor { + fn run(&self, req: &ExecRequest) -> ExecResult { + use std::os::unix::process::CommandExt; + + let Some((program, args)) = req.argv.split_first() else { + return ExecResult { + spawn_error: Some("empty argv".into()), + ..Default::default() + }; + }; + let log_path = std::env::temp_dir().join(format!( + "kr0ki-run-{}-{}.log", + std::process::id(), + sha256_hex(format!("{:?}{:?}", req.argv, Instant::now()).as_bytes()) + )); + let log = match fs::File::create(&log_path) { + Ok(f) => f, + Err(e) => { + return ExecResult { + spawn_error: Some(format!("cannot create log: {e}")), + ..Default::default() + }; + } + }; + let log_err = match log.try_clone() { + Ok(f) => f, + Err(e) => { + return ExecResult { + spawn_error: Some(format!("cannot clone log: {e}")), + ..Default::default() + }; + } + }; + let mut child = match Command::new(program) + .args(args) + .current_dir(&req.cwd) + .env_clear() + .envs(req.env.iter().map(|(k, v)| (k, v))) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(log_err)) + .process_group(0) + .spawn() + { + Ok(c) => c, + Err(e) => { + let _ = fs::remove_file(&log_path); + return ExecResult { + spawn_error: Some(format!("cannot spawn `{program}`: {e}")), + ..Default::default() + }; + } + }; + + let deadline = Instant::now() + req.timeout; + let (exit_code, timed_out) = loop { + match child.try_wait() { + Ok(Some(status)) => break (status.code(), false), + Ok(None) if Instant::now() >= deadline => { + // Kill the whole group, then reap. + let _ = Command::new("kill") + .args(["-KILL", &format!("-{}", child.id())]) + .status(); + let _ = child.kill(); + let _ = child.wait(); + break (None, true); + } + Ok(None) => std::thread::sleep(Duration::from_millis(50)), + Err(e) => { + let _ = fs::remove_file(&log_path); + return ExecResult { + spawn_error: Some(format!("wait failed: {e}")), + ..Default::default() + }; + } + } + }; + + let output = read_tail(&log_path, OUTPUT_CAP); + let _ = fs::remove_file(&log_path); + ExecResult { + exit_code, + timed_out, + output, + spawn_error: None, + } + } +} + +fn read_tail(path: &Path, cap: u64) -> Vec { + let Ok(mut f) = fs::File::open(path) else { + return Vec::new(); + }; + let len = f.metadata().map(|m| m.len()).unwrap_or(0); + if len > cap { + let _ = f.seek(SeekFrom::Start(len - cap)); + } + let mut buf = Vec::new(); + let _ = f.take(cap).read_to_end(&mut buf); + buf +} + +/// The environment a case may see: enough to find and run the toolchain, nothing else. +pub fn minimal_env() -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = [ + "PATH", + "HOME", + "CARGO_HOME", + "RUSTUP_HOME", + "CARGO_TARGET_DIR", + "TMPDIR", + ] + .iter() + .filter_map(|k| std::env::var(k).ok().map(|v| ((*k).to_string(), v))) + .collect(); + env.push(("CARGO_TERM_COLOR".into(), "never".into())); + env.push(("RUST_BACKTRACE".into(), "0".into())); + env +} + +// ── Revisions ─────────────────────────────────────────────────────────────────── + +/// Where the implementation revision and toolchain come from. +pub trait RevisionProbe: Send + Sync { + fn implementation_revision(&self) -> Result; + fn toolchain(&self) -> String; +} + +/// `git`-backed: `HEAD`, plus a digest of uncommitted changes (tracked diff and the status of +/// untracked files), so editing the tree changes the revision. +#[derive(Debug, Clone)] +pub struct GitRevisionProbe { + repo_root: PathBuf, +} + +impl GitRevisionProbe { + pub fn new(repo_root: impl Into) -> Self { + Self { + repo_root: repo_root.into(), + } + } + + fn git(&self, args: &[&str]) -> Result, String> { + let out = Command::new("git") + .args(args) + .current_dir(&self.repo_root) + .output() + .map_err(|e| format!("cannot run git: {e}"))?; + if out.status.success() { + Ok(out.stdout) + } else { + Err(format!( + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )) + } + } +} + +impl RevisionProbe for GitRevisionProbe { + fn implementation_revision(&self) -> Result { + let head = String::from_utf8_lossy(&self.git(&["rev-parse", "HEAD"])?) + .trim() + .to_string(); + let status = self.git(&["status", "--porcelain", "--untracked-files=all"])?; + if status.is_empty() { + return Ok(head); + } + let mut material = self.git(&["diff", "HEAD"])?; + material.extend_from_slice(&status); + // Untracked files' *content* matters too, not just their names. + for line in String::from_utf8_lossy(&status) + .lines() + .filter(|l| l.starts_with("??")) + { + let path = line[3..].trim(); + if let Ok(bytes) = fs::read(self.repo_root.join(path)) { + material.extend_from_slice(path.as_bytes()); + material.extend_from_slice(&bytes); + } + } + Ok(format!("{head}+dirty.{}", &sha256_hex(&material)[..12])) + } + + fn toolchain(&self) -> String { + Command::new("rustc") + .arg("--version") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "rustc-unknown".into()) + } +} + +// ── Classification ────────────────────────────────────────────────────────────── + +/// Sum the `N passed; M failed` of every `test result:` line in cargo's output. +pub fn parse_test_summary(output: &str) -> (usize, usize) { + // In `test result: ok. 3 passed; 0 failed; ...` the count is the token before the word. + fn count_before(line: &str, word: &str) -> usize { + line.split(';') + .find_map(|segment| { + let tokens: Vec<&str> = segment.split_whitespace().collect(); + let at = tokens.iter().position(|t| *t == word)?; + tokens.get(at.checked_sub(1)?)?.parse().ok() + }) + .unwrap_or(0) + } + output + .lines() + .filter(|l| l.trim_start().starts_with("test result:")) + .fold((0, 0), |(p, f), line| { + ( + p + count_before(line, "passed"), + f + count_before(line, "failed"), + ) + }) +} + +/// Decide the verdict for one execution. +pub fn classify(result: &ExecResult) -> (VerificationResult, String) { + if let Some(e) = &result.spawn_error { + return (VerificationResult::Error, format!("could not run: {e}")); + } + if result.timed_out { + return (VerificationResult::Error, "timed out".into()); + } + let text = String::from_utf8_lossy(&result.output); + let (passed, failed) = parse_test_summary(&text); + match result.exit_code { + Some(0) if failed == 0 && passed >= 1 => { + (VerificationResult::Pass, format!("{passed} tests passed")) + } + Some(0) if passed == 0 && failed == 0 => ( + VerificationResult::Error, + "no tests ran: a run that exercised nothing is not evidence".into(), + ), + _ if failed > 0 => ( + VerificationResult::Fail, + format!("{failed} tests failed, {passed} passed"), + ), + Some(code) => ( + VerificationResult::Error, + format!("exit status {code} with no test verdict (build failure?)"), + ), + None => (VerificationResult::Error, "terminated by a signal".into()), + } +} + +// ── The runner ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct RunnerConfig { + pub repo_root: PathBuf, + pub timeout: Duration, + /// The model revision the run is bound to (supplied by the caller). + pub model_revision: String, +} + +/// What one run produced. +#[derive(Debug, Clone)] +pub struct RunReport { + pub case_id: String, + pub result: VerificationResult, + pub detail: String, + pub revisions: RevisionSet, + /// One record per requirement the case verifies, all already stored. + pub records: Vec, +} + +pub struct VerificationRunner<'a> { + pub store: &'a FsEvidenceStore, + pub executor: &'a dyn CommandExecutor, + pub probe: &'a dyn RevisionProbe, + pub config: RunnerConfig, +} + +impl VerificationRunner<'_> { + /// Run `case`, store its log as an artifact and one evidence record per verified + /// requirement, and return the report. The revisions are read *before* the run, so a + /// change made while it runs shows up as stale next time, not as a mislabelled pass. + pub fn run_case(&self, case: &VerificationCase) -> Result { + if cargo_test_target(&case.argv).is_none() { + return Err(RunnerError::NotAllowlisted { + case: case.id.clone(), + argv: case.argv.clone(), + }); + } + if case.requirements.is_empty() { + return Err(RunnerError::NoRequirement(case.id.clone())); + } + let revisions = RevisionSet { + model_revision: self.config.model_revision.clone(), + implementation_revision: self + .probe + .implementation_revision() + .map_err(RunnerError::Probe)?, + configuration_digest: case.configuration_digest(&self.probe.toolchain()), + }; + + let result = self.executor.run(&ExecRequest { + argv: case.argv.clone(), + cwd: self.config.repo_root.clone(), + timeout: self.config.timeout, + env: minimal_env(), + }); + let (verdict, detail) = classify(&result); + + let mut log = format!( + "case: {}\nargv: {:?}\nmodel_revision: {}\nimplementation_revision: {}\nconfiguration_digest: {}\nverdict: {:?} ({})\nexit_code: {:?}\ntimed_out: {}\n--- output ---\n", + case.id, case.argv, revisions.model_revision, revisions.implementation_revision, + revisions.configuration_digest, verdict, detail, result.exit_code, result.timed_out + ) + .into_bytes(); + log.extend_from_slice(&result.output); + let (artifact_uri, artifact_digest) = self.store.put_artifact(&log)?; + + let mut records = Vec::new(); + for (requirement_id, _, _) in &case.requirements { + let record = EvidenceRecord { + requirement_id: requirement_id.clone(), + verification_id: case.id.clone(), + model_revision: revisions.model_revision.clone(), + implementation_revision: revisions.implementation_revision.clone(), + configuration_digest: revisions.configuration_digest.clone(), + result: verdict, + artifact_uri: artifact_uri.clone(), + artifact_digest: artifact_digest.clone(), + }; + self.store.put(&record)?; + records.push(record); + } + Ok(RunReport { + case_id: case.id.clone(), + result: verdict, + detail, + revisions, + records, + }) + } +} + +/// Group evidence by requirement id (for display). +pub fn by_requirement(records: &[EvidenceRecord]) -> BTreeMap<&str, Vec<&EvidenceRecord>> { + let mut out: BTreeMap<&str, Vec<&EvidenceRecord>> = BTreeMap::new(); + for r in records { + out.entry(r.requirement_id.as_str()).or_default().push(r); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn ok_output(n: usize) -> String { + format!("running {n} tests\ntest result: ok. {n} passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s\n") + } + + #[test] + fn summary_sums_every_test_result_line() { + let out = format!( + "{}{}", + ok_output(3), + "test result: FAILED. 2 passed; 1 failed; 0 ignored;\n" + ); + assert_eq!(parse_test_summary(&out), (5, 1)); + assert_eq!(parse_test_summary("nothing here"), (0, 0)); + assert_eq!( + parse_test_summary("test result: ok. 0 passed; 0 failed; 9 filtered out"), + (0, 0) + ); + } + + fn exec(code: Option, output: &str) -> ExecResult { + ExecResult { + exit_code: code, + output: output.as_bytes().to_vec(), + ..Default::default() + } + } + + #[test] + fn classification_is_strict_about_what_a_pass_is() { + assert_eq!( + classify(&exec(Some(0), &ok_output(4))).0, + VerificationResult::Pass + ); + // Exit 0 but nothing ran: NOT a pass. + let none = "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out;\n"; + assert_eq!(classify(&exec(Some(0), none)).0, VerificationResult::Error); + assert_eq!(classify(&exec(Some(0), "")).0, VerificationResult::Error); + // Tests ran and failed. + let failed = "test result: FAILED. 3 passed; 2 failed; 0 ignored;\n"; + assert_eq!( + classify(&exec(Some(101), failed)).0, + VerificationResult::Fail + ); + // Exit 0 but a failure line anyway: still not a pass. + assert_eq!(classify(&exec(Some(0), failed)).0, VerificationResult::Fail); + // Build failure: no verdict at all. + assert_eq!( + classify(&exec(Some(101), "error[E0432]: unresolved import")).0, + VerificationResult::Error + ); + assert_eq!(classify(&exec(None, "")).0, VerificationResult::Error); + assert_eq!( + classify(&ExecResult { + timed_out: true, + ..Default::default() + }) + .0, + VerificationResult::Error + ); + assert_eq!( + classify(&ExecResult { + spawn_error: Some("nope".into()), + ..Default::default() + }) + .0, + VerificationResult::Error + ); + } + + struct Fake { + result: ExecResult, + seen: Mutex>, + } + + impl CommandExecutor for Fake { + fn run(&self, req: &ExecRequest) -> ExecResult { + self.seen.lock().unwrap().push(req.clone()); + self.result.clone() + } + } + + struct FixedProbe(&'static str); + + impl RevisionProbe for FixedProbe { + fn implementation_revision(&self) -> Result { + Ok(self.0.into()) + } + + fn toolchain(&self) -> String { + "rustc-test".into() + } + } + + fn case(argv: &[&str]) -> VerificationCase { + VerificationCase { + id: "VC-T".into(), + argv: argv.iter().map(|s| s.to_string()).collect(), + acceptance: "it works".into(), + requirements: vec![( + "KR-T".into(), + "The thing shall work.".into(), + "it works".into(), + )], + } + } + + const OK_ARGV: [&str; 6] = ["cargo", "test", "-p", "kr0ki-core", "--test", "x"]; + + fn store(tag: &str) -> FsEvidenceStore { + let d = std::env::temp_dir().join(format!("kr0ki-runner-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + FsEvidenceStore::new(d) + } + + fn run( + fake: &Fake, + store: &FsEvidenceStore, + c: &VerificationCase, + impl_rev: &'static str, + ) -> Result { + VerificationRunner { + store, + executor: fake, + probe: &FixedProbe(impl_rev), + config: RunnerConfig { + repo_root: PathBuf::from("."), + timeout: Duration::from_secs(5), + model_revision: "model-1".into(), + }, + } + .run_case(c) + } + + #[test] + fn a_pass_is_bound_to_all_three_revisions_and_stored_with_its_log() { + let store = store("bind"); + let fake = Fake { + result: exec(Some(0), &ok_output(2)), + seen: Mutex::default(), + }; + let c = case(&OK_ARGV); + let report = run(&fake, &store, &c, "impl-1").unwrap(); + assert_eq!(report.result, VerificationResult::Pass); + let r = &report.records[0]; + assert_eq!(r.model_revision, "model-1"); + assert_eq!(r.implementation_revision, "impl-1"); + assert_eq!(r.configuration_digest, c.configuration_digest("rustc-test")); + assert_eq!(store.list().unwrap(), report.records); + // The stored artifact is the log and verifies. + let checks = store.verify().unwrap(); + assert_eq!(checks[0].1, crate::evidence_store::ArtifactCheck::Intact); + let log = fs::read_to_string(r.artifact_uri.strip_prefix("file://").unwrap()).unwrap(); + assert!(log.contains("verdict: Pass") && log.contains("2 passed")); + } + + #[test] + fn the_child_gets_a_cleared_environment_and_no_shell() { + let store = store("env"); + let fake = Fake { + result: exec(Some(0), &ok_output(1)), + seen: Mutex::default(), + }; + run(&fake, &store, &case(&OK_ARGV), "i").unwrap(); + let req = fake.seen.lock().unwrap()[0].clone(); + assert_eq!( + req.argv[0], "cargo", + "argv is passed through, not wrapped in `sh -c`" + ); + let keys: Vec<_> = req.env.iter().map(|(k, _)| k.as_str()).collect(); + assert!( + keys.iter().all(|k| [ + "PATH", + "HOME", + "CARGO_HOME", + "RUSTUP_HOME", + "CARGO_TARGET_DIR", + "TMPDIR", + "CARGO_TERM_COLOR", + "RUST_BACKTRACE" + ] + .contains(k)), + "{keys:?}" + ); + } + + #[test] + fn a_failure_and_an_error_are_recorded_as_such_never_as_a_pass() { + let store = store("neg"); + let c = case(&OK_ARGV); + let fail = Fake { + result: exec(Some(101), "test result: FAILED. 1 passed; 1 failed;\n"), + seen: Mutex::default(), + }; + assert_eq!( + run(&fail, &store, &c, "i1").unwrap().result, + VerificationResult::Fail + ); + let none = Fake { + result: exec( + Some(0), + "test result: ok. 0 passed; 0 failed; 5 filtered out;\n", + ), + seen: Mutex::default(), + }; + let r = run(&none, &store, &c, "i2").unwrap(); + assert_eq!(r.result, VerificationResult::Error); + assert!(r.detail.contains("no tests ran")); + assert!(store + .list() + .unwrap() + .iter() + .all(|e| e.result != VerificationResult::Pass)); + } + + #[test] + fn a_command_outside_the_allowlist_is_refused_before_anything_runs() { + let store = store("deny"); + let fake = Fake { + result: exec(Some(0), &ok_output(1)), + seen: Mutex::default(), + }; + for argv in [ + vec!["sh", "-c", "echo pwned"], + vec!["cargo", "test", "--workspace"], + vec!["cargo", "test", "-p", "a", "--test", "b; id"], + vec![], + ] { + assert!( + matches!( + run(&fake, &store, &case(&argv), "i"), + Err(RunnerError::NotAllowlisted { .. }) + ), + "{argv:?}" + ); + } + assert!( + fake.seen.lock().unwrap().is_empty(), + "nothing may execute for a refused case" + ); + assert_eq!(store.list().unwrap(), vec![]); + } + + #[test] + fn a_case_that_verifies_nothing_is_refused() { + let store = store("none"); + let fake = Fake { + result: exec(Some(0), &ok_output(1)), + seen: Mutex::default(), + }; + let mut c = case(&OK_ARGV); + c.requirements.clear(); + assert!(matches!( + run(&fake, &store, &c, "i"), + Err(RunnerError::NoRequirement(_)) + )); + } + + #[test] + fn the_configuration_digest_tracks_what_the_case_checks() { + let base = case(&OK_ARGV); + let d = base.configuration_digest("rustc-1"); + assert_eq!(d, base.configuration_digest("rustc-1")); + assert_ne!(d, base.configuration_digest("rustc-2"), "toolchain"); + let mut statement = base.clone(); + statement.requirements[0].1 = "The thing shall work differently.".into(); + assert_ne!( + d, + statement.configuration_digest("rustc-1"), + "requirement statement" + ); + let mut acceptance = base.clone(); + acceptance.acceptance = "something else".into(); + assert_ne!( + d, + acceptance.configuration_digest("rustc-1"), + "case acceptance" + ); + let mut argv = base.clone(); + argv.argv[5] = "y".into(); + assert_ne!(d, argv.configuration_digest("rustc-1"), "command"); + assert!(d.starts_with("sha256:") && d.len() == 7 + 64); + } + + fn git(dir: &Path, args: &[&str]) { + let ok = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .status() + .map(|s| s.success()) + .unwrap_or(false); + assert!(ok, "git {args:?} failed"); + } + + #[test] + fn the_git_probe_changes_with_any_edit_and_returns_when_it_is_undone() { + if Command::new("git").arg("--version").output().is_err() { + eprintln!("git not available - skipping"); + return; + } + let dir = std::env::temp_dir().join(format!("kr0ki-probe-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + git(&dir, &["init", "-q"]); + fs::write(dir.join("a.txt"), "one").unwrap(); + git(&dir, &["add", "."]); + git(&dir, &["commit", "-q", "-m", "init"]); + + let probe = GitRevisionProbe::new(&dir); + let clean = probe.implementation_revision().unwrap(); + assert_eq!(clean.len(), 40, "a clean tree is exactly HEAD: {clean}"); + + fs::write(dir.join("a.txt"), "two").unwrap(); + let edited = probe.implementation_revision().unwrap(); + assert!( + edited.starts_with(&clean) && edited.contains("+dirty."), + "{edited}" + ); + assert_ne!(edited, clean); + + fs::write(dir.join("a.txt"), "three").unwrap(); + assert_ne!( + probe.implementation_revision().unwrap(), + edited, + "a different edit is a different revision" + ); + + fs::write(dir.join("a.txt"), "one").unwrap(); + assert_eq!( + probe.implementation_revision().unwrap(), + clean, + "undoing the edit restores the revision" + ); + + // An untracked file counts, and so does its content. + fs::write(dir.join("new.txt"), "x").unwrap(); + let with_new = probe.implementation_revision().unwrap(); + assert_ne!(with_new, clean); + fs::write(dir.join("new.txt"), "y").unwrap(); + assert_ne!(probe.implementation_revision().unwrap(), with_new); + + assert!( + GitRevisionProbe::new(std::env::temp_dir().join("definitely-not-a-repo-xyz")) + .implementation_revision() + .is_err() + ); + } + + #[test] + fn the_process_executor_runs_without_a_shell_captures_output_and_enforces_the_timeout() { + let exec = ProcessExecutor; + let req = |argv: &[&str], secs: u64| ExecRequest { + argv: argv.iter().map(|s| s.to_string()).collect(), + cwd: std::env::temp_dir(), + timeout: Duration::from_secs(secs), + env: minimal_env(), + }; + let r = exec.run(&req(&["echo", "hello; id"], 5)); + assert_eq!(r.exit_code, Some(0)); + assert_eq!( + String::from_utf8_lossy(&r.output).trim(), + "hello; id", + "no shell interpretation" + ); + + let r = exec.run(&req(&["sleep", "30"], 1)); + assert!(r.timed_out && r.exit_code.is_none()); + + let r = exec.run(&req(&["definitely-not-a-real-binary-xyz"], 5)); + assert!(r.spawn_error.is_some()); + + // The environment really is cleared. + let r = exec.run(&ExecRequest { + env: vec![], + ..req(&["/usr/bin/env"], 5) + }); + assert_eq!(String::from_utf8_lossy(&r.output).trim(), ""); + } +} diff --git a/crates/kr0ki-core/tests/assurance_a01.rs b/crates/kr0ki-core/tests/assurance_a01.rs new file mode 100644 index 0000000..c40bc59 --- /dev/null +++ b/crates/kr0ki-core/tests/assurance_a01.rs @@ -0,0 +1,324 @@ +//! KR-A01 acceptance case (VC-A01). +//! +//! "The requirement adapter shall preserve supported requirement identifiers, typed attributes +//! and asserted relations across ReqIF export and reimport." +//! +//! Acceptance: round-trip this baseline; compare semantic content. Report unsupported content +//! explicitly. + +use std::collections::BTreeSet; + +use kr0ki_core::assurance_baseline::load_baseline; +use kr0ki_core::reqif_roundtrip::{compare, round_trip, Difference, UnsupportedKind}; +use ufo_types::mbse::requirements::{ + EvidenceRef, ModelIdentity, NonAuthoritativeRelation, Promotion, RelationAuthority, + RequirementGraph, RequirementRelation, RequirementRelationKind, +}; + +const BASELINE_TOML: &str = include_str!("../../../docs/assurance/kr0ki.assurance.toml"); + +fn baseline() -> RequirementGraph { + load_baseline( + BASELINE_TOML, + "docs/assurance/kr0ki.assurance.toml", + "rev-1", + Some("model-1"), + ) + .expect("the checked-in baseline must load") + .graph +} + +fn relation( + id: &str, + source: &str, + target: &str, + authority: RelationAuthority, + like: &RequirementRelation, +) -> RequirementRelation { + RequirementRelation { + id: id.into(), + source: source.into(), + target: target.into(), + kind: RequirementRelationKind::Traces, + authority, + provenance: like.provenance.clone(), + promotion: None, + } +} + +#[test] +fn this_baseline_round_trips_losslessly() { + let graph = baseline(); + let rt = round_trip(&graph).expect("export and reimport must succeed"); + assert!( + rt.report.is_lossless(), + "round trip lost content: {:#?}", + rt.report.differences + ); + // Counted, not assumed: every node, attribute and asserted relation was compared. + assert_eq!(rt.report.requirements_compared, graph.requirements.len()); + assert_eq!(rt.report.relations_compared, graph.relations.len()); + assert_eq!( + rt.report.attributes_compared, + graph + .requirements + .iter() + .map(|r| r.attributes.len()) + .sum::() + ); + assert!(rt.report.attributes_compared > 0); +} + +#[test] +fn identifiers_and_relation_ids_survive_exactly() { + let graph = baseline(); + let rt = round_trip(&graph).unwrap(); + let ids = |g: &RequirementGraph| -> BTreeSet { + g.requirements.iter().map(|r| r.id.clone()).collect() + }; + assert_eq!(ids(&graph), ids(&rt.reimported)); + let rel_ids = |g: &RequirementGraph| -> BTreeSet { + g.relations.iter().map(|r| r.id.clone()).collect() + }; + assert_eq!(rel_ids(&graph), rel_ids(&rt.reimported)); + assert_eq!(rt.reimported.baseline.id, graph.baseline.id); + assert_eq!(rt.reimported.baseline.revision, graph.baseline.revision); +} + +#[test] +fn profile_vocabularies_are_carried_as_typed_reqif_enumerations() { + let rt = round_trip(&baseline()).unwrap(); + for needle in [ + "DATATYPE-DEFINITION-ENUMERATION", + "ATTRIBUTE-DEFINITION-ENUMERATION", + "ATTRIBUTE-VALUE-ENUMERATION", + "LONG-NAME=\"status\"", + "LONG-NAME=\"source_kind\"", + "LONG-NAME=\"node_kind\"", + ] { + assert!(rt.xml.contains(needle), "exported ReqIF lacks {needle}"); + } + // Free-text fields stay strings. + assert!(rt.xml.contains("LONG-NAME=\"rationale\"")); + // The typed value comes back as the same string. + let kr = rt + .reimported + .requirements + .iter() + .find(|r| r.id == "KR-A01") + .unwrap(); + assert_eq!( + kr.attributes.get("status").map(String::as_str), + Some("gated") + ); + assert_eq!( + kr.attributes.get("node_kind").map(String::as_str), + Some("requirement") + ); +} + +#[test] +fn an_off_vocabulary_value_is_carried_as_text_not_lost_or_coerced() { + let mut graph = baseline(); + graph + .requirements + .iter_mut() + .find(|r| r.id == "KR-A03") + .unwrap() + .attributes + .insert("status".into(), "on-hold-pending-legal".into()); + let rt = round_trip(&graph).unwrap(); + assert!(rt.report.is_lossless(), "{:#?}", rt.report.differences); + let kr = rt + .reimported + .requirements + .iter() + .find(|r| r.id == "KR-A03") + .unwrap(); + assert_eq!(kr.attributes["status"], "on-hold-pending-legal"); +} + +#[test] +fn for_this_baseline_the_only_unsupported_content_is_node_provenance() { + let graph = baseline(); + let rt = round_trip(&graph).unwrap(); + let kinds: Vec<_> = rt.report.unsupported.iter().map(|u| u.kind).collect(); + assert_eq!(kinds, vec![UnsupportedKind::NodeProvenance]); + let u = rt + .report + .unsupported_of(UnsupportedKind::NodeProvenance) + .unwrap(); + assert_eq!(u.ids.len(), graph.requirements.len()); + // The importer's own additions are listed, not hidden. + let normalised: Vec<_> = rt + .report + .normalised + .iter() + .map(|n| n.attribute.as_str()) + .collect(); + assert!(normalised.contains(&"text")); +} + +#[test] +fn unsupported_content_is_reported_explicitly_with_the_ids_affected() { + let mut graph = baseline(); + let like = graph.relations[0].clone(); + + // Evidence node + an asserted relation that ends at it. + graph.evidence.push(EvidenceRef { + id: "EV-1".into(), + label: "a test run".into(), + uri: Some("file:///evidence/ev-1.json".into()), + provenance: None, + }); + graph.relations.push(relation( + "rel-to-evidence", + "VC-A01", + "EV-1", + RelationAuthority::Asserted, + &like, + )); + // A proposed (review-only) relation between two requirements. + graph.relations.push(relation( + "rel-proposed", + "KR-A01", + "KR-A02", + RelationAuthority::Proposed(NonAuthoritativeRelation { + confidence: 0.6, + rationale: "model guess".into(), + evidence: vec![], + model: ModelIdentity { + name: "m".into(), + version: "1".into(), + }, + }), + &like, + )); + // A promoted relation: carried as asserted, audit trail not carried. + let mut promoted = relation( + "rel-promoted", + "KR-A01", + "KR-A03", + RelationAuthority::Asserted, + &like, + ); + promoted.promotion = Some(Promotion { + actor: "reviewer".into(), + rationale: "confirmed".into(), + previous_status: "proposed".into(), + }); + graph.relations.push(promoted); + // A requirement-level evidence reference and an attribute key ReqIF import would rewrite. + let kr = graph + .requirements + .iter_mut() + .find(|r| r.id == "KR-A01") + .unwrap(); + kr.evidence.push(EvidenceRef { + id: "EV-2".into(), + label: "x".into(), + uri: None, + provenance: None, + }); + kr.attributes.insert("Bad-Key".into(), "v".into()); + graph.validate().expect("test graph must itself be valid"); + + let rt = round_trip(&graph).unwrap(); + assert!(rt.report.is_lossless(), "{:#?}", rt.report.differences); + + let ids = |k: UnsupportedKind| rt.report.unsupported_of(k).map(|u| u.ids.clone()); + assert_eq!( + ids(UnsupportedKind::EvidenceNodes), + Some(vec!["EV-1".to_string()]) + ); + assert_eq!( + ids(UnsupportedKind::NonAssertedRelations), + Some(vec!["rel-proposed".to_string()]) + ); + assert_eq!( + ids(UnsupportedKind::RelationsToNonRequirements), + Some(vec!["rel-to-evidence".to_string()]) + ); + assert_eq!( + ids(UnsupportedKind::RelationPromotion), + Some(vec!["rel-promoted".to_string()]) + ); + assert_eq!( + ids(UnsupportedKind::RequirementEvidenceRefs), + Some(vec!["KR-A01".to_string()]) + ); + assert_eq!( + ids(UnsupportedKind::AttributeKeyNotRepresentable), + Some(vec!["KR-A01:Bad-Key".to_string()]) + ); +} + +#[test] +fn the_comparator_flags_real_loss_as_a_difference_not_as_unsupported() { + let original = baseline(); + + let mut dropped_attr = original.clone(); + dropped_attr + .requirements + .iter_mut() + .find(|r| r.id == "KR-A01") + .unwrap() + .attributes + .remove("owner"); + let report = compare(&original, &dropped_attr); + assert!(!report.is_lossless()); + assert!(report.differences.contains(&Difference::AttributeLost { + id: "KR-A01".into(), + key: "owner".into() + })); + + let mut changed_text = original.clone(); + changed_text + .requirements + .iter_mut() + .find(|r| r.id == "KR-A02") + .unwrap() + .text = "different".into(); + assert!(compare(&original, &changed_text) + .differences + .iter() + .any(|d| matches!(d, Difference::TextChanged { id, .. } if id == "KR-A02"))); + + let mut lost_relation = original.clone(); + let removed = lost_relation.relations.remove(0); + assert!(compare(&original, &lost_relation) + .differences + .contains(&Difference::RelationLost { id: removed.id })); + + let mut missing_node = original.clone(); + missing_node.requirements.retain(|r| r.id != "KR-A08"); + assert!(compare(&original, &missing_node).differences.contains( + &Difference::MissingRequirement { + id: "KR-A08".into() + } + )); + + let mut added = original.clone(); + let mut extra = added.requirements[0].clone(); + extra.id = "EXTRA".into(); + added.requirements.push(extra); + assert!(compare(&original, &added) + .differences + .contains(&Difference::ExtraRequirement { id: "EXTRA".into() })); +} + +#[test] +fn the_report_serialises_for_mcp_and_http() { + let rt = round_trip(&baseline()).unwrap(); + let json = serde_json::to_value(&rt.report).unwrap(); + assert_eq!(json["differences"], serde_json::json!([])); + assert_eq!(json["unsupported"][0]["kind"], "node_provenance"); + assert!(json["requirements_compared"].as_u64().unwrap() > 0); +} + +#[test] +fn export_is_deterministic_so_the_baseline_digest_is_reproducible() { + let a = round_trip(&baseline()).unwrap(); + let b = round_trip(&baseline()).unwrap(); + assert_eq!(a.xml_sha256, b.xml_sha256); +} diff --git a/crates/kr0ki-core/tests/assurance_a02.rs b/crates/kr0ki-core/tests/assurance_a02.rs new file mode 100644 index 0000000..238ae41 --- /dev/null +++ b/crates/kr0ki-core/tests/assurance_a02.rs @@ -0,0 +1,307 @@ +//! KR-A02 acceptance case (VC-A02). +//! +//! "The traceability adapter shall resolve requirement links against a specified model +//! revision." +//! +//! Acceptance: link KR-A01 to its adapter and test; report a deliberately dangling reference. + +use std::collections::BTreeSet; +use std::path::Path; + +use kr0ki_core::assurance_baseline::load_baseline; +use kr0ki_core::assurance_trace::{ + resolve_links, DirResolver, LinkClass, LinkResolution, LinkStatus, ModelRevisionIndex, +}; +use ufo_types::mbse::assurance::{analyze, CurrentRevisions, GapKind}; +use ufo_types::mbse::requirements::{ + RelationAuthority, Requirement, RequirementGraph, RequirementRelation, RequirementRelationKind, +}; + +const BASELINE_TOML: &str = include_str!("../../../docs/assurance/kr0ki.assurance.toml"); +const MODEL_SYSML: &str = include_str!("../../../docs/assurance/kr0ki-assurance.sysml"); + +fn graph() -> RequirementGraph { + load_baseline( + BASELINE_TOML, + "docs/assurance/kr0ki.assurance.toml", + "rev-1", + None, + ) + .expect("the checked-in baseline must load") + .graph +} + +fn repo() -> DirResolver { + DirResolver::new(Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")) +} + +fn resolve(graph: &RequirementGraph, model: &str) -> LinkResolution { + resolve_links(graph, &ModelRevisionIndex::from_sysml_text(model), &repo()) +} + +fn link<'a>( + res: &'a LinkResolution, + requirement: &str, + class: LinkClass, + locator: &str, +) -> &'a LinkStatus { + &res.links + .iter() + .find(|l| l.requirement_id == requirement && l.class == class && l.locator == locator) + .unwrap_or_else(|| panic!("{requirement} has no {class:?} link to {locator}")) + .status +} + +#[test] +fn kr_a01_links_to_its_element_its_adapter_and_its_test() { + let res = resolve(&graph(), MODEL_SYSML); + let a01: Vec<_> = res.for_requirement("KR-A01").collect(); + assert!( + a01.iter().all(|l| l.status == LinkStatus::Resolved), + "{a01:#?}" + ); + assert_eq!( + link( + &res, + "KR-A01", + LinkClass::Element, + "KrOKiAssurance::ModelService" + ), + &LinkStatus::Resolved + ); + // The adapter: both files the control records. + for path in [ + "crates/kr0ki-core/src/reqif_export.rs", + "crates/kr0ki-core/src/reqif_roundtrip.rs", + ] { + assert_eq!( + link(&res, "KR-A01", LinkClass::Implementation, path), + &LinkStatus::Resolved + ); + } + // The test: the case's declared `cargo test --test assurance_a01` target. + assert_eq!( + link( + &res, + "KR-A01", + LinkClass::VerificationTarget, + "crates/kr0ki-core/tests/assurance_a01.rs" + ), + &LinkStatus::Resolved + ); +} + +#[test] +fn kr_a02_links_to_itself_too() { + let res = resolve(&graph(), MODEL_SYSML); + assert_eq!( + link( + &res, + "KR-A02", + LinkClass::Implementation, + "crates/kr0ki-core/src/assurance_trace.rs" + ), + &LinkStatus::Resolved + ); + assert_eq!( + link( + &res, + "KR-A02", + LinkClass::VerificationTarget, + "crates/kr0ki-core/tests/assurance_a02.rs" + ), + &LinkStatus::Resolved + ); +} + +/// Adds an element (`Ghost`) that the model does not contain and records a non-existent +/// implementation path on KR-A01's control: two deliberately dangling references. +fn with_dangling_references(mut graph: RequirementGraph) -> RequirementGraph { + let template: Requirement = graph + .requirements + .iter() + .find(|n| n.id == "KrOKiAssurance::ModelService") + .unwrap() + .clone(); + let mut ghost = template; + ghost.id = "KrOKiAssurance::Ghost".into(); + ghost.title = "Ghost".into(); + ghost + .attributes + .insert("qualified_name".into(), "KrOKiAssurance::Ghost".into()); + graph.requirements.push(ghost); + let like = graph + .relations + .iter() + .find(|r| r.kind == RequirementRelationKind::Satisfies) + .unwrap() + .clone(); + graph.relations.push(RequirementRelation { + id: "satisfies:ghost->KR-A01".into(), + source: "KrOKiAssurance::Ghost".into(), + target: "KR-A01".into(), + kind: RequirementRelationKind::Satisfies, + authority: RelationAuthority::Asserted, + provenance: like.provenance, + promotion: None, + }); + let control = graph + .requirements + .iter_mut() + .find(|n| n.id == "CTL-A01") + .unwrap(); + let implementation = control.attributes.get_mut("implementation").unwrap(); + implementation.push_str("; crates/kr0ki-core/src/does_not_exist.rs"); + graph + .validate() + .expect("the mutated graph must itself be valid"); + graph +} + +#[test] +fn a_deliberately_dangling_reference_is_reported_with_its_revision_and_reason() { + let res = resolve(&with_dangling_references(graph()), MODEL_SYSML); + + let element = link(&res, "KR-A01", LinkClass::Element, "KrOKiAssurance::Ghost"); + match element { + LinkStatus::Dangling { reason } => { + assert!(reason.contains("KrOKiAssurance::Ghost"), "{reason}"); + assert!( + reason.contains(&res.revision), + "the reason must name the revision: {reason}" + ); + } + other => panic!("expected dangling, got {other:?}"), + } + let file = link( + &res, + "KR-A01", + LinkClass::Implementation, + "crates/kr0ki-core/src/does_not_exist.rs", + ); + assert!( + matches!(file, LinkStatus::Dangling { reason } if reason.contains("does_not_exist.rs")) + ); + + // Exactly those two are new; everything KR-A01 really links to still resolves. + let dangling: BTreeSet<(String, String)> = res + .dangling() + .filter(|l| l.requirement_id == "KR-A01") + .map(|l| (l.requirement_id.clone(), l.locator.clone())) + .collect(); + assert_eq!( + dangling, + BTreeSet::from([ + ("KR-A01".to_string(), "KrOKiAssurance::Ghost".to_string()), + ( + "KR-A01".to_string(), + "crates/kr0ki-core/src/does_not_exist.rs".to_string() + ), + ]) + ); +} + +#[test] +fn resolution_is_against_the_named_revision_and_changes_with_it() { + let graph = graph(); + let r1 = resolve(&graph, MODEL_SYSML); + // A later revision of the model in which the gateway definition was renamed away. + let later = MODEL_SYSML.replace("part def ToolGateway", "part def RenamedGateway"); + assert_ne!(later, MODEL_SYSML, "the edit must change the model"); + let r2 = resolve(&graph, &later); + + assert_ne!(r1.revision, r2.revision); + let gateway = "KrOKiAssurance::ToolGateway"; + assert_eq!( + link(&r1, "KR-A06", LinkClass::Element, gateway), + &LinkStatus::Resolved + ); + assert!(matches!( + link(&r2, "KR-A06", LinkClass::Element, gateway), + LinkStatus::Dangling { .. } + )); + // Links to elements that still exist are unaffected. + assert_eq!( + link( + &r2, + "KR-A01", + LinkClass::Element, + "KrOKiAssurance::ModelService" + ), + &LinkStatus::Resolved + ); +} + +#[test] +fn the_assurance_analysis_agrees_with_link_resolution() { + let graph = graph(); + let revisions = CurrentRevisions::new("m", "i"); + let dangling_elements = |model: &str| -> BTreeSet { + let index = ModelRevisionIndex::from_sysml_text(model); + analyze(&graph, &[], &revisions, Some(index.locators())) + .unwrap() + .gaps + .into_iter() + .filter_map(|g| match g.kind { + GapKind::DanglingElement { element_id } => { + Some(format!("{}:{element_id}", g.requirement_id)) + } + _ => None, + }) + .collect() + }; + assert_eq!(dangling_elements(MODEL_SYSML), BTreeSet::new()); + let later = MODEL_SYSML.replace("part def ToolGateway", "part def RenamedGateway"); + assert_eq!( + dangling_elements(&later), + BTreeSet::from(["KR-A06:KrOKiAssurance::ToolGateway".to_string()]) + ); +} + +#[test] +fn the_declaration_scanner_agrees_with_the_real_parser_on_the_checked_in_model() { + use sysml_v2_parser::ast::{Body, PackageBodyElement, PartDefBodyElement, RootElement}; + + let doc = sysml_v2_parser::parse(MODEL_SYSML).expect("the model must parse"); + let package = doc + .root + .elements + .iter() + .find_map(|e| match &e.value { + RootElement::Package(p) => Some(&p.value), + _ => None, + }) + .expect("one root package"); + let Body::Brace { elements, .. } = &package.body else { + panic!("package has a body") + }; + let mut part_defs = 0; + let mut nested_usages = 0; + for member in elements { + if let PackageBodyElement::PartDef(def) = &member.value { + part_defs += 1; + if let Body::Brace { elements, .. } = &def.value.body { + nested_usages += elements + .iter() + .filter(|m| matches!(m.value, PartDefBodyElement::PartUsage(_))) + .count(); + } + } + } + + let index = ModelRevisionIndex::from_sysml_text(MODEL_SYSML); + let depth = |n: usize| { + index + .locators() + .iter() + .filter(|l| l.matches("::").count() == n) + .count() + }; + assert_eq!(depth(1), part_defs, "part definitions: scanner vs parser"); + assert_eq!( + depth(2), + nested_usages, + "nested part usages: scanner vs parser" + ); + assert!(part_defs >= 8 && nested_usages >= 8); +} diff --git a/crates/kr0ki-core/tests/assurance_a03.rs b/crates/kr0ki-core/tests/assurance_a03.rs new file mode 100644 index 0000000..f43caac --- /dev/null +++ b/crates/kr0ki-core/tests/assurance_a03.rs @@ -0,0 +1,299 @@ +//! KR-A03 acceptance case (VC-A03). +//! +//! "The requirements view shall distinguish satisfaction assertions from verification +//! results." +//! +//! Acceptance: show one satisfied but untested requirement and one verified requirement. + +use kr0ki_core::assurance_baseline::load_baseline; +use kr0ki_core::assurance_view::{assurance_view, state_name, to_d2, to_table, VerificationAxis}; +use ufo_types::mbse::assurance::{ + Assurance, CurrentRevisions, EvidenceRecord, RevisionSet, VerificationResult, +}; +use ufo_types::mbse::requirements::RequirementGraph; + +const BASELINE_TOML: &str = include_str!("../../../docs/assurance/kr0ki.assurance.toml"); +const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000001"; + +fn graph() -> RequirementGraph { + load_baseline( + BASELINE_TOML, + "docs/assurance/kr0ki.assurance.toml", + "rev-1", + None, + ) + .expect("the checked-in baseline must load") + .graph +} + +fn now() -> RevisionSet { + RevisionSet { + model_revision: "model-1".into(), + implementation_revision: "impl-1".into(), + configuration_digest: DIGEST.into(), + } +} + +/// Every declared case is current at the same configuration digest in these fixtures. +fn current(r: &RevisionSet) -> CurrentRevisions { + CurrentRevisions::for_cases(r, (1..=8).map(|n| format!("VC-A0{n}"))) +} + +fn evidence( + requirement: &str, + case: &str, + at: &RevisionSet, + result: VerificationResult, +) -> EvidenceRecord { + EvidenceRecord { + requirement_id: requirement.into(), + verification_id: case.into(), + model_revision: at.model_revision.clone(), + implementation_revision: at.implementation_revision.clone(), + configuration_digest: at.configuration_digest.clone(), + result, + artifact_uri: format!("file:///evidence/{requirement}.json"), + artifact_digest: DIGEST.into(), + } +} + +/// KR-A01 verified (fresh pass); every other requirement is satisfied by design and untested. +fn the_acceptance_scenario() -> (RequirementGraph, Vec) { + ( + graph(), + vec![evidence( + "KR-A01", + "VC-A01", + &now(), + VerificationResult::Pass, + )], + ) +} + +#[test] +fn one_requirement_is_satisfied_but_untested_and_one_is_verified() { + let (graph, ev) = the_acceptance_scenario(); + let view = assurance_view(&graph, &ev, ¤t(&now()), None).unwrap(); + + let verified = view.row("KR-A01").unwrap(); + assert_eq!(verified.assurance, Assurance::Verified); + assert_eq!( + verified.verification, + VerificationAxis::Evidence { + fresh_pass: 1, + fresh_fail: 0, + stale: 0 + } + ); + + let untested = view.row("KR-A02").unwrap(); + assert_eq!(untested.assurance, Assurance::SatisfiedUntested); + assert_eq!(untested.verification, VerificationAxis::NoEvidence); + // It *is* satisfied by design β€” that axis is independent of the verification axis. + assert_eq!(untested.satisfied_by, ["KrOKiAssurance::ModelService"]); + + let counts = view.counts(); + assert_eq!(counts.get("verified"), Some(&1)); + assert_eq!(counts.get("satisfied_untested"), Some(&7)); +} + +#[test] +fn the_diagram_draws_the_two_claims_differently() { + let (graph, ev) = the_acceptance_scenario(); + let view = assurance_view(&graph, &ev, ¤t(&now()), None).unwrap(); + let d2 = to_d2(&graph, &view); + + // Each state has its own label and fill. + assert!(d2.contains("KR-A01") && d2.contains("[verified]"), "{d2}"); + assert!(d2.contains("[satisfied Β· untested]")); + assert!(d2.contains("#b7ebc6") && d2.contains("#ffe9a8")); + // Satisfaction is an assertion: dashed. Verification is evidence: its own node and edge. + assert!(d2.contains("satisfies (assertion) { style.stroke-dash: 4 }")); + assert!(d2.contains("VC-A01 Β· pass Β· fresh")); + assert!(d2.contains("verification result")); + // Only the verified requirement has an evidence node attached. + assert_eq!(d2.matches("verification result").count(), 1); + // The dashed edge is never the evidence edge. + let dashed_lines: Vec<_> = d2.lines().filter(|l| l.contains("stroke-dash")).collect(); + assert!(dashed_lines + .iter() + .all(|l| l.contains("satisfies (assertion)"))); +} + +#[test] +fn the_table_has_separate_columns_for_assertion_and_evidence() { + let (graph, ev) = the_acceptance_scenario(); + let table = to_table(&assurance_view(&graph, &ev, ¤t(&now()), None).unwrap()); + assert!(table + .starts_with("requirement | satisfaction (asserted) | verification (evidence) | state")); + let line = |id: &str| { + table + .lines() + .find(|l| l.starts_with(id)) + .unwrap() + .to_string() + }; + assert!(line("KR-A01").contains("1 fresh pass, 0 fresh fail, 0 stale")); + assert!(line("KR-A01").ends_with("| verified")); + assert!(line("KR-A02").contains("no evidence")); + assert!(line("KR-A02").ends_with("| satisfied Β· untested")); +} + +#[test] +fn a_satisfaction_edge_alone_can_never_make_a_requirement_verified() { + let graph = graph(); + // No evidence at all: nothing is verified, however many satisfaction edges exist. + let view = assurance_view(&graph, &[], ¤t(&now()), None).unwrap(); + assert!(view.rows.iter().all(|r| r.assurance != Assurance::Verified)); + assert!(view + .rows + .iter() + .all(|r| r.verification == VerificationAxis::NoEvidence)); + assert!(view.rows.iter().all(|r| !r.satisfied_by.is_empty())); +} + +#[test] +fn evidence_for_one_requirement_does_not_verify_another() { + let graph = graph(); + let ev = [evidence( + "KR-A01", + "VC-A01", + &now(), + VerificationResult::Pass, + )]; + let view = assurance_view(&graph, &ev, ¤t(&now()), None).unwrap(); + for r in view.rows.iter().filter(|r| r.requirement_id != "KR-A01") { + assert_ne!(r.assurance, Assurance::Verified, "{}", r.requirement_id); + } + // Evidence that names a requirement with a case it is not linked to is orphaned, not counted. + let stray = [evidence( + "KR-A02", + "VC-A01", + &now(), + VerificationResult::Pass, + )]; + let view = assurance_view(&graph, &stray, ¤t(&now()), None).unwrap(); + assert_eq!( + view.row("KR-A02").unwrap().assurance, + Assurance::SatisfiedUntested + ); + assert_eq!(view.report.orphan_evidence.len(), 1); +} + +#[test] +fn a_result_for_other_revisions_is_shown_as_stale_not_as_verified_or_untested() { + let graph = graph(); + let then = now(); + let ev = [evidence( + "KR-A01", + "VC-A01", + &then, + VerificationResult::Pass, + )]; + let later = RevisionSet { + implementation_revision: "impl-2".into(), + ..then + }; + let view = assurance_view(&graph, &ev, ¤t(&later), None).unwrap(); + assert_eq!(view.row("KR-A01").unwrap().assurance, Assurance::Stale); + assert_eq!(state_name(Assurance::Stale), "stale"); + let d2 = to_d2(&graph, &view); + assert!(d2.contains("VC-A01 Β· pass Β· STALE")); + assert!(d2.contains("[satisfied Β· result stale]")); + assert!(!d2.contains("[verified]")); +} + +#[test] +fn a_fresh_failure_is_shown_as_failing() { + let graph = graph(); + let ev = [evidence( + "KR-A01", + "VC-A01", + &now(), + VerificationResult::Fail, + )]; + let view = assurance_view(&graph, &ev, ¤t(&now()), None).unwrap(); + assert_eq!(view.row("KR-A01").unwrap().assurance, Assurance::Failing); + assert!(to_d2(&graph, &view).contains("[FAILING]")); +} + +#[test] +fn the_view_serialises_for_mcp_and_http() { + let (graph, ev) = the_acceptance_scenario(); + let json = + serde_json::to_value(assurance_view(&graph, &ev, ¤t(&now()), None).unwrap()).unwrap(); + let rows = json["rows"].as_array().unwrap(); + let kr1 = rows + .iter() + .find(|r| r["requirement_id"] == "KR-A01") + .unwrap(); + assert_eq!(kr1["assurance"], "verified"); + assert_eq!(kr1["verification"]["state"], "evidence"); + let kr2 = rows + .iter() + .find(|r| r["requirement_id"] == "KR-A02") + .unwrap(); + assert_eq!(kr2["assurance"], "satisfied_untested"); + assert_eq!(kr2["verification"]["state"], "no_evidence"); +} + +#[test] +fn the_diagram_is_deterministic() { + let (graph, ev) = the_acceptance_scenario(); + let a = to_d2( + &graph, + &assurance_view(&graph, &ev, ¤t(&now()), None).unwrap(), + ); + let b = to_d2( + &graph, + &assurance_view(&graph, &ev, ¤t(&now()), None).unwrap(), + ); + assert_eq!(a, b); +} + +/// Live check: the diagram is valid D2 that a real Kroki-compatible backend renders, and the +/// rendered SVG still carries both claims. Env-gated like `template_render.rs`: +/// `KR0KI_TEST_BACKEND=http://127.0.0.1:8010 cargo test -p kr0ki-core --test assurance_a03 -- --ignored` +#[tokio::test] +#[ignore = "needs KR0KI_TEST_BACKEND pointing at a live Kroki"] +async fn the_assurance_diagram_renders_through_a_live_kroki_backend() { + use kr0ki_core::cache::{FsCache, OutputKind}; + use kr0ki_core::format::DiagramFormat; + use kr0ki_core::render::HttpKrokiBackend; + use kr0ki_core::RenderService; + + let Ok(base) = std::env::var("KR0KI_TEST_BACKEND") else { + eprintln!("KR0KI_TEST_BACKEND unset β€” skipping"); + return; + }; + let (graph, ev) = the_acceptance_scenario(); + let d2 = to_d2( + &graph, + &assurance_view(&graph, &ev, ¤t(&now()), None).unwrap(), + ); + + let dir = std::env::temp_dir().join(format!("kr0ki-assurance-a03-{}", std::process::id())); + let svc = RenderService::new(HttpKrokiBackend::new(base), FsCache::new(&dir)); + let rendered = svc + .render(DiagramFormat::D2, OutputKind::Svg, &d2) + .await + .unwrap_or_else(|e| panic!("live render failed: {e}\n--- D2 ---\n{d2}")); + let svg = String::from_utf8_lossy(&rendered.bytes); + assert!( + svg.contains(" PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() +} + +fn graph() -> RequirementGraph { + load_baseline( + BASELINE_TOML, + "docs/assurance/kr0ki.assurance.toml", + "rev-1", + None, + ) + .expect("the checked-in baseline must load") + .graph +} + +fn store(tag: &str) -> FsEvidenceStore { + let d = std::env::temp_dir().join(format!("kr0ki-a04-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + FsEvidenceStore::new(d) +} + +fn run_vc_a01(store: &FsEvidenceStore, model_revision: &str) -> RunReport { + let graph = graph(); + let case = cases_from_graph(&graph) + .into_iter() + .find(|c| c.id == "VC-A01") + .expect("the baseline declares VC-A01"); + let probe = GitRevisionProbe::new(repo_root()); + VerificationRunner { + store, + executor: &ProcessExecutor, + probe: &probe, + config: RunnerConfig { + repo_root: repo_root(), + // Short on purpose: a nested-cargo lock problem must fail fast, not hang the suite. + timeout: Duration::from_secs(600), + model_revision: model_revision.to_string(), + }, + } + .run_case(&case) + .expect("VC-A01 must run") +} + +#[test] +fn run_kr_a01s_case_retrieve_its_evidence_then_change_a_revision_and_see_it_stale() { + let store = store("main"); + let index = ModelRevisionIndex::from_sysml_text(MODEL_SYSML); + + // 1. Run KR-A01's case for real. + let report = run_vc_a01(&store, index.revision()); + assert_eq!( + report.result, + VerificationResult::Pass, + "VC-A01 must pass: {}", + report.detail + ); + assert!(report.detail.contains("tests passed"), "{}", report.detail); + assert_eq!(report.records.len(), 1, "VC-A01 verifies exactly KR-A01"); + + // 2. Retrieve its evidence, bound to all three revisions. + let evidence = store.for_requirement("KR-A01").unwrap(); + assert_eq!(evidence, report.records); + let e = &evidence[0]; + assert_eq!(e.verification_id, "VC-A01"); + assert_eq!(e.model_revision, index.revision()); + assert_eq!( + e.implementation_revision, + GitRevisionProbe::new(repo_root()) + .implementation_revision() + .unwrap(), + "the implementation revision is the repository's, not a placeholder" + ); + assert!(e.configuration_digest.starts_with("sha256:")); + e.validate().unwrap(); + // The artifact is the real log and verifies against its digest. + let (_, check) = &store.verify().unwrap()[0]; + assert_eq!(check, &ArtifactCheck::Intact); + let log = std::fs::read_to_string(e.artifact_uri.strip_prefix("file://").unwrap()).unwrap(); + assert!( + log.contains("verdict: Pass") && log.contains("test result: ok"), + "{log}" + ); + + // 3. At the revisions it ran at, KR-A01 is verified. + let graph = graph(); + let probe = GitRevisionProbe::new(repo_root()); + let now = current_revisions(&graph, &probe, index.revision()).unwrap(); + assert_eq!(report.revisions.model_revision, now.model_revision); + assert_eq!( + report.revisions.implementation_revision, + now.implementation_revision + ); + assert_eq!( + Some(&report.revisions.configuration_digest), + now.configuration_digests.get("VC-A01"), + "the runner and the view derive the same per-case configuration digest" + ); + let fresh = assurance_view(&graph, &evidence, &now, None).unwrap(); + assert_eq!(fresh.row("KR-A01").unwrap().assurance, Assurance::Verified); + + // 4. Change each revision in turn: the previous result is displayed as stale. + let changed_model = { + let later = MODEL_SYSML.replace("part def ToolGateway", "part def RenamedGateway"); + CurrentRevisions { + model_revision: ModelRevisionIndex::from_sysml_text(&later) + .revision() + .into(), + ..now.clone() + } + }; + let changed_implementation = CurrentRevisions { + implementation_revision: format!("{}-edited", now.implementation_revision), + ..now.clone() + }; + let changed_configuration = { + let mut edited = graph.clone(); + edited + .requirements + .iter_mut() + .find(|n| n.id == "VC-A01") + .unwrap() + .text = "a different acceptance".into(); + let case = cases_from_graph(&edited) + .into_iter() + .find(|c| c.id == "VC-A01") + .unwrap(); + now.clone() + .with_case("VC-A01", case.configuration_digest(&probe.toolchain())) + }; + for (what, current) in [ + ("model", changed_model), + ("implementation", changed_implementation), + ("configuration", changed_configuration), + ] { + assert_ne!(current, now, "{what}: the revision must actually differ"); + let view = assurance_view(&graph, &evidence, ¤t, None).unwrap(); + assert_eq!( + view.row("KR-A01").unwrap().assurance, + Assurance::Stale, + "{what} change must make the result stale" + ); + let d2 = to_d2(&graph, &view); + assert!(d2.contains("VC-A01 Β· pass Β· STALE"), "{what}: {d2}"); + assert!(!d2.contains("[verified]"), "{what}"); + assert!(to_table(&view).contains("1 stale"), "{what}"); + } +} + +#[test] +fn the_configuration_digest_is_stable_across_runs_of_the_same_case() { + // Bound to what the case checks, not to timestamps or paths. + let probe = GitRevisionProbe::new(repo_root()); + let case = cases_from_graph(&graph()) + .into_iter() + .find(|c| c.id == "VC-A01") + .unwrap(); + assert_eq!( + case.configuration_digest(&probe.toolchain()), + case.configuration_digest(&probe.toolchain()) + ); +} + +#[test] +fn every_declared_case_is_runnable_by_the_runner_or_explicitly_refused() { + // The baseline's cases all use the one allowlisted shape. + for case in cases_from_graph(&graph()) { + assert!( + kr0ki_core::assurance_trace::cargo_test_target(&case.argv).is_some(), + "{} declares a command the runner would refuse: {:?}", + case.id, + case.argv + ); + assert!( + !case.requirements.is_empty(), + "{} verifies nothing", + case.id + ); + } +} diff --git a/crates/kr0ki-core/tests/assurance_a05.rs b/crates/kr0ki-core/tests/assurance_a05.rs new file mode 100644 index 0000000..c43857c --- /dev/null +++ b/crates/kr0ki-core/tests/assurance_a05.rs @@ -0,0 +1,191 @@ +//! KR-A05 acceptance case (VC-A05). +//! +//! "The model commit service shall reject a proposed mutation whose expected base revision +//! differs from the current revision." +//! +//! Acceptance: submit two edits from one baseline; reject the second stale edit without losing +//! the first. +//! +//! The model content is this repository's own requirement baseline. An in-memory model server +//! stands in for the real one here; `assurance_a05_live.rs` runs the same scenario against the +//! OMG pilot server. + +use std::collections::BTreeMap; + +use kr0ki_core::assurance_baseline::load_baseline; +use kr0ki_core::change_service::in_memory::InMemoryServer; +use kr0ki_core::change_service::{ChangeError, ChangeService, Operation}; +use kr0ki_core::sync_engine::DesiredElement; +use serde_json::{json, Map}; +use ufo_types::mbse::assurance::NODE_KIND_ATTR; + +const BASELINE_TOML: &str = include_str!("../../../docs/assurance/kr0ki.assurance.toml"); +const PREFIX: &str = "kr0ki:assurance:"; + +/// The baseline's requirements as managed model elements, with title overrides applied. +fn desired(overrides: &BTreeMap<&str, &str>) -> Vec { + let graph = load_baseline( + BASELINE_TOML, + "docs/assurance/kr0ki.assurance.toml", + "rev-1", + None, + ) + .expect("the checked-in baseline must load") + .graph; + graph + .requirements + .iter() + .filter(|n| n.attributes.get(NODE_KIND_ATTR).map(String::as_str) == Some("requirement")) + .map(|n| { + let mut fields = Map::new(); + let title = overrides.get(n.id.as_str()).copied().unwrap_or(&n.title); + fields.insert("name".into(), json!(title)); + fields.insert("text".into(), json!([n.text])); + DesiredElement { + identifier: format!("{PREFIX}{}", n.id), + type_: "RequirementUsage", + fields, + } + }) + .collect() +} + +fn names_at_head(server: &InMemoryServer) -> BTreeMap { + // identifier -> name, read back from the server's head commit. + server.names_at_head() +} + +#[tokio::test] +async fn two_edits_from_one_baseline_the_second_stale_edit_is_rejected_and_the_first_is_kept() { + let svc = ChangeService::new(InMemoryServer::new(true)); + + // The baseline: all eight requirements committed. + let seed = svc + .propose("p", None, PREFIX, &desired(&BTreeMap::new())) + .await + .unwrap(); + assert_eq!(seed.base_revision, None); + assert_eq!(seed.summary.len(), 8); + assert!(seed + .summary + .iter() + .all(|s| s.operation == Operation::Create)); + let r0 = svc.commit(&seed.change_id, None).await.unwrap().commit_id; + + // Two edits, both proposed from the same baseline r0. + let edit1 = svc + .propose( + "p", + None, + PREFIX, + &desired(&BTreeMap::from([("KR-A01", "KR-A01 (edited first)")])), + ) + .await + .unwrap(); + let edit2 = svc + .propose( + "p", + None, + PREFIX, + &desired(&BTreeMap::from([("KR-A02", "KR-A02 (edited second)")])), + ) + .await + .unwrap(); + assert_eq!(edit1.base_revision.as_deref(), Some(r0.as_str())); + assert_eq!(edit2.base_revision, edit1.base_revision); + assert_eq!(edit1.summary.len(), 1); + assert_eq!(edit1.summary[0].identifier, format!("{PREFIX}KR-A01")); + assert_eq!(edit2.summary[0].identifier, format!("{PREFIX}KR-A02")); + + // The first lands. + let r1 = svc + .commit(&edit1.change_id, Some(&r0)) + .await + .unwrap() + .commit_id; + let commits_after_first = svc.backend().commit_count(); + + // The second, still expecting r0, is rejected: it names both revisions. + let err = svc.commit(&edit2.change_id, Some(&r0)).await.unwrap_err(); + assert_eq!( + err, + ChangeError::StaleBase { + expected: Some(r0.clone()), + current: Some(r1.clone()) + } + ); + assert!(err.to_string().contains("nothing was written")); + + // Nothing was lost and nothing was added. + assert_eq!(svc.backend().commit_count(), commits_after_first); + assert_eq!(svc.backend().head_id().as_deref(), Some(r1.as_str())); + let names = names_at_head(svc.backend()); + assert_eq!( + names[&format!("{PREFIX}KR-A01")], + "KR-A01 (edited first)", + "the first edit is kept" + ); + assert_ne!( + names[&format!("{PREFIX}KR-A02")], + "KR-A02 (edited second)", + "the stale edit is not applied" + ); + assert_eq!(names.len(), 8, "no requirement was lost"); + + // Re-proposing from the new head keeps both, and commits. + let redo = svc + .propose( + "p", + None, + PREFIX, + &desired(&BTreeMap::from([ + ("KR-A01", "KR-A01 (edited first)"), + ("KR-A02", "KR-A02 (edited second)"), + ])), + ) + .await + .unwrap(); + assert_eq!(redo.base_revision.as_deref(), Some(r1.as_str())); + svc.commit(&redo.change_id, Some(&r1)).await.unwrap(); + let names = names_at_head(svc.backend()); + assert_eq!(names[&format!("{PREFIX}KR-A01")], "KR-A01 (edited first)"); + assert_eq!(names[&format!("{PREFIX}KR-A02")], "KR-A02 (edited second)"); +} + +#[tokio::test] +async fn the_rejection_does_not_depend_on_the_server_checking_previous_commit() { + // A server that ignores `previousCommit` would silently overwrite; the service must not. + let svc = ChangeService::new(InMemoryServer::new(false)); + let seed = svc + .propose("p", None, PREFIX, &desired(&BTreeMap::new())) + .await + .unwrap(); + let r0 = svc.commit(&seed.change_id, None).await.unwrap().commit_id; + let e1 = svc + .propose( + "p", + None, + PREFIX, + &desired(&BTreeMap::from([("KR-A01", "one")])), + ) + .await + .unwrap(); + let e2 = svc + .propose( + "p", + None, + PREFIX, + &desired(&BTreeMap::from([("KR-A02", "two")])), + ) + .await + .unwrap(); + svc.commit(&e1.change_id, Some(&r0)).await.unwrap(); + assert!(matches!( + svc.commit(&e2.change_id, Some(&r0)).await, + Err(ChangeError::StaleBase { .. }) + )); + assert_eq!( + names_at_head(svc.backend())[&format!("{PREFIX}KR-A01")], + "one" + ); +} diff --git a/crates/kr0ki-core/tests/assurance_a05_live.rs b/crates/kr0ki-core/tests/assurance_a05_live.rs new file mode 100644 index 0000000..b7fe33c --- /dev/null +++ b/crates/kr0ki-core/tests/assurance_a05_live.rs @@ -0,0 +1,168 @@ +//! KR-A05 against a real OMG Systems Modeling API server (the Java pilot). +//! +//! Skipped by default. `just sysml-api-up`, then: +//! +//! ```sh +//! KR0KI_SYSMLV2_BASE_URL=http://127.0.0.1:9000 \ +//! cargo test -p kr0ki-core --test assurance_a05_live -- --ignored --nocapture +//! ``` +//! +//! Creates a throwaway project (as `just test-live-sysml` does). Records, without asserting, +//! whether the server itself enforces `previousCommit`: the service's own check is the +//! guarantee either way. + +use std::sync::Arc; + +use kr0ki_core::change_service::{ChangeError, ChangeService, SysmlBackend}; +use kr0ki_core::sync_engine::DesiredElement; +use kr0ki_sysmlv2_client::{CommitRequest, DataVersion, Ref, SysmlV2Client}; +use serde_json::{json, Map, Value}; + +const PREFIX: &str = "kr0ki:a05live:"; + +fn desired(names: &[(&str, &str)]) -> Vec { + names + .iter() + .map(|(id, name)| { + let mut fields = Map::new(); + fields.insert("name".into(), json!(name)); + DesiredElement { + identifier: format!("{PREFIX}{id}"), + type_: "PartUsage", + fields, + } + }) + .collect() +} + +/// identifier (from `aliasIds`, which the strictly-typed pilot keeps) -> name at `commit`. +async fn names_at( + client: &SysmlV2Client, + project: &str, + commit: &str, +) -> std::collections::BTreeMap { + client + .all_elements(project, commit) + .await + .expect("elements") + .into_iter() + .filter_map(|e| { + let id = e + .get("aliasIds")? + .as_array()? + .first()? + .as_str()? + .to_string(); + id.starts_with(PREFIX) + .then(|| (id, e.name().unwrap_or_default().to_string())) + }) + .collect() +} + +#[tokio::test] +#[ignore = "needs KR0KI_SYSMLV2_BASE_URL pointing at the OMG pilot (just sysml-api-up)"] +async fn live_two_edits_from_one_baseline_second_is_rejected_first_is_kept() { + let base = std::env::var("KR0KI_SYSMLV2_BASE_URL").expect("set KR0KI_SYSMLV2_BASE_URL"); + let created: Value = reqwest::Client::new() + .post(format!("{}/projects", base.trim_end_matches('/'))) + .json( + &json!({"@type": "Project", "name": format!("kr0ki a05 live {}", std::process::id())}), + ) + .send() + .await + .expect("create project") + .error_for_status() + .expect("project created") + .json() + .await + .unwrap(); + let project = created["@id"].as_str().expect("project id").to_string(); + + let client = Arc::new(SysmlV2Client::new(base)); + let svc = ChangeService::new(SysmlBackend::new(client.clone())); + + let seed = svc + .propose( + &project, + None, + PREFIX, + &desired(&[("a", "one"), ("b", "two")]), + ) + .await + .unwrap(); + assert_eq!(seed.base_revision, None, "a new project has no commits"); + let r0 = svc + .commit(&seed.change_id, None) + .await + .expect("seed commit") + .commit_id; + + let e1 = svc + .propose( + &project, + None, + PREFIX, + &desired(&[("a", "one (edit 1)"), ("b", "two")]), + ) + .await + .unwrap(); + let e2 = svc + .propose( + &project, + None, + PREFIX, + &desired(&[("a", "one"), ("b", "two (edit 2)")]), + ) + .await + .unwrap(); + assert_eq!(e1.base_revision.as_deref(), Some(r0.as_str())); + assert_eq!(e2.base_revision.as_deref(), Some(r0.as_str())); + + let r1 = svc + .commit(&e1.change_id, Some(&r0)) + .await + .expect("first edit") + .commit_id; + let err = svc.commit(&e2.change_id, Some(&r0)).await.unwrap_err(); + assert_eq!( + err, + ChangeError::StaleBase { + expected: Some(r0.clone()), + current: Some(r1.clone()) + } + ); + + // Read it back from the real server: first edit kept, second absent, nothing lost. + let names = names_at(&client, &project, &r1).await; + assert_eq!(names[&format!("{PREFIX}a")], "one (edit 1)"); + assert_eq!(names[&format!("{PREFIX}b")], "two"); + assert_eq!( + client.commits(&project).await.unwrap().len(), + 2, + "seed + first edit only" + ); + + // Observation, not an assertion: does the pilot itself reject a stale `previousCommit`? + let stale_direct = client + .create_commit( + &project, + None, + CommitRequest { + type_: "Commit", + change: vec![DataVersion { + type_: "DataVersion", + payload: Some(json!({"@type": "PartUsage", "name": "direct stale", "aliasIds": [format!("{PREFIX}c")]})), + identity: None, + }], + previous_commit: Some(Ref { at_id: r0.clone(), extra: Default::default() }), + }, + ) + .await; + eprintln!( + "OBSERVED: pilot server on a stale previousCommit -> {}", + match &stale_direct { + Ok(_) => "ACCEPTED (the server does not enforce previousCommit; the service's own check is the guard)".to_string(), + Err(e) => format!("REJECTED ({e})"), + } + ); +} diff --git a/crates/kr0ki-core/tests/assurance_baseline.rs b/crates/kr0ki-core/tests/assurance_baseline.rs new file mode 100644 index 0000000..db23ec5 --- /dev/null +++ b/crates/kr0ki-core/tests/assurance_baseline.rs @@ -0,0 +1,225 @@ +//! Stage 0: the brief's own requirement baseline loads, is profile-valid, and its thread is +//! analysable. Every later stage (`assurance_a01` …) runs against this same baseline. + +use std::collections::BTreeSet; + +use kr0ki_core::assurance_baseline::{load_baseline, BaselineError, LoadedBaseline}; +use ufo_types::mbse::assurance::{ + analyze, Assurance, CurrentRevisions, GapKind, NodeKind, ProfileRequirement, RevisionSet, +}; + +pub const BASELINE_TOML: &str = include_str!("../../../docs/assurance/kr0ki.assurance.toml"); +pub const MODEL_SYSML: &str = include_str!("../../../docs/assurance/kr0ki-assurance.sysml"); + +fn load() -> LoadedBaseline { + load_baseline( + BASELINE_TOML, + "docs/assurance/kr0ki.assurance.toml", + "test-revision", + Some("model-1"), + ) + .expect("the checked-in baseline must load") +} + +fn revisions() -> CurrentRevisions { + CurrentRevisions::for_cases( + &RevisionSet { + model_revision: "model-1".into(), + implementation_revision: "impl-1".into(), + configuration_digest: format!("sha256:{}", "0".repeat(64)), + }, + ["VC-A01"], + ) +} + +const IDS: [&str; 8] = [ + "KR-A01", "KR-A02", "KR-A03", "KR-A04", "KR-A05", "KR-A06", "KR-A07", "KR-A08", +]; + +#[test] +fn baseline_loads_eight_profile_valid_requirements_with_a_clean_statement_lint() { + let loaded = load(); + assert_eq!( + loaded.statement_lints, + vec![], + "statement lint must be clean" + ); + let reqs: Vec<_> = loaded + .graph + .requirements + .iter() + .filter(|n| n.attributes.get("node_kind").map(String::as_str) == Some("requirement")) + .collect(); + assert_eq!(reqs.iter().map(|r| r.id.as_str()).collect::>(), IDS); + for r in reqs { + ProfileRequirement::from_node(r).unwrap_or_else(|e| panic!("{}: {e:?}", r.id)); + } + assert_eq!(loaded.model.as_deref(), Some("kr0ki-assurance.sysml")); +} + +#[test] +fn every_requirement_has_a_source_an_element_a_control_and_a_case() { + let loaded = load(); + let report = analyze(&loaded.graph, &[], &revisions(), None).expect("analyzable"); + assert_eq!(report.requirements.len(), 8); + for t in &report.requirements { + assert!(!t.sources.is_empty(), "{} has no source", t.requirement_id); + assert_eq!(t.satisfied_by.len(), 1, "{}", t.requirement_id); + assert_eq!(t.enforced_by.len(), 1, "{}", t.requirement_id); + assert_eq!(t.verification_cases.len(), 1, "{}", t.requirement_id); + } +} + +#[test] +fn the_three_source_kinds_stay_distinguishable() { + let loaded = load(); + let kinds: BTreeSet<&str> = loaded + .graph + .requirements + .iter() + .filter(|n| n.attributes.get("node_kind").map(String::as_str) == Some("source_obligation")) + .filter_map(|n| n.attributes.get("source_kind").map(String::as_str)) + .collect(); + assert_eq!( + kinds, + BTreeSet::from(["binding_obligation", "guidance", "organisational_policy"]) + ); +} + +#[test] +fn initial_gaps_are_exactly_the_unimplemented_controls_and_nothing_is_verified() { + let loaded = load(); + let report = analyze(&loaded.graph, &[], &revisions(), None).expect("analyzable"); + + // Every requirement is satisfied by design and untested: no evidence exists yet. + for t in &report.requirements { + assert_eq!( + t.assurance, + Assurance::SatisfiedUntested, + "{}", + t.requirement_id + ); + } + // The only structural gaps are controls the file records no implementation for. + let unimplemented: BTreeSet = loaded + .graph + .requirements + .iter() + .filter(|n| n.attributes.get("node_kind").map(String::as_str) == Some("control")) + .filter(|n| { + n.attributes + .get("implementation") + .is_none_or(|v| v.trim().is_empty()) + }) + .map(|n| n.id.clone()) + .collect(); + let reported: BTreeSet = report + .gaps + .iter() + .filter_map(|g| match &g.kind { + GapKind::ControlNotImplemented { control_id } => Some(control_id.clone()), + _ => None, + }) + .collect(); + assert_eq!(reported, unimplemented); + for g in &report.gaps { + assert!( + matches!( + g.kind, + GapKind::ControlNotImplemented { .. } | GapKind::SatisfiedUntested + ), + "unexpected structural gap {g:?}" + ); + } + assert!(report.orphan_evidence.is_empty()); +} + +#[test] +fn control_nodes_record_their_enforcing_component() { + let loaded = load(); + let component = |id: &str| { + loaded + .graph + .requirements + .iter() + .find(|n| n.id == id) + .and_then(|n| n.attributes.get("component").cloned()) + }; + // Sandbox enforcement lives in the deployment component, not in kr0ki. + assert_eq!(component("CTL-A07").as_deref(), Some("deployment")); + assert_eq!(component("CTL-A06").as_deref(), Some("kr0ki")); +} + +#[test] +fn the_sysml_model_parses() { + sysml_v2_parser::parse(MODEL_SYSML).expect("kr0ki-assurance.sysml must be valid SysML v2"); +} + +#[test] +fn node_kinds_cover_all_five_thread_stages() { + let loaded = load(); + let kinds: BTreeSet = loaded + .graph + .requirements + .iter() + .filter_map(|n| { + n.attributes + .get("node_kind") + .and_then(|k| NodeKind::parse(k)) + }) + .collect(); + assert_eq!(kinds.len(), 5); +} + +#[test] +fn a_typo_in_the_baseline_is_an_error_not_a_silently_dropped_field() { + let typo = BASELINE_TOML.replacen("owner = ", "ownr = ", 1); + assert!(matches!( + load_baseline(&typo, "x", "r", None), + Err(BaselineError::Toml(_)) + )); +} + +#[test] +fn a_reference_to_an_undeclared_node_is_rejected() { + let broken = BASELINE_TOML.replacen("control = \"CTL-A01\"", "control = \"CTL-NOPE\"", 1); + match load_baseline(&broken, "x", "r", None) { + Err(BaselineError::UnknownReference { + owner, + field, + value, + }) => { + assert_eq!( + (owner.as_str(), field, value.as_str()), + ("KR-A01", "control", "CTL-NOPE") + ); + } + other => panic!("expected UnknownReference, got {other:?}"), + } +} + +#[test] +fn an_invalid_status_is_reported_by_the_profile() { + let broken = BASELINE_TOML.replacen("status = \"gated\"", "status = \"done\"", 1); + assert!(matches!( + load_baseline(&broken, "x", "r", None), + Err(BaselineError::Profile(_)) + )); +} + +#[test] +fn the_baseline_digest_changes_when_the_file_changes() { + let a = load(); + let edited = BASELINE_TOML.replacen("kr0ki-maintainers", "someone-else", 1); + let b = load_baseline( + &edited, + "docs/assurance/kr0ki.assurance.toml", + "test-revision", + Some("model-1"), + ) + .unwrap(); + assert_ne!( + a.graph.baseline.import_artifact_sha256, + b.graph.baseline.import_artifact_sha256 + ); +} diff --git a/crates/kr0ki-core/tests/assurance_service.rs b/crates/kr0ki-core/tests/assurance_service.rs new file mode 100644 index 0000000..a66670c --- /dev/null +++ b/crates/kr0ki-core/tests/assurance_service.rs @@ -0,0 +1,358 @@ +//! `AssuranceService`: the targeted, revision-qualified answers the CLI, the HTTP routes and the +//! MCP tools share. Runs against this repository's real baseline with a stub executor and a +//! fixed revision probe, so no process is spawned. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use kr0ki_core::assurance_service::{ + AssuranceConfig, AssuranceService, RequirementFilter, ServiceError, +}; +use kr0ki_core::verification_runner::{CommandExecutor, ExecRequest, ExecResult, RevisionProbe}; +use ufo_types::mbse::assurance::{Assurance, Freshness, GapKind, VerificationResult}; + +struct Probe(Mutex); + +impl RevisionProbe for Probe { + fn implementation_revision(&self) -> Result { + Ok(self.0.lock().unwrap().clone()) + } + + fn toolchain(&self) -> String { + "rustc-test".into() + } +} + +#[derive(Default)] +struct Stub { + runs: Mutex>>, + output: Mutex>, +} + +impl CommandExecutor for Stub { + fn run(&self, req: &ExecRequest) -> ExecResult { + self.runs.lock().unwrap().push(req.argv.clone()); + let out = self + .output + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| "test result: ok. 3 passed; 0 failed; 0 ignored;\n".into()); + ExecResult { + exit_code: Some(0), + output: out.into_bytes(), + ..Default::default() + } + } +} + +struct Fixture { + service: AssuranceService, + probe: Arc, + stub: Arc, +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() +} + +fn fixture(tag: &str) -> Fixture { + let dir = std::env::temp_dir().join(format!("kr0ki-service-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let probe = Arc::new(Probe(Mutex::new("impl-1".into()))); + let stub = Arc::new(Stub::default()); + let service = AssuranceService::new( + AssuranceConfig { + baseline_path: repo_root().join("docs/assurance/kr0ki.assurance.toml"), + repo_root: repo_root(), + evidence_dir: dir, + run_timeout: Duration::from_secs(5), + }, + probe.clone(), + stub.clone(), + ); + Fixture { + service, + probe, + stub, + } +} + +#[test] +fn the_list_is_one_short_row_per_requirement_qualified_by_revisions() { + let f = fixture("list"); + let list = f + .service + .list_requirements(&RequirementFilter::default()) + .unwrap(); + assert_eq!(list.total, 8); + assert_eq!(list.requirements.len(), 8); + assert!(list.revisions.model_revision.starts_with("sha256:")); + assert_eq!(list.revisions.implementation_revision, "impl-1"); + let ids: Vec<_> = list.requirements.iter().map(|r| r.id.as_str()).collect(); + assert_eq!( + ids, + ["KR-A01", "KR-A02", "KR-A03", "KR-A04", "KR-A05", "KR-A06", "KR-A07", "KR-A08"] + ); + // No evidence yet: nothing is verified. + assert!(list + .requirements + .iter() + .all(|r| r.assurance == Assurance::SatisfiedUntested)); +} + +#[test] +fn filters_narrow_the_answer_so_only_what_is_needed_is_returned() { + let f = fixture("filter"); + let f1 = |mutate: &dyn Fn(&mut RequirementFilter)| { + let mut filter = RequirementFilter::default(); + mutate(&mut filter); + f.service.list_requirements(&filter).unwrap() + }; + assert_eq!(f1(&|x| x.status = Some("gated".into())).total, 8); + assert_eq!(f1(&|x| x.status = Some("draft".into())).total, 0); + assert_eq!(f1(&|x| x.state = Some("verified".into())).total, 0); + assert_eq!( + f1(&|x| x.state = Some("satisfied_untested".into())).total, + 8 + ); + assert_eq!(f1(&|x| x.owner = Some("nobody".into())).total, 0); + // Implementation gaps: the controls the baseline records no implementation for. + let unimplemented = f1(&|x| x.gap = Some("control_not_implemented".into())); + assert!( + unimplemented.total >= 1 && unimplemented.total < 8, + "{}", + unimplemented.total + ); + assert!(unimplemented.requirements.iter().all(|r| r + .gaps + .iter() + .any(|g| matches!(g, GapKind::ControlNotImplemented { .. })))); + // A filtered answer is strictly smaller than the whole. + let whole = serde_json::to_string( + &f.service + .list_requirements(&RequirementFilter::default()) + .unwrap(), + ) + .unwrap(); + let narrow = serde_json::to_string(&unimplemented).unwrap(); + assert!(narrow.len() < whole.len()); +} + +#[test] +fn one_requirement_in_full_with_its_whole_thread() { + let f = fixture("detail"); + let d = f.service.get_requirement("KR-A01").unwrap(); + assert_eq!(d.requirement.id, "KR-A01"); + assert!(d.requirement.statement.contains("shall")); + assert_eq!(d.requirement.verification_id, "VC-A01"); + assert!(d.statement_lints.is_empty()); + assert_eq!(d.elements, ["KrOKiAssurance::ModelService"]); + assert_eq!(d.cases.len(), 1); + assert_eq!(d.cases[0].command[0], "cargo"); + assert!(d + .obligations + .iter() + .any(|o| o.id == "OBL-OMG-REQIF-1.2" && o.source_kind == "binding_obligation")); + assert!(d + .obligations + .iter() + .any(|o| o.id == "OBL-OWNER-BRIEF" && o.source_kind == "organisational_policy")); + assert_eq!(d.controls[0].id, "CTL-A01"); + assert!(d.controls[0].implementation.contains("reqif_roundtrip.rs")); + assert_eq!(d.thread.assurance, Assurance::SatisfiedUntested); + assert!(matches!( + f.service.get_requirement("KR-NOPE"), + Err(ServiceError::UnknownRequirement(_)) + )); + // A node that is not a requirement is not one. + assert!(matches!( + f.service.get_requirement("CTL-A01"), + Err(ServiceError::UnknownRequirement(_)) + )); +} + +#[test] +fn trace_resolves_links_at_the_current_revision_and_reports_dangling_ones() { + let f = fixture("trace"); + let t = f.service.trace_requirement("KR-A01").unwrap(); + assert_eq!(t.dangling, 0, "{:#?}", t.links); + assert!(t + .links + .iter() + .any(|l| l.locator == "KrOKiAssurance::ModelService")); + assert!(t + .links + .iter() + .any(|l| l.locator.ends_with("tests/assurance_a01.rs"))); + // A requirement whose control records an implementation that does not yet exist, or whose + // test target is missing, shows it as dangling rather than hiding it. + let all: usize = ["KR-A06", "KR-A07", "KR-A08"] + .iter() + .map(|id| f.service.trace_requirement(id).unwrap().dangling) + .sum(); + assert!(all >= 1, "later stages' test targets do not exist yet"); + assert!(matches!( + f.service.trace_requirement("nope"), + Err(ServiceError::UnknownRequirement(_)) + )); +} + +#[test] +fn running_a_case_stores_revision_bound_evidence_and_moves_the_requirement_to_verified() { + let f = fixture("run"); + let out = f.service.run_verification("VC-A01", "impl-1").unwrap(); + assert_eq!(out.result, VerificationResult::Pass); + assert_eq!(out.revisions.implementation_revision, "impl-1"); + assert_eq!(out.evidence_keys.len(), 1); + assert_eq!(f.stub.runs.lock().unwrap().len(), 1); + + let list = f + .service + .list_requirements(&RequirementFilter::default()) + .unwrap(); + let state = |id: &str| { + list.requirements + .iter() + .find(|r| r.id == id) + .unwrap() + .assurance + }; + assert_eq!(state("KR-A01"), Assurance::Verified); + assert_eq!( + state("KR-A02"), + Assurance::SatisfiedUntested, + "satisfied, never tested" + ); + + let ev = f.service.evidence(Some("KR-A01"), None).unwrap(); + assert_eq!(ev.records.len(), 1); + assert_eq!(ev.records[0].freshness, Freshness::Fresh); + assert_eq!(ev.records[0].artifact, "intact"); + assert!(f + .service + .evidence(Some("KR-A02"), None) + .unwrap() + .records + .is_empty()); + assert_eq!( + f.service + .evidence(None, Some("VC-A01")) + .unwrap() + .records + .len(), + 1 + ); +} + +#[test] +fn a_changed_implementation_revision_makes_the_stored_result_stale() { + let f = fixture("stale"); + f.service.run_verification("VC-A01", "impl-1").unwrap(); + *f.probe.0.lock().unwrap() = "impl-2".into(); + let list = f + .service + .list_requirements(&RequirementFilter::default()) + .unwrap(); + assert_eq!( + list.requirements + .iter() + .find(|r| r.id == "KR-A01") + .unwrap() + .assurance, + Assurance::Stale + ); + assert_eq!(list.revisions.implementation_revision, "impl-2"); + let ev = f.service.evidence(Some("KR-A01"), None).unwrap(); + assert!( + matches!(ev.records[0].freshness, Freshness::Stale { drift } if drift.implementation && !drift.model) + ); +} + +#[test] +fn verifying_a_revision_other_than_the_repositorys_is_refused_and_runs_nothing() { + let f = fixture("mismatch"); + let err = f + .service + .run_verification("VC-A01", "some-other-revision") + .unwrap_err(); + assert!(matches!( + &err, + ServiceError::RevisionMismatch { expected, actual } if expected == "some-other-revision" && actual == "impl-1" + )); + assert!( + f.stub.runs.lock().unwrap().is_empty(), + "nothing may execute on a revision mismatch" + ); + assert!(f.service.evidence(None, None).unwrap().records.is_empty()); +} + +#[test] +fn an_unknown_case_is_refused() { + let f = fixture("unknown"); + assert!(matches!( + f.service.run_verification("VC-NOPE", "impl-1"), + Err(ServiceError::UnknownCase(_)) + )); +} + +#[test] +fn a_run_that_executes_zero_tests_is_an_error_not_a_pass() { + let f = fixture("zero"); + *f.stub.output.lock().unwrap() = + Some("test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out\n".into()); + let out = f.service.run_verification("VC-A01", "impl-1").unwrap(); + assert_eq!(out.result, VerificationResult::Error); + let list = f + .service + .list_requirements(&RequirementFilter::default()) + .unwrap(); + assert_ne!( + list.requirements + .iter() + .find(|r| r.id == "KR-A01") + .unwrap() + .assurance, + Assurance::Verified + ); +} + +#[test] +fn verify_all_runs_only_the_cases_whose_test_targets_exist() { + let f = fixture("all"); + let outcomes = f.service.verify_all().unwrap(); + let ids: Vec<_> = outcomes.iter().map(|o| o.case_id.as_str()).collect(); + assert!(ids.contains(&"VC-A01") && ids.contains(&"VC-A02")); + // Every run happened through the allowlisted shape. + for argv in f.stub.runs.lock().unwrap().iter() { + assert_eq!(&argv[..3], ["cargo", "test", "-p"]); + } + for id in &ids { + let path = { + let l = f.service.get_requirement(match *id { + "VC-A01" => "KR-A01", + "VC-A02" => "KR-A02", + "VC-A03" => "KR-A03", + "VC-A04" => "KR-A04", + "VC-A05" => "KR-A05", + "VC-A06" => "KR-A06", + "VC-A07" => "KR-A07", + _ => "KR-A08", + }); + l.is_ok() + }; + assert!(path); + } +} + +#[test] +fn the_model_revision_alone_is_cheap_and_matches_the_snapshots() { + let f = fixture("rev"); + let alone = f.service.model_revision().unwrap(); + let snap = f.service.snapshot().unwrap(); + assert_eq!(alone, snap.index.revision()); +} diff --git a/crates/kr0ki-server/src/gateway.rs b/crates/kr0ki-server/src/gateway.rs new file mode 100644 index 0000000..c0f22ad --- /dev/null +++ b/crates/kr0ki-server/src/gateway.rs @@ -0,0 +1,634 @@ +// DRAFT - NOT YET COMPILED OR WIRED. Not declared as a module in app.rs; see the PR description for what remains. +//! KR-A06: the tool gateway β€” authenticate the caller, check its capability grant for the +//! operation, audit the decision, and only then run the handler. **For every transport.** +//! +//! # Why this is in the server and not the MCP bridge +//! +//! The MCP bridge forwards `tools/call` to this server's HTTP routes with the bearer token it +//! was started with; a direct HTTP client sends the same kind of request. Enforcing here, on +//! the route a request actually reaches, covers both identically. A bridge that hid a tool, a +//! description claiming a tool is read-only, or a `readOnlyHint` annotation is *advice to the +//! model*, not enforcement (MCP spec 2026-07-28: tool annotations "should be considered +//! untrusted"); nothing here relies on them. +//! +//! # Model +//! +//! - An [`Identity`] has an id and a set of **grants**. Tokens are never stored: the identities +//! file holds each token's SHA-256, compared in constant time. +//! - Every route is **classified** by its matched route pattern into an [`Operation`] requiring +//! one grant. A route with no table entry β€” or no route at all β€” requires `admin`: +//! **deny by default**. `tests` below fail if a route is added to the router without being +//! classified, or classified without existing. +//! - `admin` implies every grant. +//! - [`AuthMode`]: `Identities` (a file of identities), `SharedToken` (the legacy single +//! `KR0KI_AUTH_TOKEN`, which is admin β€” FR7 minimal, unchanged), or `Open` (no auth, local +//! development). Only `Identities` mode can express a read-only caller. +//! +//! # Audit +//! +//! With an [`AuditLog`] configured, one record is written **before** the handler runs (who, +//! what, the decision) and one after (the status). If the decision record cannot be written, +//! the request is refused (503): the gateway fails closed rather than act unrecorded. +//! `GET /health` is exempt from both authentication and audit β€” a readiness probe is not a +//! tool invocation. +//! +//! A caller-supplied `X-Request-ID` is adopted as the correlation id (so callers can correlate +//! their own logs); it is length-capped here and uniqueness comes from the log's `seq`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use axum::extract::{MatchedPath, Request}; +use axum::http::{header, Method, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use kr0ki_core::assurance_baseline::sha256_hex; +use kr0ki_core::audit_log::{now_rfc3339, AuditDraft, AuditLog, Decision, Phase}; +use kr0ki_core::mcp_tool::McpTool; +use serde::Deserialize; + +use crate::contract::RequestId; + +/// Every grant an identity may hold. +pub const GRANTS: [&str; 9] = [ + "render", + "model.read", + "model.commit", + "assurance.read", + "assurance.propose", + "assurance.verify", + "audit.read", + "ui.steer", + "admin", +]; + +// ── Identities ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + pub id: String, + grants: BTreeSet, + token_sha256: String, +} + +impl Identity { + pub fn new(id: impl Into, grants: &[&str], token: &str) -> Self { + Self { + id: id.into(), + grants: grants.iter().map(|g| (*g).to_string()).collect(), + token_sha256: sha256_hex(token.as_bytes()), + } + } + + pub fn permits(&self, grant: &str) -> bool { + self.grants.contains("admin") || self.grants.contains(grant) + } + + pub fn grants(&self) -> &BTreeSet { + &self.grants + } +} + +#[derive(Debug, thiserror::Error)] +pub enum IdentityError { + #[error("identities file: {0}")] + Toml(#[from] toml::de::Error), + #[error("identity `{0}` is declared twice")] + DuplicateId(String), + #[error("identities `{0}` and `{1}` share a token")] + DuplicateToken(String, String), + #[error("identity `{id}` has unknown grant `{grant}` (known: {})", GRANTS.join(", "))] + UnknownGrant { id: String, grant: String }, + #[error("identity `{0}`: token_sha256 must be 64 lowercase hex digits (the SHA-256 of the token)")] + BadDigest(String), + #[error("the identities file declares no identity")] + Empty, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct IdentitiesDoc { + #[serde(default)] + identity: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct IdentityDoc { + id: String, + token_sha256: String, + grants: Vec, +} + +/// Parse and validate an identities file. +pub fn parse_identities(text: &str) -> Result, IdentityError> { + let doc: IdentitiesDoc = toml::from_str(text)?; + if doc.identity.is_empty() { + return Err(IdentityError::Empty); + } + let mut out: Vec = Vec::new(); + for i in doc.identity { + if out.iter().any(|o| o.id == i.id) { + return Err(IdentityError::DuplicateId(i.id)); + } + let digest_ok = i.token_sha256.len() == 64 + && i.token_sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + if !digest_ok { + return Err(IdentityError::BadDigest(i.id)); + } + if let Some(g) = i.grants.iter().find(|g| !GRANTS.contains(&g.as_str())) { + return Err(IdentityError::UnknownGrant { id: i.id.clone(), grant: g.clone() }); + } + if let Some(other) = out.iter().find(|o| o.token_sha256 == i.token_sha256) { + return Err(IdentityError::DuplicateToken(other.id.clone(), i.id)); + } + out.push(Identity { + id: i.id, + grants: i.grants.into_iter().collect(), + token_sha256: i.token_sha256, + }); + } + Ok(out) +} + +fn constant_time_eq(a: &str, b: &str) -> bool { + a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +#[derive(Debug, Clone)] +pub enum AuthMode { + /// No authentication (local development). Every caller is `anonymous`, with every grant. + Open, + /// The legacy single `KR0KI_AUTH_TOKEN`. The caller is `shared-token`, with every grant. + SharedToken(String), + /// Per-identity tokens and grants. + Identities(Vec), +} + +// ── Operations ────────────────────────────────────────────────────────────────── + +/// What a route does, for authorisation and audit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Operation { + /// The MCP tool name when one is bound to this route (so a call is recorded under one name + /// whichever transport it arrived on), else `METHOD pattern`. + pub name: String, + /// The grant required; `None` = any authenticated caller. + pub grant: Option<&'static str>, +} + +/// `(method, route pattern, required grant)`. Anything not here requires `admin`. +/// +/// Patterns are the literals passed to `Router::route` (axum `:param` style). +pub const ROUTES: &[(&str, &str, Option<&str>)] = &[ + // public to any authenticated caller + ("GET", "/", None), + ("GET", "/welcome", None), + ("GET", "/formats", None), + ("GET", "/mcp/tools", None), + ("GET", "/mcp/resources", None), + ("GET", "/mcp/prompts", None), + ("GET", "/capabilities", None), + ("GET", "/api/examples", None), + ("GET", "/playbook/api/examples.json", None), + ("GET", "/api/catalog", None), + ("GET", "/brand", None), + ("GET", "/playbook", None), + ("GET", "/playbook/", None), + ("GET", "/playbook/*path", None), + ("GET", "/docs", None), + ("GET", "/docs/examples/kr0ki-render-flow.svg", None), + ("GET", "/docs/api.json", None), + ("GET", "/docs/api.tomllm", None), + ("GET", "/docs/api.rustdoc", None), + // rendering + ("POST", "/api/catalog/suggest", Some("render")), + ("POST", "/render/:format", Some("render")), + ("POST", "/render/requirements-view", Some("render")), + ("POST", "/render/kubediagram", Some("render")), + ("POST", "/render/k8s-topology", Some("render")), + ("POST", "/render/rust-source", Some("render")), + ("POST", "/render/sysmlv2/projects/:project_id/commits/:commit_id", Some("render")), + ("GET", "/cache/:key", Some("render")), + ("GET", "/ui/:session/events", Some("render")), + // read-only computation and model reads + ("POST", "/sparql", Some("model.read")), + ("POST", "/sparql/shapes", Some("model.read")), + ("POST", "/sparql/rollup", Some("model.read")), + ("POST", "/sysml/validate", Some("model.read")), + ("POST", "/sysml/parse", Some("model.read")), + ("POST", "/sysml/symbols", Some("model.read")), + ("POST", "/sysml/summary", Some("model.read")), + ("POST", "/requirements/import", Some("model.read")), + ("POST", "/requirements/import/url", Some("model.read")), + ("POST", "/requirements/export", Some("model.read")), + ("POST", "/requirements/views", Some("model.read")), + ("GET", "/model/projects", Some("model.read")), + ("GET", "/model/projects/:project_id/commits", Some("model.read")), + ("GET", "/model/projects/:project_id/commits/:commit_id/snapshot", Some("model.read")), + ("GET", "/model/projects/:project_id/commits/:commit_id/elements", Some("model.read")), + ("GET", "/model/projects/:project_id/commits/:commit_id/roots", Some("model.read")), + ( + "GET", + "/model/projects/:project_id/commits/:commit_id/elements/:element_id/relationships", + Some("model.read"), + ), + ("POST", "/model/projects/:project_id/recompute", Some("model.read")), + ("GET", "/model/graph/query", Some("model.read")), + ("GET", "/b00t-graph/:tag", Some("model.read")), + // writes to the model + ("POST", "/model/projects/:project_id/sync", Some("model.commit")), + ("POST", "/model/projects/:project_id/requirements", Some("model.commit")), + // steering the UI + ("POST", "/ui/:session/navigate", Some("ui.steer")), + // the assurance thread + ("GET", "/assurance/requirements", Some("assurance.read")), + ("GET", "/assurance/requirements/:id", Some("assurance.read")), + ("GET", "/assurance/requirements/:id/trace", Some("assurance.read")), + ("GET", "/assurance/view", Some("assurance.read")), + ("GET", "/assurance/evidence", Some("assurance.read")), + ("POST", "/assurance/changes", Some("assurance.propose")), + ("GET", "/assurance/changes/:change_id", Some("assurance.read")), + ("POST", "/assurance/changes/:change_id/commit", Some("model.commit")), + ("POST", "/assurance/verify/:case_id", Some("assurance.verify")), + ("GET", "/assurance/audit", Some("audit.read")), + ("GET", "/assurance/resources/*uri", Some("assurance.read")), +]; + +/// The grant a `(method, pattern)` requires. Unknown β†’ `admin` (deny by default). +pub fn required_grant(method: &str, pattern: Option<&str>) -> Option<&'static str> { + let Some(pattern) = pattern else { return Some("admin") }; + ROUTES + .iter() + .find(|(m, p, _)| *m == method && *p == pattern) + .map(|(_, _, g)| *g) + .unwrap_or(Some("admin")) +} + +/// `(method, pattern)` β†’ MCP tool name, from the tools' own HTTP bindings. +fn tool_names() -> BTreeMap<(String, String), &'static str> { + McpTool::ALL + .iter() + .map(|t| { + let b = t.http_binding(); + let method = format!("{:?}", b.method).to_uppercase(); + let pattern = b.path_template.replace('{', ":").replace('}', ""); + ((method, pattern), t.name()) + }) + .collect() +} + +// ── The gateway ───────────────────────────────────────────────────────────────── + +pub struct Gateway { + mode: AuthMode, + audit: Option>, + tools: BTreeMap<(String, String), &'static str>, + model_revision: Option Option + Send + Sync>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Authn { + Caller(Identity), + Failed, +} + +impl Gateway { + pub fn new(mode: AuthMode, audit: Option>) -> Self { + Self { mode, audit, tools: tool_names(), model_revision: None } + } + + /// How to learn "the model revision in play" for routes that name none in the request + /// (the assurance routes evaluate against the service's current model revision). + pub fn with_model_revision(mut self, f: impl Fn() -> Option + Send + Sync + 'static) -> Self { + self.model_revision = Some(Box::new(f)); + self + } + + pub fn audit(&self) -> Option<&Arc> { + self.audit.as_ref() + } + + pub fn authenticate(&self, authorization: Option<&str>) -> Authn { + let bearer = authorization.and_then(|v| v.strip_prefix("Bearer ")); + match &self.mode { + AuthMode::Open => Authn::Caller(Identity::new("anonymous", &["admin"], "")), + AuthMode::SharedToken(token) => match bearer { + Some(b) if constant_time_eq(&sha256_hex(b.as_bytes()), &sha256_hex(token.as_bytes())) => { + Authn::Caller(Identity::new("shared-token", &["admin"], token)) + } + _ => Authn::Failed, + }, + AuthMode::Identities(identities) => { + let Some(b) = bearer else { return Authn::Failed }; + let presented = sha256_hex(b.as_bytes()); + // Compare against every identity: no early exit on a match. + let mut found: Option<&Identity> = None; + for i in identities { + if constant_time_eq(&presented, &i.token_sha256) { + found = Some(i); + } + } + found.map_or(Authn::Failed, |i| Authn::Caller(i.clone())) + } + } + } + + pub fn operation(&self, method: &Method, pattern: Option<&str>) -> Operation { + let grant = required_grant(method.as_str(), pattern); + let name = pattern + .and_then(|p| self.tools.get(&(method.as_str().to_string(), p.to_string()))) + .map(|n| (*n).to_string()) + .unwrap_or_else(|| format!("{} {}", method, pattern.unwrap_or("(no route)"))); + Operation { name, grant } + } + + /// The model revision a request is about: a commit id in the path, an `expected_revision` + /// or `revision` in the query, else the service's current model revision for assurance + /// routes, else none. + fn revision_for(&self, path: &str, query: Option<&str>, pattern: Option<&str>) -> Option { + let mut segments = path.split('/'); + while let Some(s) = segments.next() { + if s == "commits" { + if let Some(id) = segments.next().filter(|s| !s.is_empty()) { + return Some(id.chars().take(128).collect()); + } + } + } + for pair in query.unwrap_or("").split('&') { + if let Some(("expected_revision" | "revision", v)) = pair.split_once('=') { + if !v.is_empty() { + return Some(v.chars().take(128).collect()); + } + } + } + if pattern.is_some_and(|p| p.starts_with("/assurance")) { + return self.model_revision.as_ref().and_then(|f| f()); + } + None + } +} + +fn deny_response(status: StatusCode, code: &str, detail: &str, request_id: &str) -> Response { + ( + status, + Json(serde_json::json!({"error": code, "detail": detail, "request_id": request_id})), + ) + .into_response() +} + +fn cap(s: &str) -> String { + s.chars().take(128).collect() +} + +/// The middleware. Layer it *inside* the contract middleware so a `RequestId` exists. +pub async fn gateway_middleware(req: Request, next: Next, gateway: Arc) -> Response { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + // A readiness probe is not a tool invocation. + if method == Method::GET && path == "/health" { + return next.run(req).await; + } + + let request_id = req.extensions().get::().map(|r| cap(&r.0)).unwrap_or_default(); + let pattern = req.extensions().get::().map(|m| m.as_str().to_string()); + let transport = match req.headers().get("x-kr0ki-transport").and_then(|v| v.to_str().ok()) { + Some("mcp") => "mcp", + _ => "http", + }; + let authorization = req.headers().get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()); + let authn = gateway.authenticate(authorization); + let op = gateway.operation(&method, pattern.as_deref()); + let revision = gateway.revision_for(&path, req.uri().query(), pattern.as_deref()); + + let (caller, decision, reason, deny_status) = match &authn { + Authn::Failed => ( + "unauthenticated".to_string(), + Decision::Deny, + Some("missing or invalid bearer token".to_string()), + StatusCode::UNAUTHORIZED, + ), + Authn::Caller(identity) => match op.grant { + Some(grant) if !identity.permits(grant) => ( + identity.id.clone(), + Decision::Deny, + Some(format!("identity `{}` has no `{grant}` grant for `{}`", identity.id, op.name)), + StatusCode::FORBIDDEN, + ), + _ => (identity.id.clone(), Decision::Permit, None, StatusCode::OK), + }, + }; + + let draft = |phase: Phase, status: Option| AuditDraft { + correlation_id: request_id.clone(), + phase, + caller: caller.clone(), + operation: op.name.clone(), + method: method.to_string(), + path: path.clone(), + decision, + reason: reason.clone(), + model_revision: revision.clone(), + transport: transport.to_string(), + status, + }; + + // Record the decision *before* acting on it; if it cannot be recorded, do not act. + if let Some(audit) = &gateway.audit { + if let Err(e) = audit.append(draft(Phase::Decision, None), &now_rfc3339()) { + tracing::error!("audit write failed; refusing the request: {e}"); + return deny_response( + StatusCode::SERVICE_UNAVAILABLE, + "audit_unavailable", + "the audit log cannot be written, so this operation was not performed", + &request_id, + ); + } + } + + if decision == Decision::Deny { + let code = if deny_status == StatusCode::UNAUTHORIZED { "unauthorized" } else { "forbidden" }; + let response = deny_response(deny_status, code, reason.as_deref().unwrap_or("denied"), &request_id); + if let Some(audit) = &gateway.audit { + let _ = audit.append(draft(Phase::Outcome, Some(deny_status.as_u16())), &now_rfc3339()); + } + return response; + } + + let response = next.run(req).await; + if let Some(audit) = &gateway.audit { + if let Err(e) = audit.append(draft(Phase::Outcome, Some(response.status().as_u16())), &now_rfc3339()) { + tracing::error!("audit outcome write failed: {e}"); + } + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + + fn route_literals(source: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut rest = source; + while let Some(i) = rest.find(".route(") { + rest = &rest[i + ".route(".len()..]; + let trimmed = rest.trim_start(); + if let Some(after) = trimmed.strip_prefix('"') { + if let Some(end) = after.find('"') { + out.insert(after[..end].to_string()); + } + } + } + out + } + + /// Every pattern registered with the router, as written in the source. + fn registered() -> BTreeSet { + let mut all = route_literals(include_str!("app.rs")); + all.extend(route_literals(include_str!("docs.rs"))); + all + } + + #[test] + fn every_registered_route_is_classified_and_every_classification_names_a_real_route() { + let registered = registered(); + let classified: BTreeSet = ROUTES.iter().map(|(_, p, _)| (*p).to_string()).collect(); + // `/health` is exempt before classification; `/assurance/*` routes are registered by the + // assurance module, which this scan also reads. + let mut registered = registered; + registered.extend(route_literals(include_str!("assurance.rs"))); + registered.remove("/health"); + let unclassified: Vec<_> = registered.difference(&classified).collect(); + assert!( + unclassified.is_empty(), + "routes registered but not classified in gateway::ROUTES (they would require admin): {unclassified:?}" + ); + let stale: Vec<_> = classified.difference(®istered).collect(); + assert!(stale.is_empty(), "gateway::ROUTES entries that match no registered route: {stale:?}"); + } + + #[test] + fn grants_in_the_table_are_all_known() { + for (m, p, g) in ROUTES { + if let Some(g) = g { + assert!(GRANTS.contains(g), "{m} {p}: unknown grant {g}"); + } + } + } + + #[test] + fn unknown_and_unmatched_routes_require_admin() { + assert_eq!(required_grant("POST", Some("/not/a/route")), Some("admin")); + assert_eq!(required_grant("DELETE", Some("/model/projects")), Some("admin"), "right path, wrong method"); + assert_eq!(required_grant("GET", None), Some("admin")); + } + + #[test] + fn every_write_to_the_model_requires_model_commit() { + for (m, p, g) in ROUTES { + let writes_model = (p.starts_with("/model/") || p.contains("/commit")) && *m == "POST"; + let is_compute = p.ends_with("/recompute"); + if writes_model && !is_compute { + assert_eq!(*g, Some("model.commit"), "{m} {p}"); + } + } + } + + #[test] + fn identities_files_are_validated() { + let ok = |s: &str| parse_identities(s); + let d = sha256_hex(b"t1"); + let d2 = sha256_hex(b"t2"); + assert!(ok(&format!("[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[\"model.read\"]\n")).is_ok()); + assert!(matches!(ok(""), Err(IdentityError::Empty))); + assert!(matches!( + ok(&format!("[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[\"root\"]\n")), + Err(IdentityError::UnknownGrant { .. }) + )); + assert!(matches!( + ok("[[identity]]\nid=\"a\"\ntoken_sha256=\"not-hex\"\ngrants=[]\n"), + Err(IdentityError::BadDigest(_)) + )); + let dup_id = format!( + "[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[]\n[[identity]]\nid=\"a\"\ntoken_sha256=\"{d2}\"\ngrants=[]\n" + ); + assert!(matches!(ok(&dup_id), Err(IdentityError::DuplicateId(_)))); + let dup_tok = format!( + "[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrants=[]\n[[identity]]\nid=\"b\"\ntoken_sha256=\"{d}\"\ngrants=[]\n" + ); + assert!(matches!(ok(&dup_tok), Err(IdentityError::DuplicateToken(_, _)))); + // a typo'd key is an error, not a silently dropped field + assert!(ok(&format!("[[identity]]\nid=\"a\"\ntoken_sha256=\"{d}\"\ngrant=[\"admin\"]\n")).is_err()); + // a plaintext token in the digest field is rejected, never stored + assert!(matches!( + ok("[[identity]]\nid=\"a\"\ntoken_sha256=\"my-secret-token\"\ngrants=[]\n"), + Err(IdentityError::BadDigest(_)) + )); + } + + #[test] + fn authentication_by_mode() { + let ids = vec![ + Identity::new("reader", &["model.read"], "tok-reader"), + Identity::new("committer", &["model.read", "model.commit"], "tok-commit"), + ]; + let gw = Gateway::new(AuthMode::Identities(ids), None); + let who = |h: Option<&str>| match gw.authenticate(h) { + Authn::Caller(i) => Some(i.id), + Authn::Failed => None, + }; + assert_eq!(who(Some("Bearer tok-reader")).as_deref(), Some("reader")); + assert_eq!(who(Some("Bearer tok-commit")).as_deref(), Some("committer")); + assert_eq!(who(Some("Bearer nope")), None); + assert_eq!(who(Some("tok-reader")), None, "no Bearer scheme"); + assert_eq!(who(None), None); + + let shared = Gateway::new(AuthMode::SharedToken("s3cret".into()), None); + assert!(matches!(shared.authenticate(Some("Bearer s3cret")), Authn::Caller(i) if i.id == "shared-token" && i.permits("anything"))); + assert_eq!(shared.authenticate(Some("Bearer other")), Authn::Failed); + + let open = Gateway::new(AuthMode::Open, None); + assert!(matches!(open.authenticate(None), Authn::Caller(i) if i.id == "anonymous")); + } + + #[test] + fn grants_decide_and_admin_implies_all() { + let reader = Identity::new("r", &["model.read"], "t"); + assert!(reader.permits("model.read")); + assert!(!reader.permits("model.commit")); + assert!(Identity::new("a", &["admin"], "t").permits("model.commit")); + } + + #[test] + fn the_operation_is_named_by_its_mcp_tool_when_it_has_one() { + let gw = Gateway::new(AuthMode::Open, None); + let op = gw.operation(&Method::GET, Some("/model/projects")); + assert_eq!(op.name, "list_model_projects"); + assert_eq!(op.grant, Some("model.read")); + let op = gw.operation(&Method::POST, Some("/sparql")); + assert_eq!(op.name, "POST /sparql"); + let op = gw.operation(&Method::GET, None); + assert_eq!((op.name.as_str(), op.grant), ("GET (no route)", Some("admin"))); + } + + #[test] + fn the_model_revision_comes_from_the_path_the_query_or_the_service() { + let gw = Gateway::new(AuthMode::Open, None).with_model_revision(|| Some("model-now".into())); + assert_eq!( + gw.revision_for("/model/projects/p/commits/c-42/elements", None, Some("/model/projects/:project_id/commits/:commit_id/elements")).as_deref(), + Some("c-42") + ); + assert_eq!( + gw.revision_for("/assurance/changes/x/commit", Some("expected_revision=r7&x=1"), Some("/assurance/changes/:change_id/commit")).as_deref(), + Some("r7") + ); + assert_eq!( + gw.revision_for("/assurance/view", None, Some("/assurance/view")).as_deref(), + Some("model-now") + ); + assert_eq!(gw.revision_for("/formats", None, Some("/formats")), None); + assert_eq!(gw.revision_for("/assurance/view", Some("revision="), Some("/assurance/view")).as_deref(), Some("model-now")); + } +} diff --git a/crates/kr0ki-server/tests/http.rs b/crates/kr0ki-server/tests/http.rs index fac3886..648d0e6 100644 --- a/crates/kr0ki-server/tests/http.rs +++ b/crates/kr0ki-server/tests/http.rs @@ -199,7 +199,7 @@ async fn mcp_tools_lists_all_tools_with_bindings() { let (status, body) = body_string(resp).await; assert_eq!(status, StatusCode::OK); let tools: Vec = serde_json::from_str(&body).unwrap(); - assert_eq!(tools.len(), 22); + assert_eq!(tools.len(), 32); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"render_diagram")); assert!(names.contains(&"list_formats")); diff --git a/docs/assurance/BRIEF-assurance-thread.md b/docs/assurance/BRIEF-assurance-thread.md new file mode 100644 index 0000000..231c11e --- /dev/null +++ b/docs/assurance/BRIEF-assurance-thread.md @@ -0,0 +1,177 @@ +# Brief: versioned assurance thread (owner brief, 2026-10-02) + +This is the source obligation `OBL-OWNER-BRIEF` in `kr0ki.assurance.toml`. Section anchors +`#KR-A01` … `#KR-A08` are the locators the baseline's `derives` edges point at. It records +the brief as given; it is not a verification result. The repository README's claims +(ReqIF import/export, requirements views, OMG model API client and sync path, MCP tools, Vue +Playb00k) are repository declarations, not independently verified runtime results, and live +write-path validation against a real model server remains outstanding. + +## Goal + +Build a **versioned assurance thread**: + +```text +source obligation β†’ requirement β†’ system element β†’ enforcing control β†’ verification case β†’ evidence +``` + +Use kr0ki's own requirements and agent workflow as the first working example. Extend the +existing surfaces. kr0ki consumes the upstream model; it renders and exposes views. + +Benefits sought from the requirements agent (reqiffy): reusable requirements authoring, +exchange, visible relationships and implementation gaps, reproducible assurance, and smaller +agent context through targeted model queries. + +## Model contract + +A minimal application profile over ReqIF and SysML v2. Standard elements are kept distinct +from application metadata. + +| Concept | ReqIF representation | SysML v2 oriented representation | +|---|---|---| +| Source obligation | Typed SpecObject, source URI/version | Referenced source metadata | +| Engineering requirement | SpecObject with stable identifier and typed attributes | Requirement definition/usage | +| Architecture | External element reference | Parts, ports, connections, constraints | +| Traceability | Typed SpecRelation | Derivation, satisfaction and verification relationships | +| Acceptance test | Verification reference attributes | Verification case | +| Evidence | URI, digest, result and revision attributes | Application evidence record linked to verification | + +**Required requirement fields:** `id`, `statement`, `source`, `source_kind`, `owner`, +`rationale`, `verification_id`, `acceptance`, `status`. Guidance, organisational policy and +binding obligations stay distinguishable (`source_kind`). + +**Evidence fields:** `requirement_id`, `verification_id`, `model_revision`, +`implementation_revision`, `configuration_digest`, `result`, `artifact_uri`, +`artifact_digest`. + +A **satisfaction relationship is an architectural assertion**; a **passing result is +revision-specific evidence**. + +## Runtime parts + +`AgentWorkload`, `WorkloadIdentity`, `ToolGateway`, `ExecutionSandbox`, `ModelService`, +`VerificationRunner`, `EvidenceStore`. Each requirement is assigned to its responsible part and +verification case. Sandbox and identity enforcement stay in their deployment components; +kr0ki displays their relationships and evidence. + +(The model, `kr0ki-assurance.sysml`, adds one sub-part, `ToolGateway::AuditRecorder`, because +KR-A08 names an "audit service" that is not among the seven parts. That is a modelling choice +to confirm with the owner.) + +## Requirements and progressive dogfooding + +Each row becomes one requirement object and one acceptance case. Implement in order; run every +completed stage against this brief's own requirement baseline. + +### KR-A01 + +The requirement adapter shall preserve supported requirement identifiers, typed attributes and +asserted relations across ReqIF export and reimport. + +*Acceptance:* round-trip this baseline; compare semantic content. Report unsupported content +explicitly. + +### KR-A02 + +The traceability adapter shall resolve requirement links against a specified model revision. + +*Acceptance:* link KR-A01 to its adapter and test; report a deliberately dangling reference. + +### KR-A03 + +The requirements view shall distinguish satisfaction assertions from verification results. + +*Acceptance:* show one satisfied but untested requirement and one verified requirement. + +### KR-A04 + +The verification runner shall bind each result to the model, implementation and configuration +revisions used. + +*Acceptance:* run KR-A01's case; retrieve its evidence. Change a revision and display the +previous result as stale. + +### KR-A05 + +The model commit service shall reject a proposed mutation whose expected base revision differs +from the current revision. + +*Acceptance:* submit two edits from one baseline; reject the second stale edit without losing +the first. + +### KR-A06 + +The tool gateway shall deny operations outside the caller identity's capability grant. + +*Acceptance:* permit model reads; deny a commit from the same read-only identity through MCP +and direct HTTP. + +### KR-A07 + +The workload sandbox shall deny writes outside its declared writable mounts. + +*Acceptance:* permit temporary output; deny a write to the mounted requirement baseline. + +### KR-A08 + +The audit service shall record each tool invocation with caller, operation, decision, model +revision and correlation identifier. + +*Acceptance:* retrieve records for both the permitted and denied KR-A06 calls. + +### Per-increment procedure + +For each increment: add the fixture and acceptance case, implement, exercise it through +Playb00k/MCP, attach evidence, then regenerate the requirements view. Use the existing +`just check` and `just test` gates; run relevant live checks before recording a live +integration as verified. + +## MCP surfaces + +MCP separates resources, tools and prompts: prompts are user-selected workflows, resources +supply context, tools execute operations. The names below are proposed contracts, not claims +about existing tool names; reuse existing tools where equivalent. Return structured results +with identifiers and revision references. Enforce authorisation in the service/gateway for +every transport; tool visibility, descriptions and read-only annotations are insufficient +enforcement. + +| Surface | Proposed minimum interface | Purpose | +|---|---|---| +| Resources | Revision-qualified requirements, model elements, evidence and skill text | Retrieve only the needed context | +| Read tools | `get_requirement`, `trace_requirement`, `render_view` | Inspect obligations, implementation and gaps | +| Draft tools | `propose_change`, `validate_change` | Return a diff and diagnostics without committing | +| Commit tool | `commit_change(change_id, expected_revision)` | Apply an authorised, revision-checked change | +| Verification tool | `run_verification(case_id, revision)` | Execute a declared case and return evidence references | +| Prompts | `review_requirement`, `explain_gap`, `implement_next_slice` | Guide repeatable developer workflows | + +## Skills + +`SKILLS.md` is a short repository index; the portable executable instruction package is a +singular `SKILL.md` with metadata, instructions and optional scripts/references. The +requirements-authoring skill's workflow: + +1. Retrieve the source and existing requirements before proposing additions. +2. Write one observable obligation per requirement: responsible component + "shall" + + behaviour + condition. +3. Separate rationale and implementation choices from the statement. +4. Assign a stable ID, source, owner and measurable acceptance case. +5. Check duplicates, contradictions, missing links and undefined terms. +6. Produce a proposed diff; validate before committing. +7. After implementation, run the linked case and attach revision-bound evidence. + +Use deterministic checks for schema, identifiers and links; use the agent to flag ambiguity and +suggest improvements. Skill instructions guide authoring; the gateway and sandbox enforce +permissions. + +## References + +- OMG ReqIF 1.2, formal/16-07-01 β€” +- OMG Systems Modeling API and Services 1.0, formal/26-03-04 β€” + +- Model Context Protocol specification 2026-07-28 β€” + +- Agent Skills specification β€” +- NASA Systems Engineering Handbook, Appendix C "How to Write a Good Requirement" β€” + + (C.1: "shall" for requirements, "will" for facts, "should" for goals; C.3: rationale; C.4: + clarity, completeness, verifiability.) diff --git a/docs/assurance/kr0ki-assurance.sysml b/docs/assurance/kr0ki-assurance.sysml new file mode 100644 index 0000000..f4fc521 --- /dev/null +++ b/docs/assurance/kr0ki-assurance.sysml @@ -0,0 +1,44 @@ +// Runtime of the kr0ki requirements agent ("reqiffy"), modelled as parts. +// +// Enforcement of identity and sandboxing lives in their deployment components +// (WorkloadIdentity, ExecutionSandbox); kr0ki displays their relationships and +// evidence, it does not implement them. Qualified names here are the locators +// that `system_element` nodes in kr0ki.assurance.toml resolve against (KR-A02). +package KrOKiAssurance { + + // The agent process that proposes and requests changes. Untrusted by design. + part def AgentWorkload; + + // Who the workload is, and what it may do (its capability grant). + part def WorkloadIdentity; + + // The single path from agent to tools; enforces the grant on every transport. + part def ToolGateway { + part audit : AuditRecorder; + } + + // Appends one record per tool invocation, permitted or denied. + part def AuditRecorder; + + // Confines the workload's filesystem writes to declared mounts. + part def ExecutionSandbox; + + // Serves revision-qualified model and requirement content; accepts commits. + part def ModelService; + + // Runs declared verification cases and returns evidence references. + part def VerificationRunner; + + // Holds revision-bound evidence records and their artifacts. + part def EvidenceStore; + + part def AssuranceRuntime { + part workload : AgentWorkload; + part identity : WorkloadIdentity; + part gateway : ToolGateway; + part sandbox : ExecutionSandbox; + part modelService : ModelService; + part runner : VerificationRunner; + part evidenceStore : EvidenceStore; + } +} diff --git a/docs/assurance/kr0ki.assurance.toml b/docs/assurance/kr0ki.assurance.toml new file mode 100644 index 0000000..0d8f52a --- /dev/null +++ b/docs/assurance/kr0ki.assurance.toml @@ -0,0 +1,365 @@ +# kr0ki assurance baseline β€” the first working example of the assurance thread: +# +# source obligation -> requirement -> system element -> enforcing control +# \-> verification case -> evidence +# +# This file is the AUTHORING source. `kr0ki_core::assurance_baseline` lowers it to a +# `RequirementGraph` (ufo-types `mbse::assurance` profile); ReqIF, the requirements view, +# MCP and Playb00k all consume that graph. Evidence is NOT stored here: it is revision-bound +# and produced by running a case (see the evidence store). +# +# Conventions +# * A requirement states one observable obligation: responsible component + "shall" + +# behaviour + condition. Rationale and implementation choices live in their own fields. +# * `satisfies` (element -> requirement) is an ARCHITECTURAL ASSERTION about the design. +# A passing result is revision-specific EVIDENCE. Never infer one from the other. +# * A control's `implementation` is empty until the code (or deployment manifest) exists. +# An empty value is reported as an implementation gap, deliberately. +# * Enforcement of identity and sandboxing belongs to the deployment components +# (`component = "deployment"`); kr0ki displays their relationships and evidence only. +# +# The model the `element` locators resolve against is `kr0ki-assurance.sysml`. + +[baseline] +id = "KR0KI-ASSURANCE" +title = "kr0ki requirements agent (reqiffy) β€” assurance baseline" +model = "kr0ki-assurance.sysml" + +# ── Source obligations ────────────────────────────────────────────────────────── +# kind: binding_obligation | organisational_policy | guidance +# +# The two OMG specifications are marked binding because kr0ki claims interchange with them +# (ReqIF import/export; the Systems Modeling API client and sync path). The MCP text is +# guidance (RFC 2119 SHOULD). The repository's own rules are organisational policy. + +[[obligation]] +id = "OBL-OWNER-BRIEF" +title = "Owner brief: versioned assurance thread (2026-10-02)" +source = "docs/assurance/BRIEF-assurance-thread.md" +version = "2026-10-02" +kind = "organisational_policy" +summary = "The product owner's brief defining requirements KR-A01 to KR-A08, the application profile, and the progressive acceptance checks." + +[[obligation]] +id = "OBL-OMG-REQIF-1.2" +title = "OMG Requirements Interchange Format (ReqIF) 1.2" +source = "https://www.omg.org/spec/ReqIF/1.2" +version = "formal/16-07-01" +kind = "binding_obligation" +summary = "Requirements exchanged as ReqIF keep SpecObject identifiers, attribute values and SpecRelations (reqif.xsd, namespace 20110401). Known gap: kr0ki's export is round-trip-grade, not yet shown to be XSD-valid (see the reqif_export module doc)." + +[[obligation]] +id = "OBL-OMG-SYSML-API-1.0" +title = "OMG Systems Modeling API and Services 1.0" +source = "https://www.omg.org/spec/SystemsModelingAPI/1.0" +version = "formal/26-03-04" +kind = "binding_obligation" +summary = "Model access and commits go through the standard project, commit, branch and element resources, with commits as immutable revisions." + +[[obligation]] +id = "OBL-MCP-SECURITY" +title = "Model Context Protocol: security and trust (spec 2026-07-28)" +source = "https://modelcontextprotocol.io/specification/2026-07-28" +version = "2026-07-28" +kind = "guidance" +summary = "Tool descriptions and annotations are untrusted unless the server is trusted; implementors SHOULD build authorization flows and access controls, because the protocol cannot enforce them." + +[[obligation]] +id = "OBL-REPO-AGENTS-8A" +title = "AGENTS.md section 8a: agent registration, read-only fallback, infrastructure audit" +source = "AGENTS.md#8a-agent-communications-and-infrastructure-squawks" +version = "2026-10-02" +kind = "organisational_policy" +summary = "An agent that cannot register remains read-only; every infrastructure change is recorded with caller, action, scope, decision, repository and git SHA, a correlation identifier and a redacted result." + +[[obligation]] +id = "OBL-REPO-AGENTS-8B" +title = "AGENTS.md section 8b: delegated work is read-only by default" +source = "AGENTS.md#8b-delegation-always-use-qwen38-via-pi" +version = "2026-10-02" +kind = "organisational_policy" +summary = "Delegated agent work is read-only by default; tools that write run only in a throwaway worktree." + +[[obligation]] +id = "OBL-REPO-AGENTS-6" +title = "AGENTS.md section 6: test discipline" +source = "AGENTS.md#6-test-discipline" +version = "2026-10-02" +kind = "organisational_policy" +summary = "Unit and in-process HTTP tests must pass; live tests require their environment, and a live integration is recorded as verified only after its live check has run." + +# ── System elements (qualified names in kr0ki-assurance.sysml) ────────────────── + +[[element]] +id = "KrOKiAssurance::ModelService" +title = "ModelService" +description = "Serves revision-qualified model and requirement content, exposes the requirements view, and accepts commits." + +[[element]] +id = "KrOKiAssurance::VerificationRunner" +title = "VerificationRunner" +description = "Runs declared verification cases and returns evidence references." + +[[element]] +id = "KrOKiAssurance::ToolGateway" +title = "ToolGateway" +description = "The single path from agent to tools; enforces the capability grant on every transport." + +[[element]] +id = "KrOKiAssurance::AuditRecorder" +title = "ToolGateway::AuditRecorder" +description = "Appends one audit record per tool invocation, permitted or denied." + +[[element]] +id = "KrOKiAssurance::ExecutionSandbox" +title = "ExecutionSandbox" +description = "Confines the workload's filesystem writes to declared mounts." + +# ── Enforcing controls ────────────────────────────────────────────────────────── +# component: "kr0ki" (implemented in this repository) | "deployment" (implemented by the +# deployment component; kr0ki displays its relationships and evidence). + +[[control]] +id = "CTL-A01" +title = "Semantic round-trip comparison with explicit unsupported-content report" +component = "kr0ki" +implementation = "crates/kr0ki-core/src/reqif_export.rs; crates/kr0ki-core/src/reqif_roundtrip.rs" + +[[control]] +id = "CTL-A02" +title = "Link resolution against a revision-qualified element index" +component = "kr0ki" +implementation = "crates/kr0ki-core/src/assurance_trace.rs" + +[[control]] +id = "CTL-A03" +title = "Assurance state keeps satisfaction and verification as separate axes" +component = "kr0ki" +implementation = "crates/kr0ki-core/src/assurance_view.rs" + +[[control]] +id = "CTL-A04" +title = "Evidence record bound to the model, implementation and configuration revisions" +component = "kr0ki" +implementation = "crates/kr0ki-core/src/verification_runner.rs; crates/kr0ki-core/src/evidence_store.rs" + +[[control]] +id = "CTL-A05" +title = "Expected-base-revision precondition on commit" +component = "kr0ki" +implementation = "crates/kr0ki-core/src/change_service.rs" + +[[control]] +id = "CTL-A06" +title = "Capability-grant check in the gateway, deny by default, on every transport" +component = "kr0ki" +implementation = "" + +[[control]] +id = "CTL-A07" +title = "Read-only mount of the requirement baseline; writes only to declared mounts" +component = "deployment" +implementation = "" + +[[control]] +id = "CTL-A08" +title = "Audit record appended before the response, for permit and deny alike" +component = "kr0ki" +implementation = "" + +# ── Verification cases ────────────────────────────────────────────────────────── +# `command` is an argv, never a shell string. The runner only executes commands that match +# its allowlist (cargo test of a named package and test target). + +[[case]] +id = "VC-A01" +title = "Round-trip this baseline through ReqIF and compare semantic content" +command = ["cargo", "test", "-p", "kr0ki-core", "--test", "assurance_a01"] +acceptance = "Round-trip this baseline; compare semantic content. Report unsupported content explicitly." + +[[case]] +id = "VC-A02" +title = "Resolve KR-A01 links at a model revision; report a deliberately dangling reference" +command = ["cargo", "test", "-p", "kr0ki-core", "--test", "assurance_a02"] +acceptance = "Link KR-A01 to its adapter and test; report a deliberately dangling reference." + +[[case]] +id = "VC-A03" +title = "Requirements view shows one satisfied-but-untested and one verified requirement" +command = ["cargo", "test", "-p", "kr0ki-core", "--test", "assurance_a03"] +acceptance = "Show one satisfied but untested requirement and one verified requirement." + +[[case]] +id = "VC-A04" +title = "Run VC-A01, retrieve its evidence, change a revision, see the result stale" +command = ["cargo", "test", "-p", "kr0ki-core", "--test", "assurance_a04"] +acceptance = "Run KR-A01's case; retrieve its evidence. Change a revision and display the previous result as stale." + +[[case]] +id = "VC-A05" +title = "Two edits from one baseline: the second is rejected, the first is kept" +command = ["cargo", "test", "-p", "kr0ki-core", "--test", "assurance_a05"] +acceptance = "Submit two edits from one baseline; reject the second stale edit without losing the first." + +[[case]] +id = "VC-A06" +title = "Read-only identity: model reads permitted, commit denied over MCP and over direct HTTP" +command = ["cargo", "test", "-p", "kr0ki-server", "--test", "assurance_a06"] +acceptance = "Permit model reads; deny a commit from the same read-only identity through MCP and direct HTTP." + +[[case]] +id = "VC-A07" +title = "Sandbox permits temporary output and denies a write to the mounted requirement baseline" +command = ["cargo", "test", "-p", "kr0ki-server", "--test", "assurance_a07"] +acceptance = "Permit temporary output; deny a write to the mounted requirement baseline." + +[[case]] +id = "VC-A08" +title = "Audit records exist for both the permitted and the denied VC-A06 calls" +command = ["cargo", "test", "-p", "kr0ki-server", "--test", "assurance_a08"] +acceptance = "Retrieve records for both the permitted and denied KR-A06 calls." + +# ── Requirements ──────────────────────────────────────────────────────────────── +# derives: obligations the requirement derives from; `locator` points into the source. + +[[requirement]] +id = "KR-A01" +title = "ReqIF round trip preserves identifiers, typed attributes and asserted relations" +statement = "The requirement adapter shall preserve supported requirement identifiers, typed attributes and asserted relations across ReqIF export and reimport." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A01" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "Requirements are exchanged with other tools as ReqIF; silent loss of an attribute or relation breaks traceability downstream." +element = "KrOKiAssurance::ModelService" +control = "CTL-A01" +verification_id = "VC-A01" +acceptance = "Round-trip this baseline; compare semantic content. Report unsupported content explicitly." +status = "gated" +derives = [ + { obligation = "OBL-OWNER-BRIEF", locator = "#KR-A01" }, + { obligation = "OBL-OMG-REQIF-1.2", locator = "SpecObject, AttributeValue, SpecRelation" }, +] + +[[requirement]] +id = "KR-A02" +title = "Links resolve against a specified model revision" +statement = "The traceability adapter shall resolve requirement links against a specified model revision." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A02" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "A link that resolves at one revision and dangles at another must be visible, not assumed." +element = "KrOKiAssurance::ModelService" +control = "CTL-A02" +verification_id = "VC-A02" +acceptance = "Link KR-A01 to its adapter and test; report a deliberately dangling reference." +status = "gated" +derives = [ + { obligation = "OBL-OWNER-BRIEF", locator = "#KR-A02" }, + { obligation = "OBL-OMG-SYSML-API-1.0", locator = "Commit, Element" }, +] + +[[requirement]] +id = "KR-A03" +title = "The requirements view separates satisfaction from verification" +statement = "The requirements view shall distinguish satisfaction assertions from verification results." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A03" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "A design assertion says nothing about whether any case ran; showing them as one status hides untested requirements." +element = "KrOKiAssurance::ModelService" +control = "CTL-A03" +verification_id = "VC-A03" +acceptance = "Show one satisfied but untested requirement and one verified requirement." +status = "gated" +derives = [{ obligation = "OBL-OWNER-BRIEF", locator = "#KR-A03" }] + +[[requirement]] +id = "KR-A04" +title = "Verification results are bound to the revisions that produced them" +statement = "The verification runner shall bind each result to the model, implementation and configuration revisions used." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A04" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "A result is only evidence for the revisions it ran against; without them a stale pass is indistinguishable from a current one." +element = "KrOKiAssurance::VerificationRunner" +control = "CTL-A04" +verification_id = "VC-A04" +acceptance = "Run KR-A01's case; retrieve its evidence. Change a revision and display the previous result as stale." +status = "gated" +derives = [ + { obligation = "OBL-OWNER-BRIEF", locator = "#KR-A04" }, + { obligation = "OBL-REPO-AGENTS-6", locator = "live checks before recording a live integration as verified" }, +] + +[[requirement]] +id = "KR-A05" +title = "A stale commit is rejected without losing the first" +statement = "The model commit service shall reject a proposed mutation whose expected base revision differs from the current revision." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A05" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "Two writers starting from one baseline must not overwrite each other; the second must re-read and re-propose. Known limit: the OMG pilot server does not itself enforce previousCommit (observed live), so the guard holds for writers going through this service, not for a writer that bypasses it." +element = "KrOKiAssurance::ModelService" +control = "CTL-A05" +verification_id = "VC-A05" +acceptance = "Submit two edits from one baseline; reject the second stale edit without losing the first." +status = "gated" +derives = [ + { obligation = "OBL-OWNER-BRIEF", locator = "#KR-A05" }, + { obligation = "OBL-OMG-SYSML-API-1.0", locator = "Commit (previousCommit)" }, +] + +[[requirement]] +id = "KR-A06" +title = "Operations outside the caller's capability grant are denied on every transport" +statement = "The tool gateway shall deny operations outside the caller identity's capability grant." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A06" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "Tool visibility, descriptions and read-only annotations are not enforcement; the service must check the grant itself, whichever transport the call arrives on." +element = "KrOKiAssurance::ToolGateway" +control = "CTL-A06" +verification_id = "VC-A06" +acceptance = "Permit model reads; deny a commit from the same read-only identity through MCP and direct HTTP." +status = "gated" +derives = [ + { obligation = "OBL-OWNER-BRIEF", locator = "#KR-A06" }, + { obligation = "OBL-MCP-SECURITY", locator = "Security and Trust & Safety > Implementation Guidelines" }, + { obligation = "OBL-REPO-AGENTS-8A", locator = "an agent that cannot register MUST remain read-only" }, +] + +[[requirement]] +id = "KR-A07" +title = "Writes outside the declared mounts are denied" +statement = "The workload sandbox shall deny writes outside its declared writable mounts." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A07" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "An agent workload is untrusted; the requirement baseline it reads must stay immutable to it." +element = "KrOKiAssurance::ExecutionSandbox" +control = "CTL-A07" +verification_id = "VC-A07" +acceptance = "Permit temporary output; deny a write to the mounted requirement baseline." +status = "gated" +derives = [ + { obligation = "OBL-OWNER-BRIEF", locator = "#KR-A07" }, + { obligation = "OBL-REPO-AGENTS-8B", locator = "read-only by default" }, +] + +[[requirement]] +id = "KR-A08" +title = "Every tool invocation is audited, permitted or denied" +statement = "The audit service shall record each tool invocation with caller, operation, decision, model revision and correlation identifier." +source = "docs/assurance/BRIEF-assurance-thread.md#KR-A08" +source_kind = "organisational_policy" +owner = "kr0ki-maintainers" +rationale = "Reviewing what an agent did, and what it was refused, needs a record that does not depend on the agent's own account." +element = "KrOKiAssurance::AuditRecorder" +control = "CTL-A08" +verification_id = "VC-A08" +acceptance = "Retrieve records for both the permitted and denied KR-A06 calls." +status = "gated" +derives = [ + { obligation = "OBL-OWNER-BRIEF", locator = "#KR-A08" }, + { obligation = "OBL-REPO-AGENTS-8A", locator = "each event carries caller, action, scope, decision, git SHA, correlation id" }, +] diff --git a/justfile b/justfile index fb38125..1830aa4 100644 --- a/justfile +++ b/justfile @@ -518,6 +518,25 @@ test-live-sysml: KR0KI_SYSMLV2_BASE_URL="$base" KR0KI_SYSMLV2_TEST_PROJECT_ID="$id" CARGO_BUILD_JOBS=3 \ cargo test -p kr0ki-sysmlv2-client --test live -p kr0ki-core --test flexo_reqif_sync --test recompute_live -- --ignored --test-threads=1 +# ── assurance thread (docs/assurance/) ────────────────────────────────────────── +# Load the baseline, check the nine profile fields and statement lint, resolve links, +# round-trip it through ReqIF, and list gaps. Exits 1 on a defect; `--strict` also fails on +# dangling links. Same service the /assurance routes and MCP tools use. +assurance-lint *flags: + cargo run -q -p kr0ki-core --bin kr0ki-assurance -- lint {{flags}} + +# Run the declared verification cases whose test targets exist and store revision-bound +# evidence under .kr0ki-evidence/. Pass case ids to run only those. +assurance-verify *cases: + cargo run -q -p kr0ki-core --bin kr0ki-assurance -- verify {{cases}} + +# Regenerate the requirements view from the baseline and the stored evidence. +assurance-view format="table": + cargo run -q -p kr0ki-core --bin kr0ki-assurance -- view --format {{format}} + +assurance-gaps: + cargo run -q -p kr0ki-core --bin kr0ki-assurance -- gaps + # Delegate an errand to the local Qwen3.8 via pi (read-only by default). See docs/PATTERN-pi-subagents.md. delegate +prompt: scripts/delegate-qwen38.sh {{prompt}} diff --git a/skills/requirements-authoring/SKILL.md b/skills/requirements-authoring/SKILL.md new file mode 100644 index 0000000..474157c --- /dev/null +++ b/skills/requirements-authoring/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-authoring +description: Author, review and trace kr0ki requirements with the assurance thread. Use when adding or changing a requirement, when asked why a requirement is not verified, or when linking a requirement to its source, system element, enforcing control, verification case and evidence. Drives the kr0ki MCP tools (list_requirements, get_requirement, trace_requirement, propose_change, commit_change, run_verification) and keeps satisfaction assertions separate from revision-bound verification results. +license: MIT +compatibility: Needs the kr0ki MCP tools (or the same HTTP routes under /assurance) and an identity whose grants cover what you try to do. The gateway enforces grants; this text does not. +metadata: + author: PromptExecution + version: "0.1" +--- + +# Requirements authoring + +A requirement here is one node in a **thread**: + +``` +source obligation β†’ requirement β†’ system element β†’ enforcing control β†’ verification case β†’ evidence +``` + +You write the requirement. The tools tell you the rest. Do not fill in a link you have not +looked up, and never report a requirement as *verified* on the strength of a design assertion. + +## Two claims, never one + +- **Satisfied** is an *architectural assertion*: some system element is said to satisfy the + requirement. It says nothing about whether anything was run. +- **Verified** is *revision-specific evidence*: a verification case passed at one model, + implementation and configuration revision. Change any of the three and it is **stale**. + +Report both axes. "Satisfied, untested", "verified", "failing" and "stale" are four different +states; saying "done" collapses them. + +## Workflow + +1. **Retrieve before proposing.** `list_requirements` (filter by `status`, `owner`, `state`, + `gap`) to see what exists; `get_requirement` for the ones you touch. Read the source and the + existing neighbours first. Ask for one requirement, not the whole baseline. +2. **One observable obligation per requirement:** + *responsible component* + **shall** + *behaviour* + *condition*. + "The tool gateway shall deny operations outside the caller identity's capability grant." + One `shall`. A component before it. Something a test can observe. +3. **Keep rationale and implementation choices out of the statement.** Put *why* in + `rationale`. Put *how* in the control, not the requirement. +4. **Give it an identity and an owner.** Stable `id` (never reuse one), `source` (a locator + into the source obligation), `source_kind` (`binding_obligation`, `organisational_policy` or + `guidance`: keep them apart), `owner`, `verification_id`, and a measurable `acceptance`. +5. **Check, deterministically first.** `propose_change` validates the nine profile fields and + lints the statement (missing or repeated `shall`, no responsible component, vague terms such + as "TBD", "and/or", "as appropriate"). `trace_requirement` resolves the links at the current + model revision and names every dangling one. Fix what these report before anything else. +6. **Then use judgment** for what a scan cannot know: duplicates, contradictions with + neighbouring requirements, undefined terms, ambiguity, whether the behaviour is really + observable. Quote the words you object to and offer a rewrite. +7. **Propose a diff; validate before committing.** `propose_change` returns the diff and the + **base revision** it was computed against, and writes nothing. `validate_change` says whether + that base is still current. `commit_change(change_id, expected_revision)` applies it only if + the model head still *is* that revision; if it is not you get `stale_base`, nothing is + written, and you must re-read and propose again. Never retry a stale commit unchanged. +8. **After implementing, run the case.** `run_verification(case_id, revision)` stores evidence + bound to the three revisions. Then `get_requirement` again and report the new state. + +## Rules that keep you honest + +- **Permissions are enforced by the gateway and the sandbox, not by this skill.** If a call is + refused (`403`), that is the answer: do not look for another route to the same effect, and do + not describe a refused change as made. +- **A passing result is only evidence for the revisions it ran at.** If `revisions` in a reply + differ from the ones on the evidence, say the result is stale. +- **Zero tests run is not a pass.** `run_verification` reports it as an error. Believe it. +- **Do not invent data.** An unknown `source`, `owner` or acceptance is a question for the + requirement's owner, not a field to fill with a plausible value. +- **An empty `implementation` on a control is a real gap**, not a formatting problem. + +## References + +- `references/profile.md`: the nine requirement fields, the evidence fields, edge directions. +- `references/statement-patterns.md`: good and bad statements, and what the lint can and cannot see. +- `scripts/check.sh`: runs the same deterministic gate from a shell (`just assurance-lint`). diff --git a/skills/requirements-authoring/references/profile.md b/skills/requirements-authoring/references/profile.md new file mode 100644 index 0000000..c0c968f --- /dev/null +++ b/skills/requirements-authoring/references/profile.md @@ -0,0 +1,48 @@ +# The assurance profile + +## Requirement fields (all required) + +| field | meaning | +|---|---| +| `id` | stable identifier, e.g. `KR-A01`; never reused | +| `statement` | one observable obligation: component + shall + behaviour + condition | +| `source` | locator into the source obligation (a URI, `file#anchor`, or clause) | +| `source_kind` | `binding_obligation` \| `organisational_policy` \| `guidance` | +| `owner` | who answers questions about it | +| `rationale` | why it exists (not part of the statement) | +| `verification_id` | the verification case that accepts it, e.g. `VC-A01` | +| `acceptance` | the measurable acceptance criterion | +| `status` | `draft` β†’ `review` β†’ `validated` β†’ `gated` β†’ `implemented` | + +## Evidence fields + +`requirement_id`, `verification_id`, `model_revision`, `implementation_revision`, +`configuration_digest`, `result` (`pass` / `fail` / `error`), `artifact_uri`, `artifact_digest`. + +`configuration_digest` digests what the case checks: its command, its acceptance, the statement +and acceptance of every requirement it verifies, and the toolchain. Edit a verified +requirement's statement and its earlier evidence goes stale. + +## Edge directions (read `source β€”kindβ†’ target`) + +| edge | meaning | +|---|---| +| `source_obligation β€”derivesβ†’ requirement` | the requirement is derived from the obligation | +| `system_element β€”satisfiesβ†’ requirement` | **assertion** that the design satisfies it | +| `control β€”implementsβ†’ requirement` | the control enforces it | +| `control β€”allocated_toβ†’ system_element` | where the control lives | +| `verification_case β€”verifiesβ†’ requirement` | the requirement's acceptance case | + +Only *asserted* edges count. Inferred and proposed edges are review-only. + +## Assurance states + +`unsatisfied` (nothing asserts it) Β· `satisfied_untested` (asserted, never run) Β· `verified` +(a fresh pass, no fresh failure) Β· `failing` (a fresh failure or error) Β· `stale` (results exist +only for other revisions). + +## Gaps the tools report + +`no_source`, `unsatisfied`, `no_enforcing_control`, `control_not_implemented`, +`no_verification_case`, `unknown_verification_case`, `satisfied_untested`, `failing`, `stale`, +`dangling_element`. diff --git a/skills/requirements-authoring/references/statement-patterns.md b/skills/requirements-authoring/references/statement-patterns.md new file mode 100644 index 0000000..46e6be4 --- /dev/null +++ b/skills/requirements-authoring/references/statement-patterns.md @@ -0,0 +1,25 @@ +# Statement patterns + +Guidance from the NASA Systems Engineering Handbook, Appendix C: use **shall** for +requirements, **will** for facts and **should** for goals; give each requirement an intelligible +rationale; state *what* is needed, not *how*; make it verifiable (can it be tested, +demonstrated, inspected or analysed?). One `shall` per requirement is this repository's own +authoring rule, not NASA's wording. + +| | statement | why | +|---|---|---| +| good | The tool gateway shall deny operations outside the caller identity's capability grant. | one `shall`, a component, an observable refusal | +| good | The verification runner shall bind each result to the model, implementation and configuration revisions used. | testable: change a revision, see the result stale | +| bad | The gateway denies writes. | no `shall`: a fact, not a requirement | +| bad | The gateway shall deny writes and shall log them. | two obligations; split into two requirements | +| bad | Shall deny writes. | no responsible component | +| bad | The UI shall be user-friendly and/or fast, TBD. | vague, open, untestable | + +## What the lint sees (and what it cannot) + +Sees: missing `shall`; more than one `shall`; nothing before `shall`; the terms `TBD`, `TBC`, +`etc`, `and/or`, `as appropriate`, `user-friendly`, `adequate`, `if possible`, `where practical`. + +Cannot see: whether the behaviour is observable, whether two requirements contradict, whether a +term is defined, whether the statement leaks a design choice. That is your judgment; quote the +words and propose a rewrite. diff --git a/skills/requirements-authoring/scripts/check.sh b/skills/requirements-authoring/scripts/check.sh new file mode 100755 index 0000000..11569d5 --- /dev/null +++ b/skills/requirements-authoring/scripts/check.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# The deterministic half of the authoring workflow, from a shell: load the baseline, check the +# nine profile fields and statement lint, resolve links, round-trip ReqIF, list gaps. +# Needs a built kr0ki-core; run from the repository root. +set -euo pipefail +exec cargo run -q -p kr0ki-core --bin kr0ki-assurance -- lint "$@"