diff --git a/.claude/settings.json b/.claude/settings.json index 8e62d02..bbc6d55 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,19 +1,13 @@ { "hooks": { - "PreCommit": [ - { - "type": "command", - "command": "rivet validate" - } - ], "PostToolUse": [ { "matcher": "Edit|Write", - "if": "Edit(artifacts/**|safety/**)|Write(artifacts/**|safety/**)", "hooks": [ { "type": "command", - "command": "bash -c 'FILE=$(cat - | jq -r \".tool_input.file_path // empty\"); [ -n \"$FILE\" ] && rivet stamp all --created-by ai-assisted 2>/dev/null || true'" + "if": "Edit(artifacts/**|safety/**)|Write(artifacts/**|safety/**)", + "command": "rivet stamp all --created-by ai-assisted 2>/dev/null || true" } ] } diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 32b149f..ea3bdcd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -94,7 +94,30 @@ "Bash(MIRIFLAGS=\"-Zmiri-disable-isolation -Zmiri-tree-borrows\" cargo +nightly miri test -p rivet-core --lib -- yaml_cst::tests::parse_actual_hazards_file --nocapture)", "Bash(cargo generate-lockfile:*)", "Bash(git rebase:*)", - "Bash(cargo install:*)" + "Bash(cargo install:*)", + "Bash(rivet validate:*)", + "Bash(rivet provenance:*)", + "Bash(rivet stamp:*)", + "Bash(rivet docs:*)", + "Skill(update-config)", + "Bash(bash -c 'rivet stamp all --created-by ai-assisted 2>/dev/null || true')", + "WebFetch(domain:x-as-code.useblocks.com)", + "WebFetch(domain:sphinx-needs.readthedocs.io)", + "WebFetch(domain:sphinx-modeling.readthedocs.io)", + "WebFetch(domain:sphinxcontrib-needs.readthedocs.io)", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:pypi.org)", + "Bash(curl -sI https://raw.githubusercontent.com/useblocks/sphinx-needs/main/docs/conf.py)", + "Bash(curl -s \"https://api.github.com/repos/useblocks/sphinx-needs/git/trees/main?recursive=1\")", + "Bash(curl -s \"https://api.github.com/repos/useblocks/sphinx-needs/contents/\")", + "Bash(curl -s \"https://api.github.com/repos/useblocks/sphinx-needs/contents/docs\")", + "Bash(curl -s \"https://api.github.com/repos/useblocks/sphinx-needs/contents/docs/directives\")", + "Bash(curl -sI \"https://raw.githubusercontent.com/useblocks/sphinx-needs/main/docs/configuration.rst\")", + "Bash(curl -s \"https://api.github.com/repos/useblocks/sphinx-needs/branches\")", + "Bash(curl -s \"https://api.github.com/repos/useblocks/sphinx-needs/git/refs/heads/master\")", + "Bash(chmod +x:*)", + "Bash(yamllint -d relaxed artifacts/v040-decisions.yaml artifacts/v040-features.yaml safety/stpa/variant-hazards.yaml schemas/eu-ai-act.yaml)" ] } } diff --git a/.rivet/agent-context.md b/.rivet/agent-context.md index 3dea467..f254bbc 100644 --- a/.rivet/agent-context.md +++ b/.rivet/agent-context.md @@ -6,8 +6,8 @@ Auto-generated by `rivet context` — do not edit. - **Name:** rivet - **Version:** 0.1.0 -- **Schemas:** common, dev, aadl -- **Sources:** artifacts (generic-yaml) +- **Schemas:** common, dev, aadl, stpa, stpa-sec +- **Sources:** artifacts (generic-yaml), safety/stpa (stpa-yaml), safety/stpa-sec (generic-yaml) - **Docs:** docs, arch - **Results:** results @@ -15,11 +15,25 @@ Auto-generated by `rivet context` — do not edit. | Type | Count | Example IDs | |------|-------|-------------| -| aadl-component | 21 | ARCH-SYS-001, ARCH-SYS-002, ARCH-CORE-001 | -| design-decision | 10 | DD-001, DD-002, DD-003 | -| feature | 30 | FEAT-001, FEAT-002, FEAT-003 | -| requirement | 16 | REQ-001, REQ-002, REQ-003 | -| **Total** | **77** | | +| aadl-component | 29 | ARCH-SYS-001, ARCH-SYS-002, ARCH-CORE-001 | +| controlled-process | 5 | PROC-ARTIFACTS, PROC-LINKGRAPH, PROC-EXTERNAL | +| controller | 8 | CTRL-DEV, CTRL-CLI, CTRL-CORE | +| controller-constraint | 59 | CC-C-1, CC-C-2, CC-C-3 | +| design-decision | 47 | DD-001, DD-002, DD-003 | +| feature | 140 | FEAT-001, FEAT-002, FEAT-003 | +| hazard | 32 | H-1, H-2, H-3 | +| loss | 13 | L-LSP-001, L-LSP-002, L-LSP-003 | +| loss-scenario | 35 | LS-C-1, LS-C-2, LS-C-3 | +| requirement | 40 | REQ-001, REQ-002, REQ-003 | +| sec-constraint | 13 | SSC-IMPL-001, SSC-IMPL-002, SSC-IMPL-003 | +| sec-hazard | 11 | SH-1, SH-2, SH-3 | +| sec-loss | 8 | SL-IMPL-001, SL-IMPL-002, SL-IMPL-003 | +| sec-scenario | 8 | SLS-IMPL-001, SLS-1, SLS-2 | +| sec-uca | 9 | SUCA-CLI-1, SUCA-CLI-2, SUCA-DASH-1 | +| sub-hazard | 12 | H-1.1, H-1.2, H-1.3 | +| system-constraint | 38 | SC-1, SC-2, SC-3 | +| uca | 59 | UCA-C-1, UCA-C-2, UCA-C-3 | +| **Total** | **566** | | ## Schema @@ -29,22 +43,89 @@ Auto-generated by `rivet context` — do not edit. Required fields: category, aadl-package - **`aadl-flow`** — End-to-end flow with latency bounds Required fields: flow-kind +- **`aadl-tool`** — An AADL ecosystem tool — captures what it does, what makes it unique, and what capabilities spar could adopt from it. + + Required fields: tool-status, category, capabilities +- **`control-action`** — An action issued by a controller to a controlled process or another controller. + + Required fields: action +- **`controlled-process`** — A process being controlled — the physical or data transformation acted upon by controllers. + + Required fields: (none) +- **`controller`** — A system component (human or automated) responsible for issuing control actions. Each controller has a process model — its internal beliefs about the state of the controlled process. + + Required fields: (none) +- **`controller-constraint`** — A constraint on a controller's behavior derived by inverting a UCA. Specifies what the controller must or must not do. + + Required fields: constraint - **`design-decision`** — An architectural or design decision with rationale Required fields: rationale - **`feature`** — A user-visible capability or feature + Required fields: (none) +- **`hazard`** — A system state or set of conditions that, together with worst-case environmental conditions, will lead to a loss. + + Required fields: (none) +- **`loss`** — An undesired or unplanned event involving something of value to stakeholders. Losses define what the analysis aims to prevent. + + Required fields: (none) +- **`loss-scenario`** — A causal pathway describing how a UCA could occur or how the control action could be improperly executed, leading to a hazard. + Required fields: (none) - **`requirement`** — A functional or non-functional requirement Required fields: (none) +- **`sec-constraint`** — A security constraint — a condition or behavior required to prevent a security hazard. Each constraint is the security inversion of a hazard. + + Required fields: (none) +- **`sec-hazard`** — A security hazard — a system state or condition that, if exploited by an adversary in a worst-case environment, leads to a security loss. + + Required fields: cia-impact +- **`sec-loss`** — A security loss — an undesired event involving breach of a CIA property (confidentiality, integrity, or availability). Parallels the STPA 'loss' type but is scoped to adversarial threats. + + Required fields: cia-impact +- **`sec-scenario`** — A security loss scenario — a causal pathway (with adversarial causation) describing how a sec-uca could occur or how the control action could be exploited by an adversary to reach a security hazard. + + Required fields: (none) +- **`sec-uca`** — A security Unsafe Control Action — a control action that, in a particular adversarial context, leads to a security hazard. Includes an adversarial-causation field explaining how an attacker could introduce or exploit this UCA. + + Required fields: uca-type, adversarial-causation +- **`sub-hazard`** — A refinement of a hazard into a more specific unsafe condition. + + Required fields: (none) +- **`system-constraint`** — A condition or behavior that must be satisfied to prevent a hazard. Each constraint is the inversion of a hazard. + + Required fields: (none) +- **`uca`** — An Unsafe Control Action — a control action that, in a particular context and worst-case environment, leads to a hazard. Four types (provably complete): + 1. Not providing the control action leads to a hazard + 2. Providing the control action leads to a hazard + 3. Providing too early, too late, or in the wrong order + 4. Control action stopped too soon or applied too long + + Required fields: uca-type ### Link Types +- `acts-on` (inverse: `acted-on-by`) +- `allocated-from` (inverse: `allocated-to`) - `allocated-to` (inverse: `allocated-from`) +- `caused-by-sec-uca` (inverse: `causes-sec-scenario`) +- `caused-by-uca` (inverse: `causes-scenario`) - `constrained-by` (inverse: `constrains`) +- `constrains-controller` (inverse: `controller-constrained-by`) +- `constraint-satisfies` (inverse: `satisfied-by-constraint`) +- `contains` (inverse: `contained-by`) - `depends-on` (inverse: `depended-on-by`) - `derives-from` (inverse: `derived-into`) - `implements` (inverse: `implemented-by`) +- `inverts-uca` (inverse: `inverted-by`) +- `issued-by` (inverse: `issues`) +- `leads-to-hazard` (inverse: `hazard-caused-by`) +- `leads-to-loss` (inverse: `loss-caused-by`) +- `leads-to-sec-hazard` (inverse: `sec-hazard-caused-by`) +- `leads-to-sec-loss` (inverse: `sec-loss-caused-by`) - `mitigates` (inverse: `mitigated-by`) - `modeled-by` (inverse: `models`) +- `prevents` (inverse: `prevented-by`) +- `prevents-sec-hazard` (inverse: `sec-hazard-prevented-by`) - `refines` (inverse: `refined-by`) - `satisfies` (inverse: `satisfied-by`) - `traces-to` (inverse: `traced-from`) @@ -57,24 +138,50 @@ Auto-generated by `rivet context` — do not edit. | requirement-coverage | requirement | warning | Every requirement should be satisfied by at least one design decision or feature | | decision-justification | design-decision | error | Every design decision must link to at least one requirement | | aadl-component-has-allocation | aadl-component | info | AADL component should trace to a requirement or architecture element | +| hazard-has-loss | hazard | error | Every hazard must link to at least one loss | +| constraint-has-hazard | system-constraint | error | Every system constraint must link to at least one hazard | +| uca-has-hazard | uca | error | Every UCA must link to at least one hazard | +| uca-has-controller | uca | error | Every UCA must link to a controller | +| controller-constraint-has-uca | controller-constraint | error | Every controller constraint must link to at least one UCA | +| hazard-has-constraint | hazard | warning | Every hazard should be addressed by at least one system constraint | +| uca-has-controller-constraint | uca | warning | Every UCA should be addressed by at least one controller constraint | +| sec-hazard-has-loss | sec-hazard | error | Every security hazard must link to at least one security loss | +| sec-constraint-has-hazard | sec-constraint | error | Every security constraint must link to at least one security hazard | +| sec-uca-has-hazard | sec-uca | error | Every security UCA must link to at least one security hazard | +| sec-hazard-has-constraint | sec-hazard | warning | Every security hazard should be addressed by at least one security constraint | +| constraint-has-requirement | system-constraint | warning | Every system constraint should be satisfied by at least one requirement | +| controller-constraint-has-requirement | controller-constraint | info | Every controller constraint should be satisfied by at least one requirement | ## Coverage -**Overall: 100.0%** +**Overall: 83.7%** | Rule | Source Type | Covered | Total | % | |------|------------|---------|-------|---| -| requirement-coverage | requirement | 16 | 16 | 100.0% | -| decision-justification | design-decision | 10 | 10 | 100.0% | -| aadl-component-has-allocation | aadl-component | 21 | 21 | 100.0% | +| requirement-coverage | requirement | 40 | 40 | 100.0% | +| decision-justification | design-decision | 47 | 47 | 100.0% | +| aadl-component-has-allocation | aadl-component | 29 | 29 | 100.0% | +| hazard-has-loss | hazard | 32 | 32 | 100.0% | +| constraint-has-hazard | system-constraint | 38 | 38 | 100.0% | +| uca-has-hazard | uca | 59 | 59 | 100.0% | +| uca-has-controller | uca | 59 | 59 | 100.0% | +| controller-constraint-has-uca | controller-constraint | 59 | 59 | 100.0% | +| hazard-has-constraint | hazard | 32 | 32 | 100.0% | +| uca-has-controller-constraint | uca | 59 | 59 | 100.0% | +| sec-hazard-has-loss | sec-hazard | 11 | 11 | 100.0% | +| sec-constraint-has-hazard | sec-constraint | 13 | 13 | 100.0% | +| sec-uca-has-hazard | sec-uca | 9 | 9 | 100.0% | +| sec-hazard-has-constraint | sec-hazard | 11 | 11 | 100.0% | +| constraint-has-requirement | system-constraint | 0 | 38 | 0.0% | +| controller-constraint-has-requirement | controller-constraint | 0 | 59 | 0.0% | ## Validation -0 errors, 0 warnings +0 errors, 38 warnings ## Documents -4 documents loaded +6 documents loaded ## Commands diff --git a/.rivet/provenance-pending.json b/.rivet/provenance-pending.json new file mode 100644 index 0000000..629b252 --- /dev/null +++ b/.rivet/provenance-pending.json @@ -0,0 +1,3 @@ +{ + "marks": [] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 3a58c63..98be7e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,15 +108,76 @@ Use `rivet validate --format json` for machine-readable output. ## Commit Traceability -This project enforces commit-to-artifact traceability. +This project enforces commit-to-artifact traceability. Every non-exempt +commit that touches code in `rivet-core/src/` or `rivet-cli/src/` MUST +include at least one artifact trailer in the commit message body. -Required git trailers: -- `Fixes` -> maps to link type `fixes` -- `Implements` -> maps to link type `implements` -- `Refs` -> maps to link type `traces-to` -- `Satisfies` -> maps to link type `satisfies` -- `Verifies` -> maps to link type `verifies` +### Required Git Trailers -Exempt artifact types (no trailer required): `chore`, `style`, `ci`, `docs`, `build` +Add one or more of these trailers to the **last paragraph** of the commit +message body (after a blank line, before any Co-Authored-By): -To skip traceability for a commit, add: `Trace: skip` +| Trailer | Link Type | When to Use | +|---------|-----------|-------------| +| `Implements: REQ-NNN` | implements | New feature that fulfils a requirement or feature artifact | +| `Fixes: REQ-NNN` | fixes | Bug fix or correction related to an artifact | +| `Verifies: REQ-NNN` | verifies | Test or verification that validates an artifact | +| `Satisfies: SC-NNN` | satisfies | Implementation that satisfies a system constraint | +| `Refs: FEAT-NNN` | traces-to | General reference when the commit relates to but does not fully implement an artifact | + +Multiple trailers are encouraged. Comma-separate IDs for the same trailer type: +``` +Implements: REQ-028, REQ-029 +Refs: #91 +``` + +### Exempt Commits + +These conventional-commit types do NOT require trailers: `chore`, `style`, +`ci`, `docs`, `build`. + +To explicitly skip traceability for any other commit, add: `Trace: skip` + +### Choosing the Right Artifacts + +- **New parser/extraction feature** -> `Implements: REQ-028` (lossless CST) or `REQ-029` (incremental validation) +- **Schema addition** -> `Implements: REQ-010` (schema-driven validation) +- **CLI command** -> `Implements: REQ-007` (CLI and serve pattern) +- **STPA-related** -> `Implements: REQ-002` (STPA artifact support) +- **Validation fix** -> `Fixes: REQ-004` (validation engine) +- **Dashboard/serve** -> `Refs: FEAT-001` or relevant FEAT-NNN +- **MCP server** -> `Refs: FEAT-010, REQ-022` +- **Test addition** -> `Verifies: REQ-NNN` for the requirement being tested + +### Retroactive Traceability Map + +The following major commits predate strict trailer enforcement. This table +documents their artifact relationships for audit purposes (these cannot be +added as trailers without rewriting git history): + +| Commit | PR | Summary | Artifacts | +|--------|----|---------|-----------| +| `8321f8bb` | #114 | Rowan-based lossless YAML CST parser (Phase 1) | Implements REQ-028 | +| `fd99574e` | #119 | Rowan HIR extraction (Phase 2) + MCP tools | Implements REQ-028, REQ-029; Refs FEAT-010 | +| `e4f398ec` | #120 | Schema-driven extraction from rowan CST (Phase 3) | Implements REQ-028, REQ-010 | +| `bd0d729a` | #121 | Salsa integration (Phase 5) + dogfood tracking | Implements REQ-029 | +| `4e2aa4ab` | #122 | Doc spans + round-trip equivalence tests | Verifies REQ-028, REQ-034 | +| `29b735bf` | #123 | Phase 6 rowan migration + issue fixes | Implements REQ-028, REQ-029; Fixes REQ-002, REQ-004 | +| `6f781be1` | #124 | Edge case hardening + STPA + MCP tests | Fixes REQ-004; Verifies FEAT-010 | +| `2b7acd41` | #118 | `rivet schema validate` command | Implements REQ-010; Refs #93 | +| `28402ebe` | #117 | ISO/PAS 8800 AI safety + SOTIF schemas | Implements REQ-010 | +| `ffeff760` | #115 | Domain schemas (IEC 61508, IEC 62304, DO-178C, EN 50128) | Implements REQ-010 | +| `2c9fb62b` | #112 | yaml-section and shorthand-links in schema | Implements REQ-010 | +| `aba9c5f3` | #111 | STPA adapter handles arbitrary filenames | Fixes REQ-002 | +| `43830b9b` | #109 | GSN safety cases + STPA-for-AI schemas | Implements REQ-010 | +| `9a5011e2` | #108 | Convergence tracking, rivet get, MCP server | Implements FEAT-010, REQ-007 | +| `2f54fabc` | #101 | Schema/LSP fixes, EU AI Act, salsa, STPA | Implements REQ-010, REQ-029 | +| `0661926d` | #97 | Release delta in export + CI snapshot | Implements REQ-035 | +| `c5ff64c8` | #96 | Embed phases 2+5 (diagnostics, matrix, snapshots) | Implements REQ-033 | +| `cc4cc1c1` | #94 | oEmbed provider and Grafana JSON API | Implements FEAT-001 | +| `adcf0bc1` | #28 | Phase 3 (30+ features, 402 tests, formal verification) | Implements REQ-004, REQ-012 | + +### Current Coverage + +Run `rivet commits` to see current commit-to-artifact traceability status. +Target: 100% of non-exempt commits in traced paths should have trailers. diff --git a/CLAUDE.md b/CLAUDE.md index ca20548..86629e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,11 +2,43 @@ See [AGENTS.md](AGENTS.md) for project instructions. -Additional Claude Code settings: +## Validation and Queries - Use `rivet validate` to verify changes to artifact YAML files - Use `rivet list --format json` for machine-readable artifact queries -- Commit messages require artifact trailers (Implements/Fixes/Verifies/Satisfies/Refs) - A Claude Code pre-commit hook runs `rivet validate` before each commit (configured in `.claude/settings.json`) + +## Commit Traceability (MANDATORY) +Every commit that touches files in `rivet-core/src/` or `rivet-cli/src/` +MUST include artifact trailers. This is the single most important convention +in this project. Without trailers, commits become "orphans" that break +traceability coverage. + +Add trailers in the commit message body (after a blank line): +``` +Implements: REQ-028, REQ-029 +Fixes: REQ-004 +Verifies: REQ-010 +Refs: FEAT-001 +``` + +Quick reference for common work: +- Parser/CST changes -> `Implements: REQ-028` +- Incremental/salsa changes -> `Implements: REQ-029` +- Validation changes -> `Implements: REQ-004` or `Fixes: REQ-004` +- Schema changes -> `Implements: REQ-010` +- CLI commands -> `Implements: REQ-007` +- Dashboard/serve -> `Refs: FEAT-001` +- MCP server -> `Refs: FEAT-010` +- STPA artifacts -> `Implements: REQ-002` +- Test additions -> `Verifies: REQ-NNN` (the requirement being tested) + +Exempt types (no trailer needed): chore, style, ci, docs, build. +To skip explicitly: add `Trace: skip` trailer. + +See AGENTS.md "Commit Traceability" section for the full trailer reference +and retroactive traceability map. + +## AI Provenance - AI provenance is auto-stamped via PostToolUse hook when artifact files are edited - When manually stamping, include model: `rivet stamp --created-by ai-assisted --model claude-opus-4-6` diff --git a/Cargo.lock b/Cargo.lock index 1459028..5c3f59a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,6 +384,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -530,6 +541,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-assembler-x64" version = "0.129.1" @@ -1212,6 +1232,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -2431,6 +2452,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2469,6 +2501,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -2718,20 +2756,28 @@ checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" dependencies = [ "async-trait", "base64", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "pastey", "pin-project-lite", "process-wrap", + "rand 0.10.0", "rmcp-macros", "schemars", "serde", "serde_json", + "sse-stream", "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -3165,7 +3211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3306,6 +3352,19 @@ dependencies = [ "spar-parser", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3781,6 +3840,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] diff --git a/artifacts/architecture.yaml b/artifacts/architecture.yaml index 0892536..7f8bfee 100644 --- a/artifacts/architecture.yaml +++ b/artifacts/architecture.yaml @@ -22,6 +22,9 @@ artifacts: aadl-file: arch/rivet_system.aadl:49 source-ref: arch/rivet_system.aadl:49-54 diagram: "root: RivetSystem::Rivet.Impl" + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-SYS-002 type: aadl-component @@ -36,6 +39,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: implementation aadl-file: arch/rivet_system.aadl:56-67 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Core process ───────────────────────────────────────────── @@ -79,6 +85,9 @@ artifacts: classifier-kind: implementation aadl-file: arch/rivet_system.aadl:75-108 source-ref: rivet-core/src/lib.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── CLI process ────────────────────────────────────────────── @@ -99,6 +108,9 @@ artifacts: classifier-kind: implementation aadl-file: arch/rivet_system.aadl:112-125 source-ref: rivet-cli/src/main.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Core threads (one per module) ──────────────────────────── @@ -122,6 +134,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/schema.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-STORE type: aadl-component @@ -139,6 +154,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/store.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-ADAPTERS type: aadl-component @@ -168,6 +186,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/adapter.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-GRAPH type: aadl-component @@ -185,6 +206,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/links.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-VALIDATE type: aadl-component @@ -203,6 +227,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/validate.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-MATRIX type: aadl-component @@ -220,6 +247,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/matrix.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-DIFF type: aadl-component @@ -237,6 +267,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/diff.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-DOC type: aadl-component @@ -254,6 +287,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/document.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-QUERY type: aadl-component @@ -271,6 +307,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/query.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-RESULTS type: aadl-component @@ -288,6 +327,9 @@ artifacts: aadl-package: RivetSystem classifier-kind: type source-ref: rivet-core/src/results.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Adapter components ─────────────────────────────────────── @@ -307,6 +349,9 @@ artifacts: aadl-package: RivetAdapters classifier-kind: type source-ref: rivet-core/src/formats/generic.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-ADAPT-STPA type: aadl-component @@ -324,6 +369,9 @@ artifacts: aadl-package: RivetAdapters classifier-kind: type source-ref: rivet-core/src/formats/stpa.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-ADAPT-AADL type: aadl-component @@ -341,6 +389,9 @@ artifacts: aadl-package: RivetAdapters classifier-kind: type source-ref: rivet-core/src/formats/aadl.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-ADAPT-REQIF type: aadl-component @@ -358,6 +409,9 @@ artifacts: aadl-package: RivetAdapters classifier-kind: type source-ref: rivet-core/src/reqif.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-ADAPT-WASM type: aadl-component @@ -377,6 +431,9 @@ artifacts: aadl-package: RivetAdapters classifier-kind: implementation source-ref: rivet-core/src/wasm_runtime.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Dashboard components ───────────────────────────────────── @@ -400,6 +457,9 @@ artifacts: classifier-kind: implementation aadl-file: arch/rivet_dashboard.aadl:18-35 source-ref: rivet-cli/src/serve.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-DASH-GRAPH type: aadl-component @@ -416,6 +476,9 @@ artifacts: category: process aadl-package: RivetDashboard classifier-kind: type + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Phase 3 core components ──────────────────────────────────── @@ -437,6 +500,9 @@ artifacts: fields: category: process aadl-package: RivetCore + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-EXTERNALS type: aadl-component @@ -454,6 +520,9 @@ artifacts: fields: category: process aadl-package: RivetCore + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-IMPACT type: aadl-component @@ -469,6 +538,9 @@ artifacts: fields: category: process aadl-package: RivetCore + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-MUTATE type: aadl-component @@ -486,6 +558,9 @@ artifacts: fields: category: process aadl-package: RivetCore + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-MARKDOWN type: aadl-component @@ -503,6 +578,9 @@ artifacts: fields: category: process aadl-package: RivetCore + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-EXPORT type: aadl-component @@ -520,6 +598,9 @@ artifacts: fields: category: process aadl-package: RivetCore + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-ADAPT-NEEDSJSON type: aadl-component @@ -535,6 +616,9 @@ artifacts: fields: category: process aadl-package: RivetAdapters + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: ARCH-CORE-SCANNER type: aadl-component @@ -550,3 +634,6 @@ artifacts: fields: category: process aadl-package: RivetCore + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/artifacts/decisions.yaml b/artifacts/decisions.yaml index 2e67606..1effb05 100644 --- a/artifacts/decisions.yaml +++ b/artifacts/decisions.yaml @@ -29,6 +29,9 @@ artifacts: style B fill:#f0f0f0,stroke:#666 style C fill:#f0f0f0,stroke:#666 style D fill:#f0f0f0,stroke:#666 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-002 type: design-decision @@ -51,6 +54,9 @@ artifacts: Custom adjacency list implementation. Rejected because graph algorithms are subtle and petgraph is well-proven. source-ref: rivet-core/src/links.rs:1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-003 type: design-decision @@ -76,6 +82,9 @@ artifacts: alternatives: > Single monolithic schema. Rejected because different projects need different subsets (STPA-only, ASPICE-only, or both). + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-004 type: design-decision @@ -97,6 +106,9 @@ artifacts: alternatives: > Plugin via dynamic libraries (.so/.dylib). Rejected due to ABI fragility and platform-specific builds. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-005 type: design-decision @@ -118,6 +130,9 @@ artifacts: alternatives: > Tauri desktop app. Rejected per user preference — CLI + serve is simpler and works over SSH. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-006 type: design-decision @@ -140,6 +155,9 @@ artifacts: Deep module hierarchy (rivet-core/src/domain/stpa/model/...). Rejected because it adds navigation overhead without benefit at this project scale. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-007 type: design-decision @@ -162,6 +180,9 @@ artifacts: alternatives: > Separate S3/GCS bucket for test evidence. Rejected because it adds infrastructure and GitHub releases are already available. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-008 type: design-decision @@ -187,6 +208,9 @@ artifacts: alternatives: > Minimal CI with just tests. Rejected because safety-critical tooling requires the same rigor it enforces on its users. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-009 type: design-decision @@ -209,6 +233,9 @@ artifacts: alternatives: > Manual timing with std::time::Instant. Rejected because it lacks statistical rigor and regression detection. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-010 type: design-decision @@ -237,6 +264,9 @@ artifacts: Keep old test terminology for backward compatibility. Rejected because the schema is pre-1.0 and alignment with the standard is more valuable than backward compatibility at this stage. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-011 type: design-decision @@ -262,6 +292,9 @@ artifacts: Inline regex parsing of [ARTIFACT-ID] patterns (Jira-style). Rejected because regex is fragile and cannot distinguish intentional references from incidental mentions. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-012 type: design-decision @@ -286,6 +319,9 @@ artifacts: rivet sync-commits writing commit YAML files to a commits/ directory. Rejected because it creates thousands of redundant files and requires ongoing sync discipline. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-013 type: design-decision @@ -314,6 +350,9 @@ artifacts: No exemption mechanism (all commits must reference artifacts). Rejected because it creates excessive friction for routine maintenance commits that have no traceability value. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-014 type: design-decision @@ -331,6 +370,9 @@ artifacts: rationale: > Simpler and more readable than URIs. Prefix is a local alias configured per project, matching sphinx-needs id_prefix pattern. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-015 type: design-decision @@ -347,6 +389,9 @@ artifacts: rationale: > Avoids central authority requirement. Matches distributed team workflows. Transitive resolution handles indirect dependencies. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-016 type: design-decision @@ -363,6 +408,9 @@ artifacts: rationale: > No platform repo required. Each repo joins baselines independently. Matches OSLC global configuration model where contributions are optional. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-017 type: design-decision @@ -379,6 +427,9 @@ artifacts: rationale: > Scales naturally. Avoids redundant declarations. Similar to cargo/npm dependency resolution. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-018 type: design-decision @@ -403,6 +454,9 @@ artifacts: OPA/Rego policies or embedded Lua scripting for validation rules. Rejected because they add runtime dependencies and split validation logic away from the schema that defines the types. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-019 type: design-decision @@ -428,6 +482,9 @@ artifacts: Git-diff-based detection (parse YAML file diffs). Rejected because YAML formatting changes produce false positives and it cannot detect semantic changes (e.g., reordered fields that are logically identical). + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-020 type: design-decision @@ -452,6 +509,9 @@ artifacts: Auto-generate rivet schema types from needs.json structure. Rejected because it would create throwaway types that don't align with any standard schema (aspice, stpa, cybersecurity). + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-021 type: design-decision @@ -477,6 +537,9 @@ artifacts: Generate test artifact YAML via a rivet sync-tests command. Rejected because it creates thousands of files that must be re-synced whenever tests change, duplicating DD-012's lesson. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-022 type: design-decision @@ -509,6 +572,9 @@ artifacts: Rivet-only externals config with manual repo/ref declarations. Kept as fallback for projects without Bazel or Nix, but not the primary path for build-system-managed projects. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-023 type: design-decision @@ -539,6 +605,9 @@ artifacts: massive overkill). tree-sitter-starlark (adds C dependency, grammar may not be maintained). Both rejected in favor of the lightweight hand-written approach proven in spar. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-024 type: design-decision @@ -572,6 +641,9 @@ artifacts: it reimplements what salsa does correctly and is error-prone for transitive dependencies. The phased approach keeps the existing pipeline working during migration. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-028 type: design-decision @@ -601,6 +673,9 @@ artifacts: does not preserve comments, key ordering, or blank lines in YAML. A rowan-based YAML CST editor could preserve formatting but is a larger investment (future work for the salsa migration). + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-025 type: design-decision @@ -628,6 +703,9 @@ artifacts: testing cannot prove absence of panics — only Kani's exhaustive bounded checking can. Both are kept: proptest for quick CI, Kani for proof. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-026 type: design-decision @@ -654,6 +732,9 @@ artifacts: integration. Prusti (Viper-based). Less mature for complex data structures. Both viable but Verus has the strongest Rust integration story. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-027 type: design-decision @@ -684,6 +765,9 @@ artifacts: metaprogramming but less Rust tooling maturity. F* via hacspec. Good for cryptographic properties but less natural for domain modeling. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-029 type: design-decision @@ -696,6 +780,9 @@ artifacts: links: - type: satisfies target: REQ-032 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-030 type: design-decision title: Schema-driven embed modifiers with link traversal depth @@ -706,6 +793,9 @@ artifacts: links: - type: satisfies target: REQ-033 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-031 type: design-decision @@ -726,6 +816,9 @@ artifacts: target: REQ-002 - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-032 type: design-decision title: Canonical generic YAML format with explicit type and links array @@ -743,6 +836,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-033 type: design-decision title: spar CLI JSON bridge for AADL import @@ -762,6 +858,9 @@ artifacts: target: REQ-001 - type: satisfies target: REQ-005 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-034 type: design-decision title: ReqIF XML round-trip with XHTML content preservation @@ -778,6 +877,9 @@ artifacts: links: - type: satisfies target: REQ-005 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-035 type: design-decision @@ -794,6 +896,9 @@ artifacts: links: - type: satisfies target: REQ-034 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-036 type: design-decision @@ -816,6 +921,9 @@ artifacts: authorities who expect evidence at each tier. Tagging artifacts with tier makes the mapping explicit in the traceability matrix. alternatives: Flat test structure with no tier distinction. Rejected because it makes ASPICE compliance reporting harder and obscures which tests constitute qualification evidence. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-037 type: design-decision @@ -837,6 +945,9 @@ artifacts: Static output means the export is self-contained and requires no running server. alternatives: Separate static site generator. Rejected as it would duplicate the rendering logic. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-038 type: design-decision @@ -858,6 +969,9 @@ artifacts: metadata from the export content so the same export can be deployed in multiple contexts. alternatives: Embed version list at export generation time. Rejected because it requires re-export when new versions are published. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-039 type: design-decision @@ -879,6 +993,9 @@ artifacts: missing optional WASM assets. alternatives: Serve from filesystem at runtime. Rejected because it requires deploying assets separately from the binary. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-040 type: design-decision @@ -912,6 +1029,9 @@ artifacts: enforcement (rejected: baselines are release markers, not workflow state). (3) Per-rule severity override (rejected: too granular, rule-level config does not match the artifact-level intent). + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-041 type: design-decision @@ -940,3 +1060,6 @@ artifacts: (1) VS Code embeds serve via iframe (rejected: fails over SSH Remote). (2) Separate TypeScript renderer (rejected: duplicates rendering). (3) Template engine (rejected: string building is sufficient). + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/artifacts/features.yaml b/artifacts/features.yaml index 527b8ad..e2d3eb0 100644 --- a/artifacts/features.yaml +++ b/artifacts/features.yaml @@ -17,6 +17,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-002 type: feature @@ -34,6 +37,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-003 type: feature @@ -49,6 +55,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-004 type: feature @@ -66,6 +75,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-005 type: feature @@ -82,6 +94,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-006 type: feature @@ -97,6 +112,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-007 type: feature @@ -112,6 +130,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-008 type: feature @@ -129,6 +150,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-009 type: feature @@ -146,6 +170,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-010 type: feature @@ -162,6 +189,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-011 type: feature @@ -179,6 +209,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-012 type: feature @@ -196,6 +229,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-013 type: feature @@ -211,6 +247,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-014 type: feature @@ -229,6 +268,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-015 type: feature @@ -247,6 +289,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-016 type: feature @@ -269,6 +314,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-017 type: feature @@ -289,6 +337,9 @@ artifacts: fields: phase: phase-1 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-018 type: feature @@ -309,6 +360,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-019 type: feature @@ -326,6 +380,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-020 type: feature @@ -344,6 +401,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-021 type: feature @@ -361,6 +421,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-022 type: feature @@ -378,6 +441,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-023 type: feature @@ -396,6 +462,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-024 type: feature @@ -413,6 +482,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-025 type: feature @@ -430,6 +502,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-026 type: feature @@ -448,6 +523,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-027 type: feature @@ -464,6 +542,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-028 type: feature @@ -481,6 +562,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-029 type: feature @@ -501,6 +585,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-030 type: feature @@ -521,6 +608,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-031 type: feature @@ -538,6 +628,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-032 type: feature @@ -558,6 +651,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-033 type: feature @@ -572,6 +668,9 @@ artifacts: - type: satisfies target: REQ-020 tags: [cross-repo] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-034 type: feature @@ -585,6 +684,9 @@ artifacts: - type: satisfies target: REQ-020 tags: [cross-repo, cli] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-035 type: feature @@ -598,6 +700,9 @@ artifacts: - type: satisfies target: REQ-020 tags: [cross-repo, cli] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-036 type: feature @@ -611,6 +716,9 @@ artifacts: - type: satisfies target: REQ-021 tags: [cross-repo, baseline, cli] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-037 type: feature @@ -624,6 +732,9 @@ artifacts: - type: satisfies target: REQ-022 tags: [packaging, wasm] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-038 type: feature @@ -638,6 +749,9 @@ artifacts: - type: satisfies target: REQ-020 tags: [cross-repo, validation] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-039 type: feature @@ -650,6 +764,9 @@ artifacts: description: > Dashboard section for browsing external project artifacts with sync status, cross-repo link navigation, and conditional nav entry. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-040 type: feature @@ -669,6 +786,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-041 type: feature @@ -689,6 +809,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-042 type: feature @@ -709,6 +832,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-043 type: feature @@ -730,6 +856,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-044 type: feature @@ -751,6 +880,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-045 type: feature @@ -771,6 +903,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-057 type: feature @@ -790,6 +925,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-052 type: feature @@ -811,6 +949,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-053 type: feature @@ -831,6 +972,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-054 type: feature @@ -853,6 +997,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-055 type: feature @@ -877,6 +1024,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-056 type: feature @@ -897,6 +1047,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-046 type: feature @@ -920,6 +1073,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-047 type: feature @@ -943,6 +1099,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-048 type: feature @@ -967,6 +1126,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-049 type: feature @@ -987,6 +1149,9 @@ artifacts: fields: phase: phase-3 baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-050 type: feature @@ -1006,6 +1171,9 @@ artifacts: target: DD-026 fields: phase: future + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-051 type: feature @@ -1027,6 +1195,9 @@ artifacts: target: DD-027 fields: phase: future + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-058 type: feature @@ -1042,6 +1213,9 @@ artifacts: target: REQ-032 - type: implements target: DD-029 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-059 type: feature @@ -1057,6 +1231,9 @@ artifacts: target: REQ-033 - type: implements target: DD-030 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-060 type: feature @@ -1073,6 +1250,9 @@ artifacts: target: REQ-033 - type: satisfies target: REQ-032 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-061 type: feature @@ -1086,6 +1266,9 @@ artifacts: target: REQ-034 - type: implements target: DD-035 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-062 type: feature @@ -1097,6 +1280,9 @@ artifacts: links: - type: satisfies target: REQ-035 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-063 type: feature @@ -1108,6 +1294,9 @@ artifacts: links: - type: satisfies target: REQ-036 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-064 type: feature @@ -1124,6 +1313,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-065 type: feature @@ -1142,6 +1334,9 @@ artifacts: fields: phase: phase-2 baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-066 type: feature @@ -1160,6 +1355,9 @@ artifacts: fields: phase: phase-3 baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-067 type: feature @@ -1178,6 +1376,9 @@ artifacts: fields: phase: future baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-068 type: feature @@ -1195,6 +1396,9 @@ artifacts: fields: phase: future baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-069 type: feature @@ -1212,6 +1416,9 @@ artifacts: fields: phase: future baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-071 type: feature @@ -1230,6 +1437,9 @@ artifacts: fields: phase: future baseline: v0.4.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-072 type: feature @@ -1247,6 +1457,9 @@ artifacts: fields: phase: future baseline: v0.4.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-073 type: feature @@ -1264,6 +1477,9 @@ artifacts: fields: phase: phase-3 baseline: v0.3.1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-070 type: feature @@ -1283,3 +1499,6 @@ artifacts: fields: phase: phase-3 baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 98629fe..f0cf898 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -6,12 +6,14 @@ artifacts: description: > All lifecycle artifacts are stored as YAML files in git repositories. No database or external service required for core operation. - tags: [core] + tags: [core, hook-test] fields: priority: must category: functional baseline: v0.1.0 - + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-002 type: requirement title: STPA artifact support @@ -25,6 +27,9 @@ artifacts: priority: must category: functional baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-003 type: requirement @@ -39,6 +44,9 @@ artifacts: priority: must category: functional baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-004 type: requirement @@ -61,6 +69,49 @@ artifacts: target: SC-3 - type: satisfies target: SC-15 + - type: constraint-satisfies + target: SC-1 + - type: constraint-satisfies + target: SC-3 + - type: constraint-satisfies + target: SC-15 + - type: constraint-satisfies + target: SC-LSP-002 + - type: constraint-satisfies + target: SC-LSP-004 + - type: constraint-satisfies + target: SC-IMPL-001 + - type: constraint-satisfies + target: SC-IMPL-004 + - type: constraint-satisfies + target: SC-IMPL-006 + - type: constraint-satisfies + target: CC-C-1 + - type: constraint-satisfies + target: CC-C-2 + - type: constraint-satisfies + target: CC-C-3 + - type: constraint-satisfies + target: CC-C-4 + - type: constraint-satisfies + target: CC-C-5 + - type: constraint-satisfies + target: CC-C-6 + - type: constraint-satisfies + target: CC-C-7 + - type: constraint-satisfies + target: CC-C-8 + - type: constraint-satisfies + target: CC-C-9 + - type: constraint-satisfies + target: CC-C-24 + - type: constraint-satisfies + target: CC-C-25 + - type: constraint-satisfies + target: CC-D-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-005 type: requirement title: ReqIF 1.2 import/export @@ -80,6 +131,27 @@ artifacts: target: SC-4 - type: satisfies target: SC-9 + - type: constraint-satisfies + target: SC-4 + - type: constraint-satisfies + target: SC-9 + - type: constraint-satisfies + target: CC-Q-1 + - type: constraint-satisfies + target: CC-Q-2 + - type: constraint-satisfies + target: CC-Q-3 + - type: constraint-satisfies + target: CC-Q-4 + - type: constraint-satisfies + target: CC-Q-5 + - type: constraint-satisfies + target: CC-Q-6 + - type: constraint-satisfies + target: CC-Q-7 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-006 type: requirement title: OSLC-based tool synchronization @@ -99,6 +171,33 @@ artifacts: target: SC-5 - type: satisfies target: SC-8 + - type: constraint-satisfies + target: SC-5 + - type: constraint-satisfies + target: SC-8 + - type: constraint-satisfies + target: CC-O-1 + - type: constraint-satisfies + target: CC-O-2 + - type: constraint-satisfies + target: CC-O-3 + - type: constraint-satisfies + target: CC-O-4 + - type: constraint-satisfies + target: CC-O-5 + - type: constraint-satisfies + target: CC-O-6 + - type: constraint-satisfies + target: CC-O-7 + - type: constraint-satisfies + target: CC-O-8 + - type: constraint-satisfies + target: CC-O-9 + - type: constraint-satisfies + target: CC-O-10 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-007 type: requirement title: CLI and serve pattern @@ -115,6 +214,39 @@ artifacts: links: - type: satisfies target: SC-18 + - type: constraint-satisfies + target: SC-18 + - type: constraint-satisfies + target: SC-22 + - type: constraint-satisfies + target: SC-IMPL-003 + - type: constraint-satisfies + target: CC-L-1 + - type: constraint-satisfies + target: CC-L-2 + - type: constraint-satisfies + target: CC-L-3 + - type: constraint-satisfies + target: CC-L-4 + - type: constraint-satisfies + target: CC-L-5 + - type: constraint-satisfies + target: CC-D-1 + - type: constraint-satisfies + target: CC-D-2 + - type: constraint-satisfies + target: CC-D-4 + - type: constraint-satisfies + target: CC-C-27 + - type: constraint-satisfies + target: CC-M-1 + - type: constraint-satisfies + target: CC-M-2 + - type: constraint-satisfies + target: CC-M-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-008 type: requirement @@ -131,6 +263,17 @@ artifacts: links: - type: satisfies target: SC-16 + - type: constraint-satisfies + target: SC-16 + - type: constraint-satisfies + target: CC-C-21 + - type: constraint-satisfies + target: CC-C-22 + - type: constraint-satisfies + target: CC-C-23 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-009 type: requirement @@ -149,6 +292,11 @@ artifacts: links: - type: satisfies target: SC-6 + - type: constraint-satisfies + target: SC-6 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-010 type: requirement title: Schema-driven validation @@ -166,6 +314,11 @@ artifacts: links: - type: satisfies target: SC-1 + - type: constraint-satisfies + target: SC-IMPL-005 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-011 type: requirement title: Rust edition 2024 with MSRV 1.85 @@ -177,6 +330,9 @@ artifacts: priority: must category: constraint baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-012 type: requirement @@ -191,6 +347,18 @@ artifacts: priority: must category: non-functional baseline: v0.1.0 + links: + - type: constraint-satisfies + target: CC-I-1 + - type: constraint-satisfies + target: CC-I-2 + - type: constraint-satisfies + target: CC-I-3 + - type: constraint-satisfies + target: CC-I-4 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-013 type: requirement @@ -205,6 +373,9 @@ artifacts: priority: must category: non-functional baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-014 type: requirement @@ -220,6 +391,9 @@ artifacts: priority: must category: non-functional baseline: v0.1.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-015 type: requirement @@ -242,6 +416,11 @@ artifacts: links: - type: satisfies target: SC-4 + - type: constraint-satisfies + target: SC-4 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-016 type: requirement title: Cybersecurity schema (ISO 21434 / ASPICE SEC.1-4) @@ -260,7 +439,11 @@ artifacts: links: - type: satisfies target: SC-4 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-017 + type: requirement title: Commit-to-artifact traceability status: approved @@ -281,6 +464,17 @@ artifacts: target: SC-7 - type: satisfies target: SC-17 + - type: constraint-satisfies + target: SC-7 + - type: constraint-satisfies + target: SC-17 + - type: constraint-satisfies + target: CC-C-18 + - type: constraint-satisfies + target: CC-C-19 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-018 type: requirement title: Commit message validation at commit time @@ -299,6 +493,9 @@ artifacts: links: - type: satisfies target: SC-7 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-019 type: requirement title: Orphan commit detection @@ -317,6 +514,9 @@ artifacts: links: - type: satisfies target: SC-7 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-020 type: requirement title: Cross-repository artifact linking via prefixed IDs @@ -335,6 +535,19 @@ artifacts: target: SC-10 - type: satisfies target: SC-19 + - type: constraint-satisfies + target: SC-10 + - type: constraint-satisfies + target: SC-19 + - type: constraint-satisfies + target: CC-C-20 + - type: constraint-satisfies + target: CC-L-6 + - type: constraint-satisfies + target: CC-L-7 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-021 type: requirement title: Distributed baselining via convention tags @@ -351,6 +564,9 @@ artifacts: links: - type: satisfies target: SC-10 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-022 type: requirement title: Single-binary WASM asset embedding @@ -363,6 +579,9 @@ artifacts: priority: should category: functional baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-023 type: requirement @@ -380,11 +599,18 @@ artifacts: links: - type: satisfies target: SC-12 + - type: constraint-satisfies + target: SC-12 + - type: constraint-satisfies + target: CC-C-12 fields: priority: should category: functional upstream-ref: "eclipse-score/docs-as-code#180" baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-024 type: requirement @@ -405,6 +631,11 @@ artifacts: links: - type: satisfies target: SC-3 + - type: constraint-satisfies + target: SC-IMPL-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-025 type: requirement title: sphinx-needs JSON import @@ -424,6 +655,9 @@ artifacts: links: - type: satisfies target: SC-4 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-026 type: requirement title: Test-to-requirement traceability extraction @@ -444,7 +678,11 @@ artifacts: links: - type: satisfies target: SC-7 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-027 + type: requirement title: Build-system-aware cross-repo discovery status: approved @@ -465,6 +703,17 @@ artifacts: links: - type: satisfies target: SC-10 + - type: constraint-satisfies + target: SC-20 + - type: constraint-satisfies + target: CC-C-15 + - type: constraint-satisfies + target: CC-C-16 + - type: constraint-satisfies + target: CC-C-17 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-028 type: requirement title: Diagnostic-quality parsing with lossless syntax trees @@ -479,10 +728,19 @@ artifacts: links: - type: satisfies target: SC-13 + - type: constraint-satisfies + target: SC-13 + - type: constraint-satisfies + target: SC-LSP-001 + - type: constraint-satisfies + target: SC-LSP-005 fields: priority: must category: non-functional baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-029 type: requirement @@ -500,10 +758,33 @@ artifacts: links: - type: satisfies target: SC-11 + - type: constraint-satisfies + target: SC-11 + - type: constraint-satisfies + target: SC-21 + - type: constraint-satisfies + target: SC-LSP-003 + - type: constraint-satisfies + target: SC-LSP-006 + - type: constraint-satisfies + target: SC-LSP-007 + - type: constraint-satisfies + target: CC-C-10 + - type: constraint-satisfies + target: CC-C-11 + - type: constraint-satisfies + target: CC-C-13 + - type: constraint-satisfies + target: CC-C-14 + - type: constraint-satisfies + target: CC-C-26 fields: priority: must category: non-functional baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-031 type: requirement @@ -525,10 +806,15 @@ artifacts: target: SC-1 - type: satisfies target: SC-2 + - type: constraint-satisfies + target: SC-2 fields: priority: must category: functional baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-030 type: requirement @@ -546,10 +832,15 @@ artifacts: links: - type: satisfies target: SC-14 + - type: constraint-satisfies + target: SC-14 fields: priority: should category: non-functional baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-032 type: requirement @@ -562,6 +853,9 @@ artifacts: category: functional priority: should baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-033 type: requirement @@ -574,6 +868,9 @@ artifacts: category: functional priority: should baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-034 type: requirement @@ -589,6 +886,9 @@ artifacts: links: - type: satisfies target: SC-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-035 type: requirement @@ -601,6 +901,9 @@ artifacts: category: functional priority: must baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-036 type: requirement @@ -613,6 +916,9 @@ artifacts: category: functional priority: should baseline: v0.2.0-dev + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-037 type: requirement @@ -629,6 +935,9 @@ artifacts: priority: should category: functional baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-038 type: requirement @@ -645,6 +954,9 @@ artifacts: priority: could category: functional baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-040 type: requirement @@ -662,6 +974,9 @@ artifacts: priority: should category: functional baseline: v0.4.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: REQ-039 type: requirement @@ -679,3 +994,167 @@ artifacts: priority: must category: functional baseline: v0.3.0 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + - id: REQ-041 + type: requirement + title: S-expression query and constraint language + status: draft + description: > + The system must provide a single s-expression-based language for + artifact filtering, traceability queries, feature model constraints, + and variant-scoped validation. The language must support predicates + over artifact fields (type, status, tags, custom fields), logical + connectives (and, or, not, implies, excludes), quantifiers (forall, + exists) over artifact sets, link traversal predicates (linked-by, + linked-from, reachable-from), and aggregation (count, ratio). The + same syntax must be usable in CLI --filter flags, schema traceability + rules, feature model constraints, and binding validation rules. No + alternative syntax or sugar — one canonical form. + tags: [core, query, s-expression] + links: + - type: constraint-satisfies + target: SC-VAR-004 + fields: + priority: must + category: functional + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: REQ-042 + type: requirement + title: Feature model for product line engineering + status: draft + description: > + The system must support FODA-style feature models that describe the + logical problem space of a product line. Feature models define a + tree of features with group types (mandatory, optional, alternative, + or) and cross-tree constraints expressed as s-expressions (implies, + excludes, requires). Feature models are stored as YAML artifacts + separate from implementation artifacts, maintaining the separation + between logical (problem space) and implementation (solution space) + concerns. Feature models must be validatable independently — the + constraint set must be satisfiable. + tags: [variant, ple, feature-model] + fields: + priority: must + category: functional + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: REQ-043 + type: requirement + title: Variant configuration with constraint solving + status: draft + description: > + The system must support named variant configurations that select a + subset of features from a feature model. A constraint solver must + propagate implications (selecting feature A forces feature B via + constraints), detect conflicts (mutually exclusive selections), and + validate that the final feature set satisfies all cross-tree + constraints. Invalid configurations must produce clear diagnostics + identifying which constraints are violated. The solver must use + propositional logic (boolean SAT) at minimum, with optional + first-order extensions (forall/exists over feature sets) for + advanced constraints. + tags: [variant, ple, constraint-solver] + links: + - type: constraint-satisfies + target: SC-VAR-002 + - type: constraint-satisfies + target: SC-VAR-006 + fields: + priority: must + category: functional + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: REQ-044 + type: requirement + title: Feature-to-artifact binding model + status: draft + description: > + The system must support a binding model that maps features from the + logical feature model to implementation artifacts and source code + globs. Bindings are stored in separate YAML files, maintaining the + three-layer separation (feature model / variant config / binding). + Each feature can bind to zero or more artifact IDs and zero or more + source file glob patterns. Validation must detect: unbound features + (feature with no artifacts), unbound artifacts (artifacts not mapped + to any feature), and stale bindings (referencing deleted artifacts + or non-existent source paths). + tags: [variant, ple, binding] + links: + - type: constraint-satisfies + target: SC-VAR-003 + - type: constraint-satisfies + target: SC-VAR-005 + fields: + priority: must + category: functional + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: REQ-045 + type: requirement + title: Per-variant validation and coverage + status: draft + description: > + The system must validate traceability and coverage independently for + each variant configuration. Given a variant, the system derives the + effective feature set, collects all bound artifacts, and validates + that traceability chains are complete within that subset. Coverage + reports must be per-variant. An --all-variants mode must validate + every defined variant independently and report per-variant results. + A variant that passes validation when the union of all artifacts + passes must still fail if its own subset has broken traceability. + tags: [variant, validation, coverage] + links: + - type: constraint-satisfies + target: SC-VAR-001 + - type: constraint-satisfies + target: SC-VAR-007 + fields: + priority: must + category: functional + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: REQ-046 + type: requirement + title: Variant-scoped CLI and API commands + status: draft + description: > + All relevant CLI commands must accept --variant flags to scope + operations to a specific product configuration. This includes + validate, coverage, matrix, list, stats, impact, export, and the + dashboard API endpoints. The --variant flag composes with existing + --type and --status filters. rivet validate without --variant + validates the union (all artifacts). rivet validate --variant X + validates only the subset bound to variant X's features. + tags: [variant, cli, api] + fields: + priority: should + category: functional + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z diff --git a/artifacts/v031-decisions.yaml b/artifacts/v031-decisions.yaml index 7231207..17e5ead 100644 --- a/artifacts/v031-decisions.yaml +++ b/artifacts/v031-decisions.yaml @@ -20,6 +20,9 @@ artifacts: links: - type: satisfies target: REQ-035 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-043 type: design-decision @@ -38,6 +41,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-044 type: design-decision @@ -57,6 +63,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-045 type: design-decision @@ -77,6 +86,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-046 type: design-decision @@ -95,6 +107,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: DD-047 type: design-decision @@ -114,3 +129,6 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/artifacts/v031-features.yaml b/artifacts/v031-features.yaml index 70fdad4..0bd5194 100644 --- a/artifacts/v031-features.yaml +++ b/artifacts/v031-features.yaml @@ -13,6 +13,9 @@ artifacts: links: - type: satisfies target: REQ-035 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-075 type: feature @@ -24,6 +27,9 @@ artifacts: links: - type: satisfies target: REQ-035 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-076 type: feature @@ -35,6 +41,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Snapshot and delta ───────────────────────────────────────────── @@ -49,6 +58,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-078 type: feature @@ -61,6 +73,9 @@ artifacts: links: - type: satisfies target: REQ-035 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-079 type: feature @@ -73,6 +88,9 @@ artifacts: links: - type: satisfies target: REQ-035 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── MCP server ───────────────────────────────────────────────────── @@ -87,6 +105,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Convergence tracking ─────────────────────────────────────────── @@ -101,6 +122,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── CLI commands ─────────────────────────────────────────────────── @@ -114,6 +138,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── LSP fixes ────────────────────────────────────────────────────── @@ -127,6 +154,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-084 type: feature @@ -139,6 +169,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-085 type: feature @@ -150,6 +183,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-086 type: feature @@ -161,6 +197,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-087 type: feature @@ -172,6 +211,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Schemas ──────────────────────────────────────────────────────── @@ -186,6 +228,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-089 type: feature @@ -198,6 +243,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-090 type: feature @@ -209,6 +257,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-091 type: feature @@ -220,6 +271,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-092 type: feature @@ -232,3 +286,6 @@ artifacts: links: - type: satisfies target: REQ-004 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/artifacts/v031-phase2-features.yaml b/artifacts/v031-phase2-features.yaml index 650dae1..d066b6d 100644 --- a/artifacts/v031-phase2-features.yaml +++ b/artifacts/v031-phase2-features.yaml @@ -13,6 +13,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-094 type: feature @@ -24,6 +27,9 @@ artifacts: links: - type: satisfies target: REQ-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-095 type: feature @@ -38,6 +44,9 @@ artifacts: target: REQ-002 - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Domain schemas ───────────────────────────────────────────────── @@ -51,6 +60,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-097 type: feature @@ -62,6 +74,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-098 type: feature @@ -73,6 +88,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-099 type: feature @@ -84,6 +102,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-100 type: feature @@ -95,6 +116,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-101 type: feature @@ -106,6 +130,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Other features ───────────────────────────────────────────────── @@ -120,6 +147,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-103 type: feature @@ -132,6 +162,9 @@ artifacts: links: - type: satisfies target: REQ-010 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-104 type: feature @@ -144,6 +177,9 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: FEAT-105 type: feature @@ -155,3 +191,6 @@ artifacts: links: - type: satisfies target: REQ-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/artifacts/v040-decisions.yaml b/artifacts/v040-decisions.yaml new file mode 100644 index 0000000..8870427 --- /dev/null +++ b/artifacts/v040-decisions.yaml @@ -0,0 +1,158 @@ +# Design decisions for v0.4.0 development cycle — variant/PLE system +artifacts: + - id: DD-048 + type: design-decision + title: S-expressions as the single canonical expression syntax + status: approved + description: > + All expressions in rivet — CLI filters, schema traceability rules, + feature model constraints, binding validation — use s-expression + syntax. No infix sugar, no "simple mode" alternatives. + fields: + rationale: > + One syntax means one parser, one evaluator, one learning curve. + Multiple syntaxes for the same thing create edge cases and + ambiguity at the boundaries. Lisp demonstrated for 60+ years + that s-expressions compose reliably. The syntax embeds cleanly + in YAML strings (no escaping issues), is trivially parseable + (no operator precedence), and is homoiconic (the expression IS + the AST). Users learn and, or, not, implies, forall, exists, + plus a handful of predicates — that covers all use cases. + alternatives: > + Infix expressions with operator precedence (Python-like). + Rejected: ambiguity at complex nesting, escaping issues in YAML, + need for precedence rules. Two syntaxes (simple infix + advanced + s-expr). Rejected: creates "which one do I use?" confusion and + edge cases where they diverge. Prolog syntax. Rejected: powerful + but alien to YAML-native users and brings non-termination risk. + links: + - type: satisfies + target: REQ-041 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: DD-049 + type: design-decision + title: Datalog semantics with termination guarantee + status: approved + description: > + The s-expression language compiles internally to Datalog relations + and is evaluated using semi-naive evaluation. This guarantees + termination for all queries (unlike Prolog) while supporting + transitive closure, negation-as-failure, and incremental evaluation. + fields: + rationale: > + Traceability is fundamentally graph queries over relations: + type(X, requirement), tag(X, eu), link(X, satisfies, Y). Datalog + is purpose-built for this. It guarantees termination (no infinite + loops from recursive rules), supports transitive closure natively + (needed for impact analysis), and is incrementally evaluable + (fits salsa's dependency tracking). The Rust borrow checker's + Polonius engine uses datafrog for the same reasons. Propositional + constraints (feature model) are a strict subset of Datalog, so + the same engine handles both filtering and constraint solving. + alternatives: > + Full Prolog engine. Rejected: non-termination risk, heavyweight + embedding, overkill for rivet's constraint complexity. Simple + predicate evaluator without Datalog. Rejected: cannot express + transitive closure or recursive rules needed for impact analysis + and reachability queries. External SAT solver (minisat, varisat). + Rejected: separate engine for constraints vs queries, impedance + mismatch with artifact data model. + source-ref: > + Polonius (Rust borrow checker) uses datafrog for Datalog. + Soufflé, Datomic, LogicBlox validate the approach at scale. + links: + - type: satisfies + target: REQ-041 + - type: satisfies + target: REQ-043 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: DD-050 + type: design-decision + title: Three-layer PLE architecture (feature / variant / binding) + status: approved + description: > + Product line engineering uses three separate artifact layers: + (1) Feature model — logical problem space, no implementation details. + (2) Variant configurations — specific feature selections. + (3) Binding model — maps features to artifacts and source globs. + Each layer is maintained independently by different roles. + fields: + rationale: > + pure::variants proved this separation at industrial scale. + Collapsing layers (e.g., annotating artifacts with variant tags) + mixes problem and solution space, making it impossible for domain + experts to reason about features without understanding + implementation details. The three-layer model lets safety + engineers own the feature model, systems engineers own the + artifacts, and integration engineers own the bindings — three + different roles, three different files, one validation. + Sphinx-Needs' needs_variants puts variant tags on needs directly, + which collapses this separation and creates coupling between + domain logic and artifact management. + alternatives: > + Tag-based variants (Sphinx-Needs style). Rejected: collapses + problem/solution space separation, combinatorial tag explosion, + no constraint solving. Per-artifact variant annotation (Option C + from design exploration). Rejected: adds variant fields to the + core schema, mixes concerns, forces every artifact to be + variant-aware. Two-layer (feature model + direct artifact + annotation). Rejected: loses the binding model, meaning you + can't independently refactor artifacts without updating the + feature model. + links: + - type: satisfies + target: REQ-042 + - type: satisfies + target: REQ-044 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: DD-051 + type: design-decision + title: Thin custom Datalog evaluator over salsa + status: draft + description: > + Implement a minimal semi-naive Datalog evaluator (~300-500 lines) + that plugs into salsa's dependency tracking, rather than embedding + datafrog or crepe as external crates. + fields: + rationale: > + Rivet needs a narrow subset of Datalog: no aggregation beyond + count, no stratified negation beyond simple not-in-set, no + recursive rules beyond transitive closure. A thin evaluator + over salsa means incremental re-evaluation is automatic — change + one artifact, only re-derive affected query results. datafrog is + excellent but has its own fixpoint loop that doesn't compose with + salsa's memoization. crepe uses proc macros that define rules at + compile time, but rivet needs runtime-defined rules (user + expressions parsed from YAML). A custom evaluator trades + generality for integration: it knows about rivet's artifact + model and can optimize common patterns (type equality, tag + membership, link existence) as indexed lookups rather than + general joins. + alternatives: > + datafrog crate. Rejected: own fixpoint loop doesn't compose + with salsa, would require double-tracking of dependencies. + crepe crate. Rejected: compile-time rule definitions, rivet + needs runtime-parsed rules from user YAML. Full Datalog engine + (Soufflé FFI). Rejected: C++ dependency, massive overkill, + hard to cross-compile. + links: + - type: satisfies + target: REQ-041 + - type: satisfies + target: REQ-029 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z diff --git a/artifacts/v040-features.yaml b/artifacts/v040-features.yaml new file mode 100644 index 0000000..6565c05 --- /dev/null +++ b/artifacts/v040-features.yaml @@ -0,0 +1,223 @@ +# Features for v0.4.0 development cycle — s-expression language + variant/PLE +artifacts: + - id: FEAT-106 + type: feature + title: S-expression parser and AST + status: draft + description: > + Hand-rolled or nom-based parser for s-expressions. Produces a typed + AST: atoms (string, integer, float, boolean, wildcard, symbol), lists, + and special forms (and, or, not, implies, forall, exists, etc.). + Parser produces span information for error reporting. Approximately + 200 lines of parser + 100 lines of AST types. + tags: [s-expression, parser, phase-1] + links: + - type: satisfies + target: REQ-041 + - type: implements + target: DD-048 + fields: + phase: phase-1 + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-107 + type: feature + title: Predicate evaluator for single-artifact filtering + status: draft + description: > + Evaluate s-expression predicates against a single artifact. Supports + field access (type, status, id, title, tags, custom fields), comparison + operators (=, !=, >, <, >=, <=), set membership (in, has-tag, has-field), + string matching (matches, contains), link predicates (linked-by, + linked-from, linked-to, links-count), and logical connectives (and, or, + not). This is the foundation for CLI --filter flags. Approximately 300 + lines. + tags: [s-expression, evaluator, phase-1] + links: + - type: satisfies + target: REQ-041 + - type: implements + target: DD-048 + fields: + phase: phase-1 + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-108 + type: feature + title: CLI --filter integration for list, coverage, matrix, stats + status: draft + description: > + Add --filter flag to rivet list, coverage, matrix, stats, and export + commands. The flag accepts an s-expression string that is parsed and + evaluated to filter the artifact set. Composes with existing --type + and --status flags (logical AND). Also add filter support to dashboard + API endpoints as a query parameter. + tags: [s-expression, cli, api, phase-1] + links: + - type: satisfies + target: REQ-041 + - type: satisfies + target: REQ-046 + fields: + phase: phase-1 + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-109 + type: feature + title: Datalog compiler and semi-naive evaluator + status: draft + description: > + Compile s-expression quantifiers (forall, exists) and graph traversal + (reachable-from, reachable-to, path) into Datalog relations. Implement + semi-naive evaluation with fixpoint iteration for transitive closure. + Integrate with salsa for incremental re-evaluation. Approximately 500 + lines. Unlocks s-expression-based traceability rules in schemas. + tags: [s-expression, datalog, evaluator, phase-2] + links: + - type: satisfies + target: REQ-041 + - type: implements + target: DD-049 + - type: implements + target: DD-051 + fields: + phase: phase-2 + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-110 + type: feature + title: Feature model schema and parser + status: draft + description: > + New artifact kind "feature-model" with YAML structure for FODA-style + feature trees. Supports group types (mandatory, optional, alternative, + or), feature hierarchy (parent/children), and cross-tree constraints + as s-expressions. Schema validation ensures well-formedness: no + cycles in feature tree, valid group types, parseable constraints. + tags: [variant, ple, feature-model, phase-3] + links: + - type: satisfies + target: REQ-042 + - type: implements + target: DD-050 + fields: + phase: phase-3 + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-111 + type: feature + title: Propositional constraint solver for variant configurations + status: draft + description: > + Solve feature model constraints to validate and propagate variant + configurations. Given a set of selected features, propagate + implications (implies, requires), detect conflicts (excludes, + alternative group violations), and derive the effective feature set. + Report clear diagnostics for invalid configurations. Approximately + 400 lines using boolean constraint propagation (BCP). + tags: [variant, ple, constraint-solver, phase-3] + links: + - type: satisfies + target: REQ-043 + - type: implements + target: DD-049 + fields: + phase: phase-3 + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-112 + type: feature + title: Binding model for feature-to-artifact mapping + status: draft + description: > + New artifact kind "feature-binding" that maps features to artifact + IDs and source file glob patterns. Validation detects unbound + features, unbound artifacts, stale bindings (deleted artifacts, + non-existent paths), and binding conflicts (same artifact bound + to mutually exclusive features). Approximately 200 lines. + tags: [variant, ple, binding, phase-3] + links: + - type: satisfies + target: REQ-044 + - type: implements + target: DD-050 + fields: + phase: phase-3 + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-113 + type: feature + title: Per-variant validation and coverage reporting + status: draft + description: > + Extend the validation engine to scope traceability checks to a + variant's effective artifact set. rivet validate --variant X derives + features, collects bound artifacts, validates within that scope. + rivet validate --all-variants iterates all defined variants. Coverage + reports include per-variant breakdowns. Dashboard and API endpoints + support variant scoping via query parameter. + tags: [variant, validation, coverage, future] + links: + - type: satisfies + target: REQ-045 + - type: satisfies + target: REQ-046 + fields: + phase: future + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: FEAT-114 + type: feature + title: Variant-aware impact analysis + status: draft + description: > + Extend rivet impact to use the binding model for variant-scoped + change propagation. Given a changed source file, identify affected + features via source globs, then identify affected variants that + include those features. Report which variants need re-validation + based on which files changed. + tags: [variant, impact, future] + links: + - type: satisfies + target: REQ-045 + - type: satisfies + target: REQ-024 + fields: + phase: future + baseline: v0.4.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 771e52f..f735096 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -20,6 +20,9 @@ artifacts: - "Given a YAML artifact file, When rivet loads it, Then the artifact is stored with correct ID and title" - "Given a store with artifacts, When querying by type, Then only artifacts of that type are returned" - "Given two store snapshots, When computing diff, Then added/removed/modified artifacts are identified" + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-002 type: feature @@ -39,6 +42,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-003 type: feature @@ -59,6 +65,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-004 type: feature @@ -76,6 +85,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-005 type: feature @@ -94,6 +106,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-006 type: feature @@ -116,6 +131,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-007 type: feature @@ -138,6 +156,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-008 type: feature @@ -155,6 +176,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-009 type: feature @@ -176,6 +200,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-010 type: feature @@ -195,6 +222,9 @@ artifacts: target: REQ-014 fields: phase: phase-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-011 type: feature @@ -217,6 +247,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-012 type: feature @@ -235,6 +268,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-013 type: feature @@ -254,6 +290,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-014 type: feature @@ -272,6 +311,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-015 type: feature @@ -293,6 +335,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-016 type: feature @@ -311,6 +356,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-017 type: feature @@ -331,6 +379,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-018 type: feature @@ -349,6 +400,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-019 type: feature @@ -367,6 +421,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-020 type: feature @@ -385,6 +442,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-021 type: feature @@ -403,6 +463,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-022 type: feature @@ -422,6 +485,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-023 type: feature @@ -443,6 +509,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-024 type: feature @@ -463,6 +532,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-025 type: feature @@ -482,6 +554,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-026 type: feature @@ -503,6 +578,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-027 type: feature @@ -522,6 +600,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-028 type: feature @@ -543,6 +624,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-029 type: feature @@ -564,6 +648,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-030 type: feature @@ -583,6 +670,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-031 type: feature @@ -603,6 +693,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-032 type: feature @@ -622,6 +715,9 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-033 type: feature @@ -643,6 +739,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-034 type: feature @@ -663,6 +762,9 @@ artifacts: target: REQ-014 fields: phase: phase-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: TEST-035 type: feature @@ -682,3 +784,6 @@ artifacts: target: REQ-014 fields: phase: phase-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/rivet-cli/Cargo.toml b/rivet-cli/Cargo.toml index 7cbbf42..4ba71fb 100644 --- a/rivet-cli/Cargo.toml +++ b/rivet-cli/Cargo.toml @@ -34,7 +34,7 @@ lsp-server = "0.7" lsp-types = "0.97" rowan = { workspace = true } notify = "7" -rmcp = { version = "1.3.0", features = ["server", "transport-io", "macros"] } +rmcp = { version = "1.3.0", features = ["server", "transport-io", "macros", "transport-streamable-http-server"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index af6b9d9..9330ee0 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -182,6 +182,10 @@ enum Command { /// Generate AGENTS.md (and CLAUDE.md shim) from current project state #[arg(long)] agents: bool, + + /// Install git hooks (commit-msg, pre-commit) that call rivet for validation + #[arg(long)] + hooks: bool, }, /// Validate artifacts against schemas @@ -227,6 +231,10 @@ enum Command { #[arg(short, long)] status: Option, + /// S-expression filter, e.g. '(and (= type "requirement") (has-tag "stpa"))' + #[arg(long)] + filter: Option, + /// Output format: "text" (default) or "json" #[arg(short, long, default_value = "text")] format: String, @@ -238,6 +246,10 @@ enum Command { /// Show artifact summary statistics Stats { + /// S-expression filter to scope statistics + #[arg(long)] + filter: Option, + /// Output format: "text" (default) or "json" #[arg(short, long, default_value = "text")] format: String, @@ -249,6 +261,10 @@ enum Command { /// Show traceability coverage report Coverage { + /// S-expression filter to scope coverage + #[arg(long)] + filter: Option, + /// Output format: "text" (default) or "json" #[arg(short, long, default_value = "text")] format: String, @@ -368,6 +384,10 @@ enum Command { /// Topic slug to display (omit for topic list) topic: Option, + /// List available topics (same as `rivet docs` with no args) + #[arg(long)] + list: bool, + /// Search across all docs (like grep) #[arg(long)] grep: Option, @@ -762,21 +782,26 @@ fn run(cli: Cli) -> Result { schema, dir, agents, + hooks, } = &cli.command { if *agents { return cmd_init_agents(&cli); } + if *hooks { + return cmd_init_hooks(dir); + } return cmd_init(name.as_deref(), preset, schema, dir); } if let Command::Docs { topic, + list, grep, format, context, } = &cli.command { - return cmd_docs(topic.as_deref(), grep.as_deref(), format, *context); + return cmd_docs(topic.as_deref(), *list, grep.as_deref(), format, *context); } if let Command::Context = &cli.command { return cmd_context(&cli); @@ -816,18 +841,25 @@ fn run(cli: Cli) -> Result { Command::List { r#type, status, + filter, format, baseline, } => cmd_list( &cli, r#type.as_deref(), status.as_deref(), + filter.as_deref(), format, baseline.as_deref(), ), Command::Get { id, format } => cmd_get(&cli, id, format), - Command::Stats { format, baseline } => cmd_stats(&cli, format, baseline.as_deref()), + Command::Stats { + filter, + format, + baseline, + } => cmd_stats(&cli, filter.as_deref(), format, baseline.as_deref()), Command::Coverage { + filter, format, fail_under, tests, @@ -837,7 +869,13 @@ fn run(cli: Cli) -> Result { if *tests { cmd_coverage_tests(&cli, format, scan_paths) } else { - cmd_coverage(&cli, format, fail_under.as_ref(), baseline.as_deref()) + cmd_coverage( + &cli, + filter.as_deref(), + format, + fail_under.as_ref(), + baseline.as_deref(), + ) } } Command::Matrix { @@ -2262,6 +2300,116 @@ rivet stats # Show summary statistics Ok(true) } +/// Install git hooks that delegate to rivet for commit validation. +/// +/// Hooks chain with existing hooks: if a hook file already exists, it is +/// renamed to `.prev` and called after rivet's check succeeds. +/// This allows coexistence with other hook managers (husky, pre-commit, lefthook). +fn cmd_init_hooks(dir: &std::path::Path) -> Result { + let dir = if dir == std::path::Path::new(".") { + std::env::current_dir().context("resolving current directory")? + } else { + dir.to_path_buf() + }; + + // Find .git directory (supports worktrees) + let git_dir_output = std::process::Command::new("git") + .args(["rev-parse", "--git-dir"]) + .current_dir(&dir) + .output() + .context("running git rev-parse --git-dir")?; + + if !git_dir_output.status.success() { + anyhow::bail!("not a git repository (run from a git working tree)"); + } + + let git_dir = dir.join(String::from_utf8_lossy(&git_dir_output.stdout).trim()); + let hooks_dir = git_dir.join("hooks"); + std::fs::create_dir_all(&hooks_dir) + .with_context(|| format!("creating {}", hooks_dir.display()))?; + + // Resolve rivet binary path for the hook. + let rivet_bin = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "rivet".to_string()); + + // ── commit-msg hook ───────────────────────────────────────────── + let commit_msg_path = hooks_dir.join("commit-msg"); + install_hook( + &commit_msg_path, + &format!( + r#"#!/usr/bin/env bash +# Installed by: rivet init --hooks +# Validates commit trailers reference artifact IDs. +"{rivet_bin}" commit-msg-check "$1" || exit $? +"#, + ), + )?; + println!(" installed {}", commit_msg_path.display()); + + // ── pre-commit hook ───────────────────────────────────────────── + let pre_commit_path = hooks_dir.join("pre-commit"); + install_hook( + &pre_commit_path, + &format!( + r#"#!/usr/bin/env bash +# Installed by: rivet init --hooks +# Runs rivet validate and blocks on errors. +output=$("{rivet_bin}" validate --format json 2>/dev/null) +errors=$(echo "$output" | python3 -c "import json,sys; print(json.load(sys.stdin).get('errors',0))" 2>/dev/null || echo "0") +if [ "$errors" -gt 0 ]; then + echo "rivet validate: $errors error(s). Run 'rivet validate' for details." + exit 1 +fi +"#, + ), + )?; + println!(" installed {}", pre_commit_path.display()); + + println!("\nGit hooks installed. Rivet will validate commits automatically."); + println!("Hooks chain with existing hooks via .prev files."); + Ok(true) +} + +/// Install a hook file, chaining with any existing hook. +/// +/// If a hook already exists: +/// 1. Rename it to `.prev` +/// 2. The new hook calls `.prev` at the end (after rivet's check) +/// +/// This allows coexistence with pre-commit, husky, lefthook, etc. +fn install_hook(path: &std::path::Path, content: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + if path.exists() { + let prev = path.with_extension("prev"); + // Don't overwrite an existing .prev (user may have modified it) + if !prev.exists() { + std::fs::rename(path, &prev) + .with_context(|| format!("backing up {}", path.display()))?; + eprintln!(" note: existing hook backed up to {}", prev.display()); + } + } + + // Check for .prev file and append chaining call + let prev = path.with_extension("prev"); + let chain_snippet = if prev.exists() { + format!( + "\n# Chain to previous hook\nif [ -x \"{}\" ]; then\n \"{}\" \"$@\"\nfi\n", + prev.display(), + prev.display() + ) + } else { + String::new() + }; + + let final_content = format!("{content}{chain_snippet}"); + std::fs::write(path, &final_content).with_context(|| format!("writing {}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("setting permissions on {}", path.display()))?; + Ok(()) +} + /// Collapse newlines and pipes so a description fits in a markdown table cell. fn sanitize_for_table(s: &str) -> String { s.replace('\n', " ") @@ -2997,20 +3145,9 @@ fn run_salsa_validation(cli: &Cli, config: &ProjectConfig) -> Result = Vec::new(); - for name in &config.project.schemas { - let path = schemas_dir.join(format!("{name}.yaml")); - if path.exists() { - let content = std::fs::read_to_string(&path) - .with_context(|| format!("reading schema {}", path.display()))?; - schema_contents.push((name.clone(), content)); - } else if let Some(content) = rivet_core::embedded::embedded_schema(name) { - schema_contents.push((name.clone(), content.to_string())); - } else { - log::warn!("schema '{name}' not found on disk or embedded"); - } - } + // ── Collect schema content (including auto-discovered bridges) ────── + let schema_contents = + rivet_core::embedded::load_schema_contents(&config.project.schemas, &schemas_dir); // ── Collect source file content ───────────────────────────────────── let mut source_contents: Vec<(String, String)> = Vec::new(); @@ -3158,6 +3295,7 @@ fn cmd_list( cli: &Cli, type_filter: Option<&str>, status_filter: Option<&str>, + sexpr_filter: Option<&str>, format: &str, baseline_name: Option<&str>, ) -> Result { @@ -3171,7 +3309,17 @@ fn cmd_list( ..Default::default() }; - let results = rivet_core::query::execute(&store, &query); + let mut results = rivet_core::query::execute(&store, &query); + + // Apply s-expression filter if provided. + if let Some(filter_src) = sexpr_filter { + let expr = rivet_core::sexpr_eval::parse_filter(filter_src).map_err(|errs| { + let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); + anyhow::anyhow!("invalid filter: {}", msgs.join("; ")) + })?; + let graph = rivet_core::links::LinkGraph::build(&store, &ctx.schema); + results.retain(|a| rivet_core::sexpr_eval::matches_filter(&expr, a, &graph)); + } if format == "json" { let artifacts_json: Vec = results @@ -3208,67 +3356,138 @@ fn cmd_list( } /// Print summary statistics. -fn cmd_stats(cli: &Cli, format: &str, baseline_name: Option<&str>) -> Result { +fn cmd_stats( + cli: &Cli, + sexpr_filter: Option<&str>, + format: &str, + baseline_name: Option<&str>, +) -> Result { validate_format(format, &["text", "json"])?; let ctx = ProjectContext::load(cli)?; - let store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); - let graph = if baseline_name.is_some() { + let mut store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); + let mut graph = if baseline_name.is_some() { LinkGraph::build(&store, &ctx.schema) } else { ctx.graph }; - let orphans = graph.orphans(&store); + // Apply s-expression filter if provided. + if let Some(filter_src) = sexpr_filter { + let expr = rivet_core::sexpr_eval::parse_filter(filter_src).map_err(|errs| { + let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); + anyhow::anyhow!("invalid filter: {}", msgs.join("; ")) + })?; + let mut filtered = rivet_core::store::Store::default(); + for a in store.iter() { + if rivet_core::sexpr_eval::matches_filter(&expr, a, &graph) { + filtered.upsert(a.clone()); + } + } + store = filtered; + graph = LinkGraph::build(&store, &ctx.schema); + } + + // Compute stats once — both formats share the same data. + let stats = compute_stats(&store, &graph); if format == "json" { let mut types = serde_json::Map::new(); - let mut type_names: Vec<&str> = store.types().collect(); - type_names.sort(); - for t in &type_names { - types.insert(t.to_string(), serde_json::json!(store.count_by_type(t))); + for (name, count) in &stats.type_counts { + types.insert(name.clone(), serde_json::json!(count)); } let output = serde_json::json!({ "command": "stats", - "total": store.len(), + "total": stats.total, "types": types, - "orphans": orphans, - "broken_links": graph.broken.len(), + "orphans": stats.orphans, + "broken_links": stats.broken_links, }); println!("{}", serde_json::to_string_pretty(&output).unwrap()); } else { - print_stats(&store); + println!("Artifact summary:"); + for (name, count) in &stats.type_counts { + println!(" {:30} {:>4}", name, count); + } + println!(" {:30} {:>4}", "TOTAL", stats.total); - if !orphans.is_empty() { - println!("\nOrphan artifacts (no links): {}", orphans.len()); - for id in &orphans { + if !stats.orphans.is_empty() { + println!("\nOrphan artifacts (no links): {}", stats.orphans.len()); + for id in &stats.orphans { println!(" {}", id); } } - if !graph.broken.is_empty() { - println!("\nBroken links: {}", graph.broken.len()); + if stats.broken_links > 0 { + println!("\nBroken links: {}", stats.broken_links); } } Ok(true) } +/// Pre-computed stats shared by text and JSON output paths. +struct StatsResult { + total: usize, + type_counts: Vec<(String, usize)>, + orphans: Vec, + broken_links: usize, +} + +/// Compute artifact stats from the store and link graph. +/// +/// The total is derived as the sum of per-type counts so that both text and +/// JSON formats are guaranteed to agree. +fn compute_stats(store: &Store, graph: &LinkGraph) -> StatsResult { + let mut type_names: Vec<&str> = store.types().collect(); + type_names.sort(); + let type_counts: Vec<(String, usize)> = type_names + .iter() + .map(|t| (t.to_string(), store.count_by_type(t))) + .collect(); + let total: usize = type_counts.iter().map(|(_, c)| c).sum(); + let orphans: Vec = graph.orphans(store).into_iter().cloned().collect(); + StatsResult { + total, + type_counts, + orphans, + broken_links: graph.broken.len(), + } +} + /// Show traceability coverage report. fn cmd_coverage( cli: &Cli, + sexpr_filter: Option<&str>, format: &str, fail_under: Option<&f64>, baseline_name: Option<&str>, ) -> Result { validate_format(format, &["text", "json"])?; let ctx = ProjectContext::load(cli)?; - let store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); + let mut store = apply_baseline_scope(ctx.store, baseline_name, &ctx.config); let schema = ctx.schema; - let graph = if baseline_name.is_some() { + let mut graph = if baseline_name.is_some() { LinkGraph::build(&store, &schema) } else { ctx.graph }; + + // Apply s-expression filter if provided. + if let Some(filter_src) = sexpr_filter { + let expr = rivet_core::sexpr_eval::parse_filter(filter_src).map_err(|errs| { + let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); + anyhow::anyhow!("invalid filter: {}", msgs.join("; ")) + })?; + let mut filtered = rivet_core::store::Store::default(); + for a in store.iter() { + if rivet_core::sexpr_eval::matches_filter(&expr, a, &graph) { + filtered.upsert(a.clone()); + } + } + store = filtered; + graph = LinkGraph::build(&store, &schema); + } + let report = coverage::compute_coverage(&store, &schema, &graph); if format == "json" { @@ -3837,6 +4056,21 @@ fn cmd_export_html( } else { String::new() }; + let eu_ai_act_loaded = rivet_core::compliance::is_eu_ai_act_loaded(&state.schema); + let eu_ai_act_nav = if eu_ai_act_loaded { + let eu_count: usize = rivet_core::compliance::EU_AI_ACT_TYPES + .iter() + .map(|t| state.store.count_by_type(t)) + .sum(); + let badge = if eu_count > 0 { + format!("{eu_count}") + } else { + String::new() + }; + format!("
  • EU AI Act{badge}
  • ") + } else { + String::new() + }; format!( r#" @@ -3871,6 +4105,7 @@ document.addEventListener('DOMContentLoaded',function(){{
  • Documents{doc_badge}
  • {stpa_nav} + {eu_ai_act_nav}
  • Help & Docs
  • @@ -3922,6 +4157,8 @@ document.addEventListener('DOMContentLoaded',function(){{ page_count += 1; write_page("stpa/index.html", "/stpa", "STPA", out_dir)?; page_count += 1; + write_page("eu-ai-act/index.html", "/eu-ai-act", "EU AI Act", out_dir)?; + page_count += 1; write_page("documents/index.html", "/documents", "Documents", out_dir)?; page_count += 1; write_page("graph/index.html", "/graph", "Graph", out_dir)?; @@ -4573,9 +4810,18 @@ struct RawLink { } /// Show built-in docs (no project load needed). -fn cmd_docs(topic: Option<&str>, grep: Option<&str>, format: &str, context: usize) -> Result { +fn cmd_docs( + topic: Option<&str>, + list: bool, + grep: Option<&str>, + format: &str, + context: usize, +) -> Result { validate_format(format, &["text", "json"])?; - if let Some(pattern) = grep { + if list { + // --list explicitly requests the topic listing + print!("{}", docs::list_topics(format)); + } else if let Some(pattern) = grep { print!("{}", docs::grep_docs(pattern, format, context)); } else if let Some(slug) = topic { print!("{}", docs::show_topic(slug, format)); @@ -5799,9 +6045,20 @@ impl ProjectContext { match rivet_core::externals::load_all_externals(externals, &cli.project) { Ok(resolved) => { for ext in resolved { + // Collect all IDs in this external so we can prefix + // internal link targets consistently. + let ext_ids: std::collections::HashSet = + ext.artifacts.iter().map(|a| a.id.clone()).collect(); for mut artifact in ext.artifacts { // Prefix external artifact IDs so they don't collide artifact.id = format!("{}:{}", ext.prefix, artifact.id); + // Prefix link targets that reference artifacts within + // this same external project so they resolve correctly. + for link in &mut artifact.links { + if ext_ids.contains(&link.target) { + link.target = format!("{}:{}", ext.prefix, link.target); + } + } store.upsert(artifact); } } @@ -6278,8 +6535,12 @@ fn cmd_stamp( // Collect artifact IDs to stamp let ids: Vec = if id == "all" { - // Stamp every artifact in the store - store.iter().map(|a| a.id.clone()).collect() + // Stamp every local artifact (skip externals with ':' prefix) + store + .iter() + .filter(|a| !a.id.contains(':')) + .map(|a| a.id.clone()) + .collect() } else { // Single artifact if !store.contains(id) { @@ -6297,9 +6558,10 @@ fn cmd_stamp( let mut by_file: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for aid in &ids { - let source_file = mutate::find_source_file(aid, &store) - .ok_or_else(|| anyhow::anyhow!("cannot determine source file for '{aid}'"))?; - by_file.entry(source_file).or_default().push(aid.clone()); + if let Some(source_file) = mutate::find_source_file(aid, &store) { + by_file.entry(source_file).or_default().push(aid.clone()); + } + // Skip artifacts without source files (externals, etc.) } for (file_path, artifact_ids) in &by_file { @@ -7340,6 +7602,10 @@ fn cmd_lsp(cli: &Cli) -> Result { } } + // Drop the connection to close the sender channel, allowing the + // writer IO thread to finish. Without this, io_threads.join() would + // deadlock because the writer thread blocks on the channel. + drop(connection); io_threads.join()?; eprintln!("rivet lsp: shut down"); Ok(true) @@ -8487,3 +8753,74 @@ artifacts: assert_eq!(symbols.len(), 2); } } + +#[cfg(test)] +mod stats_tests { + use super::*; + use rivet_core::store::Store; + + fn make_artifact(id: &str, art_type: &str) -> rivet_core::model::Artifact { + rivet_core::model::Artifact { + id: id.into(), + artifact_type: art_type.into(), + title: format!("Title of {id}"), + description: None, + status: None, + tags: vec![], + links: vec![], + fields: Default::default(), + provenance: None, + source_file: None, + } + } + + #[test] + fn stats_total_equals_sum_of_type_counts() { + let mut store = Store::new(); + store.upsert(make_artifact("R-1", "req")); + store.upsert(make_artifact("R-2", "req")); + store.upsert(make_artifact("F-1", "feat")); + store.upsert(make_artifact("H-1", "hazard")); + + let schema = rivet_core::schema::Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let stats = compute_stats(&store, &graph); + + let sum: usize = stats.type_counts.iter().map(|(_, c)| c).sum(); + assert_eq!( + stats.total, sum, + "stats total ({}) must equal sum of type counts ({})", + stats.total, sum, + ); + assert_eq!(stats.total, store.len()); + } + + #[test] + fn stats_total_consistent_after_type_change() { + let mut store = Store::new(); + store.upsert(make_artifact("A-1", "req")); + store.upsert(make_artifact("A-2", "req")); + store.upsert(make_artifact("A-3", "feat")); + // Change A-1's type + store.upsert(make_artifact("A-1", "feat")); + + let schema = rivet_core::schema::Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let stats = compute_stats(&store, &graph); + + let sum: usize = stats.type_counts.iter().map(|(_, c)| c).sum(); + assert_eq!( + stats.total, sum, + "after type change: total ({}) must equal sum of type counts ({})", + stats.total, sum, + ); + assert_eq!(stats.total, 3); + // No phantom types with 0 count + for (name, count) in &stats.type_counts { + assert!( + *count > 0, + "type '{name}' has 0 count but still appears in stats" + ); + } + } +} diff --git a/rivet-cli/src/mcp.rs b/rivet-cli/src/mcp.rs index 54cf46d..e055406 100644 --- a/rivet-cli/src/mcp.rs +++ b/rivet-cli/src/mcp.rs @@ -119,6 +119,50 @@ pub struct LinkParam { pub target: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ModifyParams { + #[schemars(description = "Artifact ID to modify")] + pub id: String, + #[schemars(description = "New status value")] + pub status: Option, + #[schemars(description = "New title")] + pub title: Option, + #[schemars(description = "Tags to add")] + pub add_tags: Option>, + #[schemars(description = "Tags to remove")] + pub remove_tags: Option>, + #[schemars(description = "Fields to set as key=value pairs")] + pub set_fields: Option>, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct McpLinkParams { + #[schemars(description = "Source artifact ID")] + pub source: String, + #[schemars(description = "Link type (e.g., 'satisfies', 'implements')")] + pub link_type: String, + #[schemars(description = "Target artifact ID")] + pub target: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RemoveParams { + #[schemars(description = "Artifact ID to remove")] + pub id: String, + #[schemars(description = "Force removal even if other artifacts link to this one")] + pub force: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct QueryParams { + #[schemars( + description = "S-expression filter, e.g. '(and (= type \"requirement\") (has-tag \"stpa\"))'" + )] + pub filter: String, + #[schemars(description = "Maximum number of results (default: 100)")] + pub limit: Option, +} + // ── RivetServer ──────────────────────────────────────────────────────── #[derive(Clone)] @@ -169,6 +213,25 @@ impl RivetServer { }) } + /// Create a `RivetServer` from pre-loaded state (used by the dashboard's + /// MCP-over-HTTP endpoint so we don't reload from disk). + pub fn from_shared( + project_dir: PathBuf, + store: Store, + schema: rivet_core::schema::Schema, + graph: LinkGraph, + ) -> Self { + Self { + tool_router: Self::tool_router(), + project_dir: Arc::new(project_dir), + project: Arc::new(RwLock::new(McpProject { + store, + schema, + graph, + })), + } + } + #[tool(description = "Validate artifacts against schemas and return diagnostics")] fn rivet_validate(&self) -> Result { let result = self.with_project(|proj| Ok(tool_validate_cached(proj)))?; @@ -273,6 +336,67 @@ impl RivetServer { )])) } + #[tool( + description = "Query artifacts using an s-expression filter. Returns matching artifacts with full details." + )] + fn rivet_query( + &self, + Parameters(p): Parameters, + ) -> Result { + let result = self.with_project(|proj| tool_query(proj, &p))?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + + #[tool( + description = "Modify an existing artifact (status, title, tags, fields). Call rivet_reload after." + )] + fn rivet_modify( + &self, + Parameters(p): Parameters, + ) -> Result { + let result = tool_modify(self.dir(), &p).map_err(Self::err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + + #[tool(description = "Add a link between two artifacts. Call rivet_reload after.")] + fn rivet_link( + &self, + Parameters(p): Parameters, + ) -> Result { + let result = + tool_link(self.dir(), &p.source, &p.link_type, &p.target).map_err(Self::err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + + #[tool(description = "Remove a link between two artifacts. Call rivet_reload after.")] + fn rivet_unlink( + &self, + Parameters(p): Parameters, + ) -> Result { + let result = + tool_unlink(self.dir(), &p.source, &p.link_type, &p.target).map_err(Self::err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + + #[tool(description = "Remove an artifact from the project. Call rivet_reload after.")] + fn rivet_remove( + &self, + Parameters(p): Parameters, + ) -> Result { + let result = tool_remove(self.dir(), &p.id, p.force.unwrap_or(false)).map_err(Self::err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + #[tool(description = "Reload project from disk after file changes")] fn rivet_reload(&self) -> Result { let new_proj = load_project(self.dir()).map_err(Self::err)?; @@ -806,6 +930,138 @@ fn json_to_yaml_value(v: &Value) -> serde_yaml::Value { } } +// ── Query tool helper ───────────────────────────────────────────────── + +fn tool_query(proj: &McpProject, params: &QueryParams) -> Result { + let expr = rivet_core::sexpr_eval::parse_filter(¶ms.filter).map_err(|errs| { + let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); + anyhow::anyhow!("invalid filter: {}", msgs.join("; ")) + })?; + + let limit = params.limit.unwrap_or(100); + let mut results: Vec = Vec::new(); + + for artifact in proj.store.iter() { + if !rivet_core::sexpr_eval::matches_filter(&expr, artifact, &proj.graph) { + continue; + } + let links_json: Vec = artifact + .links + .iter() + .map(|l| json!({"type": l.link_type, "target": l.target})) + .collect(); + results.push(json!({ + "id": artifact.id, + "type": artifact.artifact_type, + "title": artifact.title, + "status": artifact.status.as_deref().unwrap_or("-"), + "tags": artifact.tags, + "links": links_json, + "description": artifact.description.as_deref().unwrap_or(""), + })); + if results.len() >= limit { + break; + } + } + + Ok(json!({ + "filter": params.filter, + "count": results.len(), + "artifacts": results, + })) +} + +// ── Mutation tool helpers ────────────────────────────────────────────── + +fn tool_modify(project_dir: &Path, p: &ModifyParams) -> Result { + use rivet_core::mutate; + + let proj = load_project(project_dir)?; + + let set_fields: Vec<(String, String)> = p + .set_fields + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|s| { + let (k, v) = s.split_once('=')?; + Some((k.to_string(), v.to_string())) + }) + .collect(); + + let params = mutate::ModifyParams { + set_status: p.status.clone(), + set_title: p.title.clone(), + add_tags: p.add_tags.clone().unwrap_or_default(), + remove_tags: p.remove_tags.clone().unwrap_or_default(), + set_fields, + }; + + mutate::validate_modify(&p.id, ¶ms, &proj.store, &proj.schema)?; + + let source_file = mutate::find_source_file(&p.id, &proj.store) + .ok_or_else(|| anyhow::anyhow!("cannot find source file for '{}'", p.id))?; + + mutate::modify_artifact_in_file(&p.id, ¶ms, &source_file, &proj.store)?; + + Ok(json!({ "modified": p.id, "file": source_file.display().to_string() })) +} + +fn tool_link(project_dir: &Path, source: &str, link_type: &str, target: &str) -> Result { + use rivet_core::model::Link; + use rivet_core::mutate; + + let proj = load_project(project_dir)?; + + mutate::validate_link(source, link_type, target, &proj.store, &proj.schema)?; + + let source_file = mutate::find_source_file(source, &proj.store) + .ok_or_else(|| anyhow::anyhow!("cannot find source file for '{source}'"))?; + + let link = Link { + link_type: link_type.to_string(), + target: target.to_string(), + }; + + mutate::add_link_to_file(source, &link, &source_file)?; + + Ok( + json!({ "linked": format!("{source} --[{link_type}]--> {target}"), "file": source_file.display().to_string() }), + ) +} + +fn tool_unlink(project_dir: &Path, source: &str, link_type: &str, target: &str) -> Result { + use rivet_core::mutate; + + let proj = load_project(project_dir)?; + + mutate::validate_unlink(source, link_type, target, &proj.store)?; + + let source_file = mutate::find_source_file(source, &proj.store) + .ok_or_else(|| anyhow::anyhow!("cannot find source file for '{source}'"))?; + + mutate::remove_link_from_file(source, link_type, target, &source_file)?; + + Ok( + json!({ "unlinked": format!("{source} --[{link_type}]--> {target}"), "file": source_file.display().to_string() }), + ) +} + +fn tool_remove(project_dir: &Path, id: &str, force: bool) -> Result { + use rivet_core::mutate; + + let proj = load_project(project_dir)?; + + mutate::validate_remove(id, force, &proj.store, &proj.graph)?; + + let source_file = mutate::find_source_file(id, &proj.store) + .ok_or_else(|| anyhow::anyhow!("cannot find source file for '{id}'"))?; + + mutate::remove_artifact_from_file(id, &source_file)?; + + Ok(json!({ "removed": id, "file": source_file.display().to_string() })) +} + // ── Entry point ──────────────────────────────────────────────────────── /// Run the MCP server using rmcp over stdio transport. diff --git a/rivet-cli/src/serve/api.rs b/rivet-cli/src/serve/api.rs index 4a46c4c..bff7149 100644 --- a/rivet-cli/src/serve/api.rs +++ b/rivet-cli/src/serve/api.rs @@ -330,6 +330,8 @@ pub(crate) struct ArtifactsParams { status: Option, origin: Option, q: Option, + /// S-expression filter, e.g. `(and (= type "requirement") (has-tag "stpa"))` + filter: Option, #[serde(default = "default_limit")] limit: u32, #[serde(default)] @@ -354,6 +356,12 @@ pub(crate) async fn artifacts( let limit = params.limit.min(1000) as usize; let offset = params.offset as usize; + // Parse s-expression filter once before iterating. + let sexpr_filter = params + .filter + .as_deref() + .and_then(|f| rivet_core::sexpr_eval::parse_filter(f).ok()); + let include_externals = params .origin .as_deref() @@ -368,9 +376,15 @@ pub(crate) async fn artifacts( .is_none_or(|o| o == "all" || o == "local"); if include_local { for artifact in guard.store.iter() { - if matches_filters(artifact, ¶ms) { - results.push(to_api_artifact(artifact, "local", &guard)); + if !matches_filters(artifact, ¶ms) { + continue; + } + if let Some(ref expr) = sexpr_filter { + if !rivet_core::sexpr_eval::matches_filter(expr, artifact, &guard.graph) { + continue; + } } + results.push(to_api_artifact(artifact, "local", &guard)); } } diff --git a/rivet-cli/src/serve/mod.rs b/rivet-cli/src/serve/mod.rs index 34e23b0..417e8e4 100644 --- a/rivet-cli/src/serve/mod.rs +++ b/rivet-cli/src/serve/mod.rs @@ -9,6 +9,11 @@ use axum::routing::{get, post}; use tokio::sync::RwLock; use tower_http::cors::CorsLayer; +use crate::mcp::RivetServer; +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, +}; + /// HTMX bundled inline — no CDN dependency, works offline. const HTMX_JS: &str = include_str!("../../assets/htmx.min.js"); @@ -693,6 +698,40 @@ pub async fn run(app_state: AppState, bind: String, watch: bool) -> Result<()> { let state: SharedState = Arc::new(RwLock::new(app_state)); + // ── MCP over Streamable HTTP ─────────────────────────────────────── + // + // Creates an MCP endpoint at /mcp that reuses the same project data as + // the dashboard. Each MCP session snapshots the current store/schema/graph + // so that a dashboard reload is picked up by new sessions automatically. + let mcp_state = state.clone(); + let mcp_project_path = project_path_for_watch.clone(); + let mcp_config = StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true); + let mcp_service: StreamableHttpService = + StreamableHttpService::new( + move || { + // Snapshot the dashboard state into a fresh RivetServer. + // `try_read()` avoids blocking the tokio runtime if a reload + // is in progress — in that (rare) case we return an error and + // the MCP client retries. + let guard = mcp_state.try_read().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "project is reloading, retry shortly", + ) + })?; + Ok(RivetServer::from_shared( + mcp_project_path.clone(), + guard.store.clone(), + guard.schema.clone(), + guard.graph.clone(), + )) + }, + Arc::new(LocalSessionManager::default()), + mcp_config, + ); + let app = Router::new() .route("/", get(views::index)) .route("/artifacts", get(views::artifacts_list)) @@ -747,6 +786,7 @@ pub async fn run(app_state: AppState, bind: String, watch: bool) -> Result<()> { .route("/assets/htmx.js", get(htmx_asset)) .route("/assets/mermaid.js", get(mermaid_asset)) .route("/reload", post(reload_handler)) + .nest_service("/mcp", mcp_service) .with_state(state.clone()) .layer(axum::middleware::from_fn_with_state(state.clone(), wrap_full_page)) .layer(axum::middleware::map_response( @@ -772,6 +812,7 @@ pub async fn run(app_state: AppState, bind: String, watch: bool) -> Result<()> { } eprintln!("rivet dashboard listening on http://{bind}:{actual_port}"); + eprintln!(" MCP endpoint: http://{bind}:{actual_port}/mcp"); if watch { spawn_file_watcher( @@ -840,6 +881,7 @@ async fn wrap_full_page( && !is_htmx && (path != "/" || is_print || is_embed) && !path.starts_with("/api/") + && !path.starts_with("/mcp") && !path.starts_with("/oembed") && !path.starts_with("/assets/") && !path.starts_with("/wasm/") diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 8c5a808..904fdab 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -49,6 +49,27 @@ fn docs_list_topics() { ); } +/// `rivet docs --list` explicitly lists all available topics. +#[test] +fn docs_list_flag() { + let output = Command::new(rivet_bin()) + .args(["docs", "--list"]) + .output() + .expect("failed to execute rivet docs --list"); + + assert!(output.status.success(), "rivet docs --list must exit 0"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("artifact-format"), + "topic list must include 'artifact-format', got:\n{stdout}" + ); + assert!( + stdout.contains("rivet-yaml"), + "topic list must include 'rivet-yaml', got:\n{stdout}" + ); +} + /// `rivet docs artifact-format` shows the topic content. #[test] fn docs_show_topic() { @@ -878,3 +899,426 @@ fn get_nonexistent_returns_error() { "stderr must mention 'not found'. Got:\n{stderr}" ); } + +/// `rivet get REQ-001 --format yaml` produces YAML output. +#[test] +fn get_yaml_produces_valid_output() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "get", + "REQ-001", + "--format", + "yaml", + ]) + .output() + .expect("failed to execute rivet get REQ-001 --format yaml"); + + assert!( + output.status.success(), + "rivet get REQ-001 --format yaml must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("REQ-001"), + "YAML output must contain artifact ID. Got:\n{stdout}" + ); + assert!( + stdout.contains("requirement"), + "YAML output must contain artifact type. Got:\n{stdout}" + ); +} + +// ── rivet coverage ───────────────────────────────────────────────────── + +/// `rivet coverage --format json` produces valid JSON with overall and rules. +#[test] +fn coverage_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "coverage", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet coverage --format json"); + + assert!( + output.status.success(), + "rivet coverage --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("coverage JSON must be valid"); + + assert!( + parsed.get("overall").is_some(), + "coverage JSON must contain 'overall'" + ); + assert!( + parsed.get("rules").and_then(|v| v.as_array()).is_some(), + "coverage JSON must contain 'rules' array" + ); +} + +// ── rivet matrix ─────────────────────────────────────────────────────── + +/// `rivet matrix --format json` produces valid JSON with matrix data. +#[test] +fn matrix_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "matrix", + "--from", + "requirement", + "--to", + "feature", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet matrix --format json"); + + assert!( + output.status.success(), + "rivet matrix --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("matrix JSON must be valid"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("matrix"), + ); + assert!( + parsed.get("rows").and_then(|v| v.as_array()).is_some(), + "matrix JSON must contain 'rows' array" + ); + assert!( + parsed.get("source_type").and_then(|v| v.as_str()).is_some(), + "matrix JSON must contain 'source_type'" + ); + assert!( + parsed.get("target_type").and_then(|v| v.as_str()).is_some(), + "matrix JSON must contain 'target_type'" + ); +} + +// ── rivet next-id ────────────────────────────────────────────────────── + +/// `rivet next-id --type requirement --format json` produces valid JSON. +#[test] +fn next_id_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "next-id", + "--type", + "requirement", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet next-id --format json"); + + assert!( + output.status.success(), + "rivet next-id --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("next-id JSON must be valid"); + + assert!( + parsed.get("next_id").and_then(|v| v.as_str()).is_some(), + "next-id JSON must contain 'next_id'" + ); + assert!( + parsed.get("prefix").and_then(|v| v.as_str()).is_some(), + "next-id JSON must contain 'prefix'" + ); +} + +// ── rivet schema subcommands ─────────────────────────────────────────── + +/// `rivet schema show requirement --format json` produces valid JSON. +#[test] +fn schema_show_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "schema", + "show", + "requirement", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet schema show --format json"); + + assert!( + output.status.success(), + "rivet schema show --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("schema show JSON must be valid"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("schema-show"), + ); + assert!( + parsed.get("artifact_type").is_some(), + "schema show JSON must contain 'artifact_type'" + ); +} + +/// `rivet schema links --format json` produces valid JSON with link_types. +#[test] +fn schema_links_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "schema", + "links", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet schema links --format json"); + + assert!( + output.status.success(), + "rivet schema links --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("schema links JSON must be valid"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("schema-links"), + ); + assert!( + parsed + .get("link_types") + .and_then(|v| v.as_array()) + .is_some(), + "schema links JSON must contain 'link_types' array" + ); + assert!( + parsed.get("count").and_then(|v| v.as_u64()).unwrap_or(0) > 0, + "schema links must report at least one link type" + ); +} + +/// `rivet schema rules --format json` produces valid JSON with rules. +#[test] +fn schema_rules_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "schema", + "rules", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet schema rules --format json"); + + assert!( + output.status.success(), + "rivet schema rules --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("schema rules JSON must be valid"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("schema-rules"), + ); + assert!( + parsed.get("rules").and_then(|v| v.as_array()).is_some(), + "schema rules JSON must contain 'rules' array" + ); + assert!( + parsed.get("count").and_then(|v| v.as_u64()).unwrap_or(0) > 0, + "schema rules must report at least one rule" + ); +} + +/// `rivet schema info stpa --format json` produces valid JSON with schema metadata. +#[test] +fn schema_info_json() { + let output = Command::new(rivet_bin()) + .args([ + "--project", + project_root().to_str().unwrap(), + "schema", + "info", + "stpa", + "--format", + "json", + ]) + .output() + .expect("failed to execute rivet schema info --format json"); + + assert!( + output.status.success(), + "rivet schema info --format json must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("schema info JSON must be valid"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("schema-info"), + ); + assert_eq!( + parsed.get("name").and_then(|v| v.as_str()), + Some("stpa"), + "schema info must report correct schema name" + ); + assert!( + parsed.get("version").is_some(), + "schema info JSON must contain 'version'" + ); + assert!( + parsed.get("artifact_type_count").is_some(), + "schema info JSON must contain 'artifact_type_count'" + ); +} + +// ── JSON validity sweep ──────────────────────────────────────────────── + +/// Comprehensive sweep: every command that accepts `--format json` must +/// produce output that parses as valid JSON on stdout. +#[test] +fn all_json_outputs_are_valid() { + let project = project_root(); + let p = project.to_str().unwrap(); + + // (description, args) + let cases: Vec<(&str, Vec<&str>)> = vec![ + ( + "validate", + vec!["--project", p, "validate", "--format", "json"], + ), + ("list", vec!["--project", p, "list", "--format", "json"]), + ("stats", vec!["--project", p, "stats", "--format", "json"]), + ( + "coverage", + vec!["--project", p, "coverage", "--format", "json"], + ), + ( + "get", + vec!["--project", p, "get", "REQ-001", "--format", "json"], + ), + ( + "schema list", + vec!["--project", p, "schema", "list", "--format", "json"], + ), + ( + "schema show", + vec![ + "--project", + p, + "schema", + "show", + "requirement", + "--format", + "json", + ], + ), + ( + "schema links", + vec!["--project", p, "schema", "links", "--format", "json"], + ), + ( + "schema rules", + vec!["--project", p, "schema", "rules", "--format", "json"], + ), + ( + "schema info", + vec!["--project", p, "schema", "info", "stpa", "--format", "json"], + ), + ( + "matrix", + vec![ + "--project", + p, + "matrix", + "--from", + "requirement", + "--to", + "feature", + "--format", + "json", + ], + ), + ( + "next-id", + vec![ + "--project", + p, + "next-id", + "--type", + "requirement", + "--format", + "json", + ], + ), + ("docs", vec!["docs", "--format", "json"]), + ( + "docs grep", + vec!["docs", "--grep", "verification", "--format", "json"], + ), + ]; + + for (label, args) in cases { + let output = Command::new(rivet_bin()) + .args(&args) + .output() + .unwrap_or_else(|e| panic!("failed to execute rivet {label}: {e}")); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let parsed: Result = serde_json::from_str(&stdout); + assert!( + parsed.is_ok(), + "rivet {label} --format json must produce valid JSON.\n\ + stdout: {stdout}\nstderr: {stderr}\nerror: {}", + parsed.unwrap_err() + ); + } +} diff --git a/rivet-cli/tests/lsp_integration.rs b/rivet-cli/tests/lsp_integration.rs new file mode 100644 index 0000000..27a3346 --- /dev/null +++ b/rivet-cli/tests/lsp_integration.rs @@ -0,0 +1,677 @@ +//! LSP integration tests -- spawn `rivet lsp` as a subprocess and exercise +//! the Language Server Protocol over stdio (JSON-RPC 2.0). +//! +//! Each test creates a temporary project directory with `rivet.yaml` and a +//! small artifact YAML file, starts the LSP, and verifies the expected +//! responses and notifications. +//! +//! A background reader thread is used to avoid blocking on stdout reads. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +/// Locate the `rivet` binary built by cargo. +fn rivet_bin() -> PathBuf { + if let Ok(bin) = std::env::var("CARGO_BIN_EXE_rivet") { + return PathBuf::from(bin); + } + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest.parent().expect("workspace root"); + workspace_root.join("target").join("debug").join("rivet") +} + +// ── JSON-RPC helpers ──────────────────────────────────────────────────── + +/// Encode a JSON-RPC message with Content-Length header for LSP. +fn encode_message(json: &serde_json::Value) -> Vec { + let body = serde_json::to_string(json).expect("serialize JSON-RPC message"); + format!("Content-Length: {}\r\n\r\n{}", body.len(), body).into_bytes() +} + +/// Read a single LSP message from a BufReader (blocking). Called from a +/// dedicated reader thread. +fn read_one_message(reader: &mut BufReader) -> Option { + let mut content_length: Option = None; + loop { + let mut header_line = String::new(); + match reader.read_line(&mut header_line) { + Ok(0) => return None, // EOF + Ok(_) => { + let trimmed = header_line.trim(); + if trimmed.is_empty() { + break; // end of headers + } + if let Some(val) = trimmed.strip_prefix("Content-Length: ") { + content_length = val.parse().ok(); + } + } + Err(_) => return None, + } + } + let length = content_length?; + let mut body = vec![0u8; length]; + reader.read_exact(&mut body).ok()?; + serde_json::from_slice(&body).ok() +} + +/// A handle to an LSP subprocess with a background reader thread. +/// Messages from the server are delivered via a channel. +struct LspProcess { + child: std::process::Child, + stdin: std::process::ChildStdin, + rx: mpsc::Receiver, + stderr_rx: mpsc::Receiver, +} + +impl LspProcess { + /// Send a JSON-RPC message to the server. + fn send(&mut self, msg: &serde_json::Value) { + self.stdin + .write_all(&encode_message(msg)) + .expect("write to LSP stdin"); + self.stdin.flush().expect("flush LSP stdin"); + } + + /// Receive the next message from the server, with a timeout. + fn recv(&self, timeout: Duration) -> Option { + self.rx.recv_timeout(timeout).ok() + } + + /// Receive messages until one matches the predicate, or timeout. + fn recv_until( + &self, + timeout: Duration, + pred: impl Fn(&serde_json::Value) -> bool, + ) -> Option { + let deadline = std::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return None; + } + match self.rx.recv_timeout(remaining) { + Ok(msg) if pred(&msg) => return Some(msg), + Ok(_) => continue, + Err(_) => return None, + } + } + } + + /// Drain all pending messages (non-blocking). + fn drain(&self) { + while self.rx.try_recv().is_ok() {} + } + + /// Collect all stderr lines received so far (non-blocking). + fn collect_stderr(&self) -> Vec { + let mut lines = Vec::new(); + while let Ok(line) = self.stderr_rx.try_recv() { + lines.push(line); + } + lines + } + + /// Perform LSP initialize handshake and return the server capabilities. + fn initialize(&mut self, root_uri: &str) -> serde_json::Value { + self.send(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": std::process::id(), + "rootUri": root_uri, + "capabilities": {}, + "workspaceFolders": null + } + })); + + let resp = self + .recv_until(Duration::from_secs(30), |m| { + m.get("id").and_then(|v| v.as_u64()) == Some(1) + }) + .expect("initialize response"); + + // Send initialized notification + self.send(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "initialized", + "params": {} + })); + + resp + } + + /// Send shutdown + exit and wait for the process to terminate. + fn shutdown(mut self) { + self.send(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 9999, + "method": "shutdown", + "params": null + })); + // Try to read shutdown response (best effort) + let _ = self.recv(Duration::from_secs(5)); + + self.send(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "exit", + "params": null + })); + + // Close stdin so the server's IO reader thread can finish. + // The lsp-server crate's io_threads.join() waits for stdin EOF. + drop(self.stdin); + + // Wait for exit with polling + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + match self.child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + _ => { + let _ = self.child.kill(); + let _ = self.child.wait(); + break; + } + } + } + } +} + +/// Create a temporary project directory with rivet.yaml and a sample artifact file. +/// Returns (temp_dir_handle, project_path, artifact_file_path). +fn create_test_project() -> (tempfile::TempDir, PathBuf, PathBuf) { + let tmp = tempfile::tempdir().expect("create temp dir"); + let dir = tmp.path().to_path_buf(); + + let config = r#"project: + name: lsp-test + version: "0.1.0" + schemas: + - common + - dev + +sources: + - path: artifacts + format: generic-yaml +"#; + std::fs::write(dir.join("rivet.yaml"), config).expect("write rivet.yaml"); + + let artifacts_dir = dir.join("artifacts"); + std::fs::create_dir_all(&artifacts_dir).expect("create artifacts dir"); + // The rowan schema-driven parser expects a top-level mapping with an + // `artifacts:` key for the generic-yaml format. + let artifacts_yaml = r#"artifacts: + - id: REQ-001 + type: requirement + title: First requirement + status: draft + + - id: REQ-002 + type: requirement + title: Second requirement + status: draft + links: + - type: satisfies + target: REQ-001 +"#; + let artifact_path = artifacts_dir.join("requirements.yaml"); + std::fs::write(&artifact_path, artifacts_yaml).expect("write artifacts"); + + (tmp, dir, artifact_path) +} + +/// Spawn `rivet lsp` with background reader threads for stdout and stderr. +fn spawn_lsp(project_dir: &std::path::Path) -> LspProcess { + let mut child = Command::new(rivet_bin()) + .args(["--project", project_dir.to_str().unwrap(), "lsp"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn rivet lsp"); + + let stdin = child.stdin.take().expect("stdin"); + let stdout = child.stdout.take().expect("stdout"); + let stderr = child.stderr.take().expect("stderr"); + + let (tx, rx) = mpsc::channel(); + + // Background thread for stdout (LSP messages) + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + while let Some(msg) = read_one_message(&mut reader) { + if tx.send(msg).is_err() { + break; + } + } + }); + + // Background thread for stderr (prevents pipe buffer from filling) + let (stderr_tx, stderr_rx) = mpsc::channel(); + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines() { + match line { + Ok(l) => { + if stderr_tx.send(l).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + LspProcess { + child, + stdin, + rx, + stderr_rx, + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +const TIMEOUT: Duration = Duration::from_secs(30); + +/// Verify the LSP initialize handshake: send initialize request, receive +/// a response with server capabilities. +#[test] +fn lsp_initialize_handshake() { + let (_tmp, project_dir, _artifact_path) = create_test_project(); + let mut lsp = spawn_lsp(&project_dir); + + let root_uri = format!("file://{}", project_dir.display()); + let response = lsp.initialize(&root_uri); + + let result = response + .get("result") + .expect("result field in initialize response"); + let capabilities = result.get("capabilities").expect("capabilities in result"); + + assert!( + capabilities.get("textDocumentSync").is_some(), + "server must advertise textDocumentSync" + ); + assert!( + capabilities.get("hoverProvider").is_some(), + "server must advertise hoverProvider" + ); + assert!( + capabilities.get("definitionProvider").is_some(), + "server must advertise definitionProvider" + ); + assert!( + capabilities.get("documentSymbolProvider").is_some(), + "server must advertise documentSymbolProvider" + ); + assert!( + capabilities.get("completionProvider").is_some(), + "server must advertise completionProvider" + ); + + lsp.shutdown(); +} + +/// Send textDocument/didOpen with a YAML artifact file that has a +/// validation error and verify that diagnostics are published. +#[test] +fn lsp_diagnostics_on_did_open() { + let (_tmp, project_dir, artifact_path) = create_test_project(); + let mut lsp = spawn_lsp(&project_dir); + + let root_uri = format!("file://{}", project_dir.display()); + let artifact_uri = format!("file://{}", artifact_path.display()); + + lsp.initialize(&root_uri); + + // Give the server time to finish loading schemas/sources after + // initialize, then send didOpen with a broken link. + std::thread::sleep(Duration::from_secs(2)); + + // Send didOpen with content that has a broken link (target NONEXISTENT + // does not exist), which should trigger a validation error. + let yaml_with_error = concat!( + "artifacts:\n", + " - id: REQ-001\n", + " type: requirement\n", + " title: First requirement\n", + " status: draft\n", + " links:\n", + " - type: satisfies\n", + " target: NONEXISTENT\n", + "\n", + " - id: REQ-002\n", + " type: requirement\n", + " title: Second requirement\n", + " status: draft\n", + ); + lsp.send(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": artifact_uri, + "languageId": "yaml", + "version": 1, + "text": yaml_with_error + } + } + })); + + // Send a documentSymbol request to force a server round-trip. + // This ensures the server has processed the didOpen before we check + // for diagnostics, and confirms the server is responsive. + lsp.send(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 50, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { "uri": artifact_uri } + } + })); + + // Read ALL messages until we get the documentSymbol response (id=50). + // Diagnostics notifications should arrive before or interleaved with + // the response. + let mut all_messages = Vec::new(); + let mut got_diagnostics = false; + let deadline = std::time::Instant::now() + TIMEOUT; + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + match lsp.recv(remaining) { + Some(msg) => { + if msg.get("method").and_then(|v| v.as_str()) + == Some("textDocument/publishDiagnostics") + { + got_diagnostics = true; + } + let is_sym_response = msg.get("id").and_then(|v| v.as_u64()) == Some(50); + all_messages.push(msg); + if is_sym_response { + break; + } + } + None => break, + } + } + + assert!( + all_messages + .iter() + .any(|m| m.get("id").and_then(|v| v.as_u64()) == Some(50)), + "server must respond to documentSymbol request after didOpen. \ + Received {} messages: {:?}", + all_messages.len(), + all_messages + ); + if !got_diagnostics { + let stderr_lines = lsp.collect_stderr(); + panic!( + "server must publish diagnostics after textDocument/didOpen with errors. \ + All messages received: {:?}\nServer stderr ({} lines): {}", + all_messages, + stderr_lines.len(), + stderr_lines.join("\n") + ); + } + // Find the diagnostics message and verify its structure + let diag_msg = all_messages + .iter() + .find(|m| { + m.get("method").and_then(|v| v.as_str()) == Some("textDocument/publishDiagnostics") + }) + .expect("diagnostics message must exist (asserted above)"); + let params = diag_msg.get("params").expect("diagnostics params"); + assert!( + params.get("uri").is_some(), + "diagnostics must have a uri field" + ); + assert!( + params + .get("diagnostics") + .and_then(|v| v.as_array()) + .is_some(), + "diagnostics must have a diagnostics array" + ); + + lsp.shutdown(); +} + +/// Send textDocument/documentSymbol request and verify symbols are returned. +#[test] +fn lsp_document_symbols() { + let (_tmp, project_dir, artifact_path) = create_test_project(); + let mut lsp = spawn_lsp(&project_dir); + + let root_uri = format!("file://{}", project_dir.display()); + let artifact_uri = format!("file://{}", artifact_path.display()); + + lsp.initialize(&root_uri); + + // Wait a moment for initialization to settle, drain notifications + std::thread::sleep(Duration::from_millis(500)); + lsp.drain(); + + // Send textDocument/didOpen so the server knows about the file + let artifact_content = std::fs::read_to_string(&artifact_path).expect("read artifact"); + lsp.send(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": artifact_uri, + "languageId": "yaml", + "version": 1, + "text": artifact_content + } + } + })); + + // Wait for didOpen diagnostics to be published, then drain them + std::thread::sleep(Duration::from_millis(500)); + lsp.drain(); + + // Send documentSymbol request + lsp.send(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { + "uri": artifact_uri + } + } + })); + + // Read the response with id=2 + let response = lsp + .recv_until(TIMEOUT, |m| m.get("id").and_then(|v| v.as_u64()) == Some(2)) + .expect("documentSymbol response"); + + let result = response + .get("result") + .expect("result in documentSymbol response"); + let symbols = result + .as_array() + .expect("result should be an array of symbols"); + + // Our test file has REQ-001 and REQ-002 + assert!( + symbols.len() >= 2, + "expected at least 2 symbols (REQ-001, REQ-002), got {}", + symbols.len() + ); + + // Verify the first symbol has expected fields + let first = &symbols[0]; + let name = first.get("name").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + name.contains("REQ-001") || name.contains("REQ-002"), + "first symbol name should contain an artifact ID, got: {name}" + ); + assert!( + first.get("kind").is_some(), + "symbol must have a 'kind' field" + ); + assert!( + first.get("range").is_some(), + "symbol must have a 'range' field" + ); + assert!( + first.get("selectionRange").is_some(), + "symbol must have a 'selectionRange' field" + ); + + lsp.shutdown(); +} + +/// Verify the LSP shutdown handshake: server responds to shutdown request +/// with the correct id. After shutdown+exit, close stdin so IO threads +/// can join. The server should then exit. +#[test] +fn lsp_clean_shutdown() { + let (_tmp, project_dir, _artifact_path) = create_test_project(); + let mut lsp = spawn_lsp(&project_dir); + + let root_uri = format!("file://{}", project_dir.display()); + lsp.initialize(&root_uri); + + // Drain initial notifications + std::thread::sleep(Duration::from_millis(500)); + lsp.drain(); + + // Send shutdown request + lsp.send(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 99, + "method": "shutdown", + "params": null + })); + + // Read the shutdown response. The lsp-server crate's handle_shutdown + // sends the response and then blocks waiting for the exit notification, + // so the response should arrive before we need to send exit. + let shutdown_resp = lsp.recv_until(Duration::from_secs(10), |m| { + m.get("id").and_then(|v| v.as_u64()) == Some(99) + }); + assert!( + shutdown_resp.is_some(), + "server must respond to shutdown request" + ); + let resp = shutdown_resp.unwrap(); + assert_eq!( + resp.get("id").and_then(|v| v.as_u64()), + Some(99), + "shutdown response must have matching id" + ); + + // Send exit notification and close stdin to let IO threads join. + lsp.send(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "exit", + "params": null + })); + drop(lsp.stdin); + + // Wait for process to exit + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let mut exited = false; + while std::time::Instant::now() < deadline { + match lsp.child.try_wait() { + Ok(Some(_)) => { + exited = true; + break; + } + Ok(None) => std::thread::sleep(Duration::from_millis(100)), + Err(e) => panic!("error waiting for child: {e}"), + } + } + if !exited { + let _ = lsp.child.kill(); + } + assert!( + exited, + "LSP server must exit within 10 seconds after shutdown+exit" + ); +} + +/// Send textDocument/didOpen with invalid YAML (bad artifact type) and +/// verify error diagnostics are published with severity. +#[test] +fn lsp_diagnostics_for_invalid_artifacts() { + let (_tmp, project_dir, artifact_path) = create_test_project(); + let mut lsp = spawn_lsp(&project_dir); + + let root_uri = format!("file://{}", project_dir.display()); + let artifact_uri = format!("file://{}", artifact_path.display()); + + lsp.initialize(&root_uri); + + // Drain initial diagnostics + std::thread::sleep(Duration::from_millis(500)); + lsp.drain(); + + // Send didOpen with an artifact that has an unknown type + let bad_yaml = "artifacts:\n - id: BAD-001\n type: nonexistent-type\n title: Invalid artifact\n status: draft\n"; + lsp.send(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": artifact_uri, + "languageId": "yaml", + "version": 1, + "text": bad_yaml + } + } + })); + + // Read diagnostics -- we expect at least one error about the unknown type + let mut found_error_diagnostic = false; + let deadline = std::time::Instant::now() + TIMEOUT; + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if let Some(msg) = lsp.recv(remaining) { + if msg.get("method").and_then(|v| v.as_str()) == Some("textDocument/publishDiagnostics") + { + let params = msg.get("params").expect("params"); + if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) { + for diag in diags { + let message = diag.get("message").and_then(|v| v.as_str()).unwrap_or(""); + if message.contains("unknown artifact type") + || message.contains("nonexistent") + { + found_error_diagnostic = true; + // Verify severity is Error (1) + let severity = diag.get("severity").and_then(|v| v.as_u64()); + assert_eq!( + severity, + Some(1), + "unknown type diagnostic should have Error severity" + ); + break; + } + } + } + if found_error_diagnostic { + break; + } + } + } else { + break; + } + } + assert!( + found_error_diagnostic, + "server must publish error diagnostics for unknown artifact type" + ); + + lsp.shutdown(); +} diff --git a/rivet-cli/tests/mcp_integration.rs b/rivet-cli/tests/mcp_integration.rs index c5c6c68..729cb03 100644 --- a/rivet-cli/tests/mcp_integration.rs +++ b/rivet-cli/tests/mcp_integration.rs @@ -150,7 +150,7 @@ fn parse_result(result: &CallToolResult) -> Value { // ── Tests ─────────────────────────────────────────────────────────────── #[tokio::test] -async fn test_tools_list_returns_all_10_tools() { +async fn test_tools_list_returns_all_15_tools() { let tmp = tempfile::tempdir().unwrap(); create_test_project(tmp.path()); @@ -170,6 +170,11 @@ async fn test_tools_list_returns_all_10_tools() { "rivet_embed", "rivet_snapshot_capture", "rivet_add", + "rivet_query", + "rivet_modify", + "rivet_link", + "rivet_unlink", + "rivet_remove", "rivet_reload", ]; diff --git a/rivet-core/src/db.rs b/rivet-core/src/db.rs index bf727c2..39d7292 100644 --- a/rivet-core/src/db.rs +++ b/rivet-core/src/db.rs @@ -364,6 +364,57 @@ pub fn compute_coverage_tracked( coverage::compute_coverage(&store, &schema, &graph) } +// ── S-expression filter support ──────────────────────────────────────── + +/// A filter expression tracked as a salsa input. +/// +/// Changing the filter source triggers re-parsing and re-evaluation of +/// the filter against all artifacts. +#[salsa::input] +pub struct FilterInput { + pub source: String, +} + +/// Parse a filter expression string into a typed AST. +/// +/// Cached by salsa — the same filter string is only parsed once until the +/// input changes. +#[salsa::tracked] +pub fn parse_filter_expr( + db: &dyn salsa::Database, + filter: FilterInput, +) -> Option { + let source = filter.source(db); + if source.is_empty() { + return None; + } + crate::sexpr_eval::parse_filter(&source).ok() +} + +/// Build a filtered list of artifact IDs matching a filter expression. +/// +/// Returns artifact IDs rather than a Store to satisfy salsa's PartialEq +/// requirement on tracked return types. +#[salsa::tracked] +pub fn filter_artifact_ids( + db: &dyn salsa::Database, + source_set: SourceFileSet, + schema_set: SchemaInputSet, + filter: FilterInput, +) -> Vec { + let expr = match parse_filter_expr(db, filter) { + Some(e) => e, + None => return Vec::new(), // no filter = empty (caller uses full store) + }; + let store = build_store(db, source_set, schema_set); + let graph = build_link_graph(db, source_set, schema_set); + store + .iter() + .filter(|a| crate::sexpr_eval::matches_filter(&expr, a, &graph)) + .map(|a| a.id.clone()) + .collect() +} + // ── Internal helpers (non-tracked) ────────────────────────────────────── /// Build the full Store + Schema + LinkGraph pipeline from salsa inputs. diff --git a/rivet-core/src/export.rs b/rivet-core/src/export.rs index 266369f..b07e64e 100644 --- a/rivet-core/src/export.rs +++ b/rivet-core/src/export.rs @@ -17,6 +17,7 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Write as _; +use crate::compliance; use crate::coverage; use crate::document::{self, ArtifactInfo, DocumentStore}; use crate::links::LinkGraph; @@ -479,6 +480,7 @@ fn nav_bar(active: &str, _config: &ExportConfig, is_single_page: bool) -> String ("requirements", "Requirements", "requirements.html"), ("documents", "Documents", "documents.html"), ("stpa", "STPA", "stpa.html"), + ("eu-ai-act", "EU AI Act", "eu-ai-act.html"), ("matrix", "Matrix", "matrix.html"), ("coverage", "Coverage", "coverage.html"), ("validation", "Validation", "validation.html"), @@ -1644,6 +1646,141 @@ pub fn render_graph(store: &Store, graph: &LinkGraph, config: &ExportConfig) -> out } +/// Render the EU AI Act Annex IV compliance section (no HTML wrapper). +fn render_section_eu_ai_act(store: &Store, schema: &Schema) -> String { + let report = compliance::compute_compliance(store, schema); + + if !report.schema_loaded { + return "

    EU AI Act Compliance

    \n\ +

    The EU AI Act schema is not loaded for this project. \ + Add eu-ai-act to your rivet.yaml schemas list \ + to enable the Annex IV compliance dashboard.

    \n" + .to_string(); + } + + let mut out = String::from("

    EU AI Act Compliance

    \n"); + + // ── Overall stats ────────────────────────────────────────────── + let complete = report + .sections + .iter() + .filter(|s| s.coverage_pct >= 100.0) + .count(); + writeln!( + out, + "

    Overall compliance: {:.1}% — \ + {complete}/{total} sections complete — \ + {artifacts} artifact(s) total

    ", + report.overall_pct, + total = report.sections.len(), + artifacts = report.total_artifacts, + ) + .unwrap(); + + // ── Per-section table ────────────────────────────────────────── + out.push_str( + "\n\ + \ + \ + \ + \ + \ + \n\n", + ); + + for section in &report.sections { + let types_list: String = section + .required_types + .iter() + .map(|t| format!("{}", html_escape(t))) + .collect::>() + .join(", "); + + let covered_str = format!( + "{}/{}", + section.covered_types.len(), + section.required_types.len() + ); + + let pct_class = if section.coverage_pct >= 100.0 { + "badge-approved" + } else if section.coverage_pct >= 50.0 { + "badge-draft" + } else { + "badge-obsolete" + }; + + writeln!( + out, + "\ + \ + \ + \ + \ + \ + ", + title = html_escape(§ion.title), + reference = html_escape(§ion.reference), + pct = section.coverage_pct, + ) + .unwrap(); + } + + out.push_str("\n
    SectionReferenceRequired TypesCoveredCoverage
    {title}{reference}{types_list}{covered_str}{pct:.0}%
    \n"); + + // ── Missing types ────────────────────────────────────────────── + let has_missing = report.sections.iter().any(|s| !s.missing_types.is_empty()); + if has_missing { + out.push_str("

    Missing Artifact Types

    \n"); + out.push_str("
      \n"); + for section in &report.sections { + for missing in §ion.missing_types { + writeln!( + out, + "
    • {section}: {typ}
    • ", + section = html_escape(§ion.title), + typ = html_escape(missing), + ) + .unwrap(); + } + } + out.push_str("
    \n"); + } + + // ── Artifact inventory ───────────────────────────────────────── + if report.total_artifacts > 0 { + out.push_str("

    Artifact Inventory

    \n"); + out.push_str( + "\n\ + \ + \ + \ + \n\n", + ); + for typ in compliance::EU_AI_ACT_TYPES { + let count = store.count_by_type(typ); + if count == 0 { + continue; + } + let ids: Vec = store + .by_type(typ) + .iter() + .map(|id| format!("{}", html_escape(id))) + .collect(); + writeln!( + out, + "", + html_escape(typ), + ids.join(", "), + ) + .unwrap(); + } + out.push_str("\n
    TypeCountArtifacts
    {}{count}{}
    \n"); + } + + out +} + /// Render the graph section content (no HTML wrapper). fn render_section_graph(store: &Store, link_graph: &LinkGraph) -> String { use etch::layout::{EdgeInfo, LayoutOptions, NodeInfo}; @@ -2352,6 +2489,11 @@ pub fn render_single_page( out.push_str(&render_section_stpa(store, graph)); out.push_str("\n
    \n"); + // EU AI Act section + out.push_str("
    \n"); + out.push_str(&render_section_eu_ai_act(store, schema)); + out.push_str("
    \n
    \n"); + // Matrix section out.push_str("
    \n"); out.push_str(&render_section_matrix(store, graph)); @@ -3042,10 +3184,21 @@ mod tests { assert!(html.contains("id=\"matrix\"")); assert!(html.contains("id=\"coverage\"")); assert!(html.contains("id=\"validation\"")); + assert!(html.contains("id=\"eu-ai-act\"")); assert!(html.contains("SingleTest")); assert!(html.contains("Generated by Rivet")); } + // rivet: verifies REQ-035 + #[test] + fn eu_ai_act_section_not_loaded() { + let (store, schema, _graph, _) = test_fixtures(); + // test_fixtures uses an empty-ish schema, so EU AI Act should NOT be loaded + let html = render_section_eu_ai_act(&store, &schema); + assert!(html.contains("EU AI Act Compliance")); + assert!(html.contains("not loaded")); + } + // rivet: verifies REQ-035 #[test] fn html_escape_works() { @@ -3432,6 +3585,8 @@ mod tests { assert!(nav.contains("graph.html"), "nav missing graph link"); assert!(nav.contains(">STPA<"), "nav missing STPA label"); assert!(nav.contains(">Graph<"), "nav missing Graph label"); + assert!(nav.contains("eu-ai-act.html"), "nav missing eu-ai-act link"); + assert!(nav.contains(">EU AI Act<"), "nav missing EU AI Act label"); } // rivet: verifies REQ-035 diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 3c33faa..ce408a6 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -29,6 +29,8 @@ pub mod query; pub mod reqif; pub mod results; pub mod schema; +pub mod sexpr; +pub mod sexpr_eval; pub mod snapshot; pub mod store; pub mod test_scanner; diff --git a/rivet-core/src/sexpr.rs b/rivet-core/src/sexpr.rs new file mode 100644 index 0000000..a40e83b --- /dev/null +++ b/rivet-core/src/sexpr.rs @@ -0,0 +1,579 @@ +//! Rowan-based lossless s-expression parser. +//! +//! Parses s-expressions used for rivet filter/constraint/query language. +//! Preserves all whitespace and comments for round-tripping and diagnostic +//! reporting with exact byte spans. +//! +//! Syntax: +//! expr = atom | list +//! list = '(' expr* ')' +//! atom = string | integer | float | bool | wildcard | symbol +//! string = '"' ... '"' (with \" escaping) +//! integer = [+-]? [0-9]+ +//! float = [+-]? [0-9]+ '.' [0-9]* +//! bool = 'true' | 'false' +//! wildcard = '_' +//! symbol = [a-zA-Z_!?] [a-zA-Z0-9_\-!?.*]* +//! comment = ';' ... newline + +use rowan::GreenNodeBuilder; + +// ── Syntax kinds ──────────────────────────────────────────────────────── + +/// Token and node kinds for the s-expression CST. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u16)] +pub enum SyntaxKind { + // ── Tokens ────────────────────────────────────────────────────── + /// `(` + LParen = 0, + /// `)` + RParen, + /// Spaces, tabs, or newlines. + Whitespace, + /// `; comment text` through end of line. + Comment, + /// `"double quoted"` including the quotes. + StringLit, + /// Integer literal: `42`, `-1`, `+0`. + IntLit, + /// Float literal: `3.14`, `-0.5`. + FloatLit, + /// `true` + BoolTrue, + /// `false` + BoolFalse, + /// `_` (wildcard / don't care). + Wildcard, + /// Unquoted symbol: `and`, `type`, `has-tag`, `linked-by`, etc. + Symbol, + + // ── Composite nodes ───────────────────────────────────────────── + /// Root of the tree (may contain multiple exprs). + Root, + /// A parenthesised list: `(form arg1 arg2 ...)`. + List, + /// A single atom (wraps one token). + Atom, + + // ── Error recovery ────────────────────────────────────────────── + /// A span the parser could not interpret. + Error, +} + +impl SyntaxKind { + /// True for tokens that carry no semantic content. + pub fn is_trivia(self) -> bool { + matches!(self, SyntaxKind::Whitespace | SyntaxKind::Comment) + } +} + +impl From for rowan::SyntaxKind { + fn from(kind: SyntaxKind) -> Self { + Self(kind as u16) + } +} + +// ── Language definition ───────────────────────────────────────────────── + +/// Rowan language tag for s-expressions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SExprLanguage {} + +impl rowan::Language for SExprLanguage { + type Kind = SyntaxKind; + + fn kind_from_raw(raw: rowan::SyntaxKind) -> SyntaxKind { + assert!(raw.0 <= SyntaxKind::Error as u16); + // SAFETY: SyntaxKind is repr(u16) with contiguous discriminants. + unsafe { std::mem::transmute(raw.0) } + } + + fn kind_to_raw(kind: SyntaxKind) -> rowan::SyntaxKind { + kind.into() + } +} + +/// Convenience alias. +pub type SyntaxNode = rowan::SyntaxNode; +/// Convenience alias. +pub type SyntaxToken = rowan::SyntaxToken; + +// ── Lexer ─────────────────────────────────────────────────────────────── + +/// A single token produced by the lexer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token<'src> { + pub kind: SyntaxKind, + pub text: &'src str, +} + +/// Lex an s-expression source string into tokens. +/// +/// Never fails — unknown bytes produce single-character Error tokens. +pub fn lex(source: &str) -> Vec> { + let mut tokens = Vec::new(); + let bytes = source.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + let start = i; + match bytes[i] { + b'(' => { + tokens.push(Token { + kind: SyntaxKind::LParen, + text: &source[i..i + 1], + }); + i += 1; + } + b')' => { + tokens.push(Token { + kind: SyntaxKind::RParen, + text: &source[i..i + 1], + }); + i += 1; + } + b';' => { + // Comment: consume to end of line. + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + tokens.push(Token { + kind: SyntaxKind::Comment, + text: &source[start..i], + }); + } + b' ' | b'\t' | b'\n' | b'\r' => { + while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r') { + i += 1; + } + tokens.push(Token { + kind: SyntaxKind::Whitespace, + text: &source[start..i], + }); + } + b'"' => { + // String literal with \" escaping. + i += 1; // skip opening quote + while i < bytes.len() { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + i += 2; // skip escaped character + } else if bytes[i] == b'"' { + i += 1; // skip closing quote + break; + } else { + i += 1; + } + } + tokens.push(Token { + kind: SyntaxKind::StringLit, + text: &source[start..i], + }); + } + b'0'..=b'9' | b'+' | b'-' + if { + // Distinguish sign from symbol: +/- is numeric only if followed by digit. + let is_sign = matches!(bytes[i], b'+' | b'-'); + !is_sign || (i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) + } => + { + let mut is_float = false; + if matches!(bytes[i], b'+' | b'-') { + i += 1; + } + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i < bytes.len() + && bytes[i] == b'.' + && i + 1 < bytes.len() + && bytes[i + 1].is_ascii_digit() + { + is_float = true; + i += 1; // skip dot + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + let kind = if is_float { + SyntaxKind::FloatLit + } else { + SyntaxKind::IntLit + }; + tokens.push(Token { + kind, + text: &source[start..i], + }); + } + _ if is_symbol_start(bytes[i]) => { + while i < bytes.len() && is_symbol_cont(bytes[i]) { + i += 1; + } + let text = &source[start..i]; + let kind = match text { + "true" => SyntaxKind::BoolTrue, + "false" => SyntaxKind::BoolFalse, + "_" => SyntaxKind::Wildcard, + _ => SyntaxKind::Symbol, + }; + tokens.push(Token { kind, text }); + } + _ => { + // Unknown byte — advance one character (UTF-8 aware). + let ch = source[i..].chars().next().unwrap(); + i += ch.len_utf8(); + tokens.push(Token { + kind: SyntaxKind::Error, + text: &source[start..i], + }); + } + } + } + + tokens +} + +fn is_symbol_start(b: u8) -> bool { + b.is_ascii_alphabetic() || matches!(b, b'_' | b'!' | b'?' | b'>' | b'<' | b'=') +} + +fn is_symbol_cont(b: u8) -> bool { + b.is_ascii_alphanumeric() + || matches!( + b, + b'_' | b'-' | b'!' | b'?' | b'.' | b'*' | b'>' | b'<' | b'=' + ) +} + +// ── Parser ────────────────────────────────────────────────────────────── + +/// Parse error with byte offset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError { + pub offset: usize, + pub message: String, +} + +/// Parse an s-expression source into a rowan green tree. +/// +/// Returns the green node and any parse errors. The tree always round-trips: +/// `SyntaxNode::new_root(parse(s).0).text() == s` for all inputs. +pub fn parse(source: &str) -> (rowan::GreenNode, Vec) { + let tokens = lex(source); + let mut p = Parser { + tokens, + pos: 0, + builder: GreenNodeBuilder::new(), + errors: Vec::new(), + source, + }; + + p.builder.start_node(SyntaxKind::Root.into()); + while !p.at_end() { + p.skip_trivia(); + if p.at_end() { + break; + } + p.parse_expr(); + } + // Consume any trailing trivia. + p.eat_remaining_trivia(); + p.builder.finish_node(); + + (p.builder.finish(), p.errors) +} + +struct Parser<'src> { + tokens: Vec>, + pos: usize, + builder: GreenNodeBuilder<'static>, + errors: Vec, + source: &'src str, +} + +impl<'src> Parser<'src> { + fn at_end(&self) -> bool { + self.pos >= self.tokens.len() + } + + fn current(&self) -> Option<&Token<'src>> { + self.tokens.get(self.pos) + } + + fn current_kind(&self) -> Option { + self.current().map(|t| t.kind) + } + + fn current_offset(&self) -> usize { + if let Some(t) = self.current() { + // Calculate byte offset from source start. + t.text.as_ptr() as usize - self.source.as_ptr() as usize + } else { + self.source.len() + } + } + + /// Advance past the current token, adding it to the green tree. + fn bump(&mut self) { + if let Some(t) = self.tokens.get(self.pos) { + self.builder.token(t.kind.into(), t.text); + self.pos += 1; + } + } + + /// Consume whitespace and comment tokens, adding them to the tree. + fn skip_trivia(&mut self) { + while let Some(kind) = self.current_kind() { + if kind.is_trivia() { + self.bump(); + } else { + break; + } + } + } + + /// Consume all remaining tokens (trivia at end of input). + fn eat_remaining_trivia(&mut self) { + while !self.at_end() { + self.bump(); + } + } + + fn parse_expr(&mut self) { + self.skip_trivia(); + match self.current_kind() { + Some(SyntaxKind::LParen) => self.parse_list(), + Some(SyntaxKind::RParen) => { + // Unexpected closing paren — wrap in error node. + let offset = self.current_offset(); + self.builder.start_node(SyntaxKind::Error.into()); + self.bump(); + self.builder.finish_node(); + self.errors.push(ParseError { + offset, + message: "unexpected ')'".into(), + }); + } + Some(kind) if !kind.is_trivia() => self.parse_atom(), + _ => {} + } + } + + fn parse_list(&mut self) { + self.builder.start_node(SyntaxKind::List.into()); + self.bump(); // consume '(' + + loop { + self.skip_trivia(); + match self.current_kind() { + Some(SyntaxKind::RParen) => { + self.bump(); // consume ')' + break; + } + None => { + // EOF without closing paren — error recovery. + let offset = self.current_offset(); + self.errors.push(ParseError { + offset, + message: "expected ')', found end of input".into(), + }); + break; + } + _ => self.parse_expr(), + } + } + + self.builder.finish_node(); + } + + fn parse_atom(&mut self) { + self.builder.start_node(SyntaxKind::Atom.into()); + self.bump(); + self.builder.finish_node(); + } +} + +// ── Utilities ─────────────────────────────────────────────────────────── + +/// Compute line-start byte offsets for mapping byte offsets to line:col. +pub fn line_starts(source: &str) -> Vec { + let mut starts = vec![0]; + for (i, b) in source.bytes().enumerate() { + if b == b'\n' { + starts.push(i + 1); + } + } + starts +} + +/// Convert a byte offset to (line, col), both 0-based. +pub fn offset_to_line_col(line_starts: &[usize], offset: usize) -> (usize, usize) { + let line = line_starts + .partition_point(|&s| s <= offset) + .saturating_sub(1); + let col = offset - line_starts[line]; + (line, col) +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_text(source: &str) -> String { + let (green, _) = parse(source); + SyntaxNode::new_root(green).text().to_string() + } + + #[test] + fn round_trip_simple() { + let cases = [ + "", + "hello", + "(and a b)", + "(= type \"requirement\")", + "(and (= status \"draft\") (has-tag \"stpa\"))", + " (or x y) ", + "; comment\n(a b)", + "(not (not x))", + "(implies a (and b c))", + "(links-count satisfies > 2)", + "42 -1 3.14 true false _", + ]; + for s in cases { + assert_eq!(parse_text(s), s, "round-trip failed for: {s:?}"); + } + } + + #[test] + fn error_recovery_missing_rparen() { + let source = "(and a b"; + let (green, errors) = parse(source); + let text = SyntaxNode::new_root(green).text().to_string(); + assert_eq!(text, source, "round-trip must hold even with errors"); + assert_eq!(errors.len(), 1); + assert!(errors[0].message.contains("expected ')'")); + } + + #[test] + fn error_recovery_unexpected_rparen() { + let source = ")extra"; + let (green, errors) = parse(source); + let text = SyntaxNode::new_root(green).text().to_string(); + assert_eq!(text, source); + assert_eq!(errors.len(), 1); + assert!(errors[0].message.contains("unexpected ')'")); + } + + #[test] + fn error_recovery_nested_missing() { + let source = "(a (b c)"; + let (green, errors) = parse(source); + let text = SyntaxNode::new_root(green).text().to_string(); + assert_eq!(text, source); + assert_eq!(errors.len(), 1); + } + + #[test] + fn lex_string_with_escapes() { + let tokens = lex(r#""hello \"world\"""#); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].kind, SyntaxKind::StringLit); + assert_eq!(tokens[0].text, r#""hello \"world\"""#); + } + + #[test] + fn lex_numbers() { + let tokens = lex("42 -3 +7 3.14 -0.5"); + let kinds: Vec<_> = tokens + .iter() + .filter(|t| !t.kind.is_trivia()) + .map(|t| t.kind) + .collect(); + assert_eq!( + kinds, + vec![ + SyntaxKind::IntLit, + SyntaxKind::IntLit, + SyntaxKind::IntLit, + SyntaxKind::FloatLit, + SyntaxKind::FloatLit, + ] + ); + } + + #[test] + fn lex_keywords() { + let tokens = lex("true false _ and or not implies"); + let kinds: Vec<_> = tokens + .iter() + .filter(|t| !t.kind.is_trivia()) + .map(|t| t.kind) + .collect(); + assert_eq!( + kinds, + vec![ + SyntaxKind::BoolTrue, + SyntaxKind::BoolFalse, + SyntaxKind::Wildcard, + SyntaxKind::Symbol, + SyntaxKind::Symbol, + SyntaxKind::Symbol, + SyntaxKind::Symbol, + ] + ); + } + + #[test] + fn lex_comment() { + let tokens = lex("; this is a comment\n(a b)"); + assert_eq!(tokens[0].kind, SyntaxKind::Comment); + assert_eq!(tokens[0].text, "; this is a comment"); + } + + #[test] + fn deeply_nested() { + let source = "((((((x))))))"; + let (green, errors) = parse(source); + assert!(errors.is_empty()); + let text = SyntaxNode::new_root(green).text().to_string(); + assert_eq!(text, source); + } + + #[test] + fn empty_list() { + let source = "()"; + let (green, errors) = parse(source); + assert!(errors.is_empty()); + assert_eq!(SyntaxNode::new_root(green).text().to_string(), source); + } + + #[test] + fn multiple_top_level_exprs() { + let source = "(a b) (c d)"; + let (green, errors) = parse(source); + assert!(errors.is_empty()); + assert_eq!(SyntaxNode::new_root(green).text().to_string(), source); + } + + #[test] + fn line_col_mapping() { + let source = "line1\nline2\nline3"; + let starts = line_starts(source); + assert_eq!(offset_to_line_col(&starts, 0), (0, 0)); + assert_eq!(offset_to_line_col(&starts, 6), (1, 0)); + assert_eq!(offset_to_line_col(&starts, 14), (2, 2)); + } + + #[test] + fn symbol_with_dots_and_stars() { + let tokens = lex("fields.priority links.satisfies.*"); + let kinds: Vec<_> = tokens + .iter() + .filter(|t| !t.kind.is_trivia()) + .map(|t| t.kind) + .collect(); + assert_eq!(kinds, vec![SyntaxKind::Symbol, SyntaxKind::Symbol]); + } +} diff --git a/rivet-core/src/sexpr_eval.rs b/rivet-core/src/sexpr_eval.rs new file mode 100644 index 0000000..86ca04f --- /dev/null +++ b/rivet-core/src/sexpr_eval.rs @@ -0,0 +1,929 @@ +//! S-expression evaluator for artifact filtering and constraints. +//! +//! Two-layer design: +//! 1. Typed AST (`Expr`) — pure data, no rowan dependency. +//! 2. Lowering from rowan CST (`sexpr::SyntaxNode`) → `Expr`. +//! +//! The evaluator operates on a single artifact + link graph context, +//! returning a boolean for predicate evaluation. + +use crate::links::LinkGraph; +use crate::model::Artifact; + +// ── Typed AST ─────────────────────────────────────────────────────────── + +/// A filter/constraint expression. +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + // ── Logical connectives ───────────────────────────────────────── + /// All sub-expressions must be true (variadic). + And(Vec), + /// At least one sub-expression must be true (variadic). + Or(Vec), + /// Negation. + Not(Box), + /// `(implies a b)` ≡ `(or (not a) b)`. + Implies(Box, Box), + /// `(excludes a b)` ≡ `(not (and a b))`. + Excludes(Box, Box), + + // ── Comparison predicates ─────────────────────────────────────── + /// `(= field "value")` + Eq(Accessor, Value), + /// `(!= field "value")` + Ne(Accessor, Value), + /// `(> field value)` — numeric comparison. + Gt(Accessor, Value), + /// `(< field value)` + Lt(Accessor, Value), + /// `(>= field value)` + Ge(Accessor, Value), + /// `(<= field value)` + Le(Accessor, Value), + + // ── Collection predicates ─────────────────────────────────────── + /// `(in "value" field)` — value is a member of a list field (e.g., tags). + In(Value, Accessor), + /// `(has-tag "stpa")` — shorthand for `(in "stpa" tags)`. + HasTag(Value), + /// `(has-field "asil")` — field exists and is non-null. + HasField(Value), + /// `(matches field "regex")` — regex match on string field. + Matches(Accessor, Value), + /// `(contains field "substring")` — substring match. + Contains(Accessor, Value), + + // ── Link predicates ───────────────────────────────────────────── + /// `(linked-by "satisfies" _)` — has outgoing link of type. + LinkedBy(Value, Value), + /// `(linked-from "implements" _)` — has incoming link of type. + LinkedFrom(Value, Value), + /// `(linked-to "SPEC-021")` — has a link targeting specific ID. + LinkedTo(Value), + /// `(links-count "satisfies" > 2)` — cardinality check. + LinksCount(Value, CompOp, Value), + + // ── Literal ───────────────────────────────────────────────────── + /// Constant boolean (useful after constant folding). + BoolLit(bool), +} + +/// How to access a field on an artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Accessor { + /// Named field: "type", "status", "id", "title", "description", + /// or any key in the `fields` BTreeMap. + Field(String), +} + +/// A literal value in an expression. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Str(String), + Int(i64), + Float(f64), + Bool(bool), + /// `_` — matches anything (used in link predicates). + Wildcard, +} + +/// Comparison operator for `LinksCount`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompOp { + Gt, + Lt, + Ge, + Le, + Eq, + Ne, +} + +// ── Evaluation context ────────────────────────────────────────────────── + +/// Context needed to check a predicate against one artifact. +pub struct EvalContext<'a> { + pub artifact: &'a Artifact, + pub graph: &'a LinkGraph, +} + +// ── Predicate checker ─────────────────────────────────────────────────── + +/// Check whether an expression holds for a single artifact. +/// +/// This is a pure function: it pattern-matches the AST and resolves +/// field accesses against the artifact struct and link graph. +/// No arbitrary code execution — only structured predicate evaluation. +pub fn check(expr: &Expr, ctx: &EvalContext) -> bool { + match expr { + // Logical connectives + Expr::And(exprs) => exprs.iter().all(|e| check(e, ctx)), + Expr::Or(exprs) => exprs.iter().any(|e| check(e, ctx)), + Expr::Not(e) => !check(e, ctx), + Expr::Implies(a, b) => !check(a, ctx) || check(b, ctx), + Expr::Excludes(a, b) => !(check(a, ctx) && check(b, ctx)), + + // Comparison predicates + Expr::Eq(acc, val) => resolve_str(acc, ctx.artifact) == value_to_str(val), + Expr::Ne(acc, val) => resolve_str(acc, ctx.artifact) != value_to_str(val), + Expr::Gt(acc, val) => compare_numeric(acc, val, ctx.artifact, |a, b| a > b), + Expr::Lt(acc, val) => compare_numeric(acc, val, ctx.artifact, |a, b| a < b), + Expr::Ge(acc, val) => compare_numeric(acc, val, ctx.artifact, |a, b| a >= b), + Expr::Le(acc, val) => compare_numeric(acc, val, ctx.artifact, |a, b| a <= b), + + // Collection predicates + Expr::In(val, acc) => { + let needle = value_to_str(val); + resolve_list(acc, ctx.artifact).contains(&needle) + } + Expr::HasTag(val) => { + let tag = value_to_str(val); + ctx.artifact.tags.contains(&tag) + } + Expr::HasField(val) => { + let name = value_to_str(val); + resolve_field_exists(&name, ctx.artifact) + } + Expr::Matches(acc, val) => { + let text = resolve_str(acc, ctx.artifact); + let pattern = value_to_str(val); + regex::Regex::new(&pattern) + .map(|re| re.is_match(&text)) + .unwrap_or(false) + } + Expr::Contains(acc, val) => { + let text = resolve_str(acc, ctx.artifact); + let needle = value_to_str(val); + text.contains(&needle) + } + + // Link predicates + Expr::LinkedBy(link_type, target) => { + let lt = value_to_str(link_type); + let tgt = value_to_str(target); + ctx.artifact.links.iter().any(|l| { + l.link_type == lt && (matches!(target, Value::Wildcard) || l.target == tgt) + }) + } + Expr::LinkedFrom(link_type, _source) => { + let lt = value_to_str(link_type); + let backlinks = ctx.graph.backlinks_to(&ctx.artifact.id); + backlinks.iter().any(|bl| bl.link_type == lt) + } + Expr::LinkedTo(target_id) => { + let tgt = value_to_str(target_id); + ctx.artifact.links.iter().any(|l| l.target == tgt) + } + Expr::LinksCount(link_type, op, val) => { + let lt = value_to_str(link_type); + let count = ctx + .artifact + .links + .iter() + .filter(|l| l.link_type == lt) + .count() as i64; + let threshold = value_to_i64(val); + match op { + CompOp::Gt => count > threshold, + CompOp::Lt => count < threshold, + CompOp::Ge => count >= threshold, + CompOp::Le => count <= threshold, + CompOp::Eq => count == threshold, + CompOp::Ne => count != threshold, + } + } + + Expr::BoolLit(b) => *b, + } +} + +// ── Field resolution ──────────────────────────────────────────────────── + +fn resolve_str(acc: &Accessor, artifact: &Artifact) -> String { + match acc { + Accessor::Field(name) => match name.as_str() { + "id" => artifact.id.clone(), + "type" => artifact.artifact_type.clone(), + "title" => artifact.title.clone(), + "description" => artifact.description.clone().unwrap_or_default(), + "status" => artifact.status.clone().unwrap_or_default(), + other => artifact + .fields + .get(other) + .map(yaml_value_to_string) + .unwrap_or_default(), + }, + } +} + +fn resolve_list(acc: &Accessor, artifact: &Artifact) -> Vec { + match acc { + Accessor::Field(name) => match name.as_str() { + "tags" => artifact.tags.clone(), + other => artifact + .fields + .get(other) + .and_then(|v| match v { + serde_yaml::Value::Sequence(seq) => { + Some(seq.iter().map(yaml_value_to_string).collect()) + } + _ => None, + }) + .unwrap_or_default(), + }, + } +} + +fn resolve_field_exists(name: &str, artifact: &Artifact) -> bool { + match name { + "id" | "type" | "title" => true, + "description" => artifact.description.is_some(), + "status" => artifact.status.is_some(), + "tags" => !artifact.tags.is_empty(), + other => artifact.fields.contains_key(other), + } +} + +fn yaml_value_to_string(v: &serde_yaml::Value) -> String { + match v { + serde_yaml::Value::String(s) => s.clone(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Null => String::new(), + _ => format!("{v:?}"), + } +} + +fn value_to_str(val: &Value) -> String { + match val { + Value::Str(s) => s.clone(), + Value::Int(i) => i.to_string(), + Value::Float(f) => f.to_string(), + Value::Bool(b) => b.to_string(), + Value::Wildcard => "_".into(), + } +} + +fn value_to_i64(val: &Value) -> i64 { + match val { + Value::Int(i) => *i, + Value::Float(f) => *f as i64, + Value::Str(s) => s.parse().unwrap_or(0), + _ => 0, + } +} + +fn compare_numeric( + acc: &Accessor, + val: &Value, + artifact: &Artifact, + cmp: fn(f64, f64) -> bool, +) -> bool { + let field_str = resolve_str(acc, artifact); + let field_num: f64 = field_str.parse().unwrap_or(f64::NAN); + let threshold = match val { + Value::Int(i) => *i as f64, + Value::Float(f) => *f, + Value::Str(s) => s.parse().unwrap_or(f64::NAN), + _ => f64::NAN, + }; + cmp(field_num, threshold) +} + +// ── CST → AST Lowering ───────────────────────────────────────────────── + +/// Error from lowering a CST to a typed AST. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LowerError { + pub offset: usize, + pub message: String, +} + +/// Error from parsing + lowering a filter expression. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilterError { + pub offset: usize, + pub message: String, +} + +impl std::fmt::Display for FilterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "offset {}: {}", self.offset, self.message) + } +} + +/// Parse a filter string into a typed expression. +/// +/// Combines parsing (CST) and lowering (AST) in one step. +pub fn parse_filter(source: &str) -> Result> { + use crate::sexpr; + + let (green, parse_errors) = sexpr::parse(source); + if !parse_errors.is_empty() { + return Err(parse_errors + .into_iter() + .map(|e| FilterError { + offset: e.offset, + message: e.message, + }) + .collect()); + } + + let root = sexpr::SyntaxNode::new_root(green); + lower(&root).map_err(|errs| { + errs.into_iter() + .map(|e| FilterError { + offset: e.offset, + message: e.message, + }) + .collect() + }) +} + +/// Convenience: parse a filter and check it against one artifact. +pub fn matches_filter(expr: &Expr, artifact: &Artifact, graph: &LinkGraph) -> bool { + let ctx = EvalContext { artifact, graph }; + check(expr, &ctx) +} + +/// Lower a rowan s-expression CST root into a typed `Expr`. +pub fn lower(root: &crate::sexpr::SyntaxNode) -> Result> { + use crate::sexpr::SyntaxKind as SK; + + let mut errors = Vec::new(); + let mut exprs = Vec::new(); + + for child in root.children() { + match child.kind() { + SK::List => { + if let Some(e) = lower_list(&child, &mut errors) { + exprs.push(e); + } + } + SK::Atom => match lower_atom_expr(&child) { + Some(e) => exprs.push(e), + None => errors.push(LowerError { + offset: child.text_range().start().into(), + message: "unexpected atom at top level".into(), + }), + }, + SK::Error => { + errors.push(LowerError { + offset: child.text_range().start().into(), + message: "syntax error".into(), + }); + } + _ => {} // trivia + } + } + + if !errors.is_empty() { + return Err(errors); + } + + match exprs.len() { + 0 => Ok(Expr::BoolLit(true)), // empty filter matches everything + 1 => Ok(exprs.into_iter().next().unwrap()), + _ => Ok(Expr::And(exprs)), // multiple top-level = implicit and + } +} + +fn lower_list(node: &crate::sexpr::SyntaxNode, errors: &mut Vec) -> Option { + let children: Vec<_> = node.children().collect(); + if children.is_empty() { + return Some(Expr::BoolLit(true)); + } + + let head = &children[0]; + let form_name = extract_symbol(head)?; + let args: Vec<_> = children[1..].to_vec(); + let offset: usize = node.text_range().start().into(); + + match form_name.as_str() { + "and" => { + let sub: Vec = args.iter().filter_map(|a| lower_child(a, errors)).collect(); + Some(Expr::And(sub)) + } + "or" => { + let sub: Vec = args.iter().filter_map(|a| lower_child(a, errors)).collect(); + Some(Expr::Or(sub)) + } + "not" => { + if args.len() != 1 { + errors.push(LowerError { + offset, + message: "'not' requires exactly 1 argument".into(), + }); + return None; + } + lower_child(&args[0], errors).map(|e| Expr::Not(Box::new(e))) + } + "implies" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'implies' requires exactly 2 arguments".into(), + }); + return None; + } + let a = lower_child(&args[0], errors)?; + let b = lower_child(&args[1], errors)?; + Some(Expr::Implies(Box::new(a), Box::new(b))) + } + "excludes" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'excludes' requires exactly 2 arguments".into(), + }); + return None; + } + let a = lower_child(&args[0], errors)?; + let b = lower_child(&args[1], errors)?; + Some(Expr::Excludes(Box::new(a), Box::new(b))) + } + + "=" | "!=" | ">" | "<" | ">=" | "<=" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: format!("'{form_name}' requires exactly 2 arguments"), + }); + return None; + } + let acc = extract_accessor(&args[0])?; + let val = extract_value(&args[1])?; + Some(match form_name.as_str() { + "=" => Expr::Eq(acc, val), + "!=" => Expr::Ne(acc, val), + ">" => Expr::Gt(acc, val), + "<" => Expr::Lt(acc, val), + ">=" => Expr::Ge(acc, val), + "<=" => Expr::Le(acc, val), + _ => unreachable!(), + }) + } + + "in" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'in' requires exactly 2 arguments".into(), + }); + return None; + } + let val = extract_value(&args[0])?; + let acc = extract_accessor(&args[1])?; + Some(Expr::In(val, acc)) + } + "has-tag" => { + if args.len() != 1 { + errors.push(LowerError { + offset, + message: "'has-tag' requires exactly 1 argument".into(), + }); + return None; + } + let val = extract_value(&args[0])?; + Some(Expr::HasTag(val)) + } + "has-field" => { + if args.len() != 1 { + errors.push(LowerError { + offset, + message: "'has-field' requires exactly 1 argument".into(), + }); + return None; + } + let val = extract_value(&args[0])?; + Some(Expr::HasField(val)) + } + "matches" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'matches' requires exactly 2 arguments".into(), + }); + return None; + } + let acc = extract_accessor(&args[0])?; + let val = extract_value(&args[1])?; + Some(Expr::Matches(acc, val)) + } + "contains" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'contains' requires exactly 2 arguments".into(), + }); + return None; + } + let acc = extract_accessor(&args[0])?; + let val = extract_value(&args[1])?; + Some(Expr::Contains(acc, val)) + } + + "linked-by" => { + if args.is_empty() || args.len() > 2 { + errors.push(LowerError { + offset, + message: "'linked-by' requires 1-2 arguments".into(), + }); + return None; + } + let lt = extract_value(&args[0])?; + let tgt = if args.len() == 2 { + extract_value(&args[1])? + } else { + Value::Wildcard + }; + Some(Expr::LinkedBy(lt, tgt)) + } + "linked-from" => { + if args.is_empty() || args.len() > 2 { + errors.push(LowerError { + offset, + message: "'linked-from' requires 1-2 arguments".into(), + }); + return None; + } + let lt = extract_value(&args[0])?; + let src = if args.len() == 2 { + extract_value(&args[1])? + } else { + Value::Wildcard + }; + Some(Expr::LinkedFrom(lt, src)) + } + "linked-to" => { + if args.len() != 1 { + errors.push(LowerError { + offset, + message: "'linked-to' requires exactly 1 argument".into(), + }); + return None; + } + let val = extract_value(&args[0])?; + Some(Expr::LinkedTo(val)) + } + "links-count" => { + if args.len() != 3 { + errors.push(LowerError { + offset, + message: "'links-count' requires exactly 3 arguments (type op value)".into(), + }); + return None; + } + let lt = extract_value(&args[0])?; + let op_str = extract_symbol(&args[1]).unwrap_or_default(); + let op = match op_str.as_str() { + ">" => CompOp::Gt, + "<" => CompOp::Lt, + ">=" => CompOp::Ge, + "<=" => CompOp::Le, + "=" => CompOp::Eq, + "!=" => CompOp::Ne, + _ => { + errors.push(LowerError { + offset, + message: format!("invalid operator '{op_str}' in links-count"), + }); + return None; + } + }; + let val = extract_value(&args[2])?; + Some(Expr::LinksCount(lt, op, val)) + } + + unknown => { + errors.push(LowerError { + offset, + message: format!("unknown form '{unknown}'"), + }); + None + } + } +} + +fn lower_child(node: &crate::sexpr::SyntaxNode, errors: &mut Vec) -> Option { + use crate::sexpr::SyntaxKind as SK; + + match node.kind() { + SK::List => lower_list(node, errors), + SK::Atom => lower_atom_expr(node).or_else(|| { + errors.push(LowerError { + offset: node.text_range().start().into(), + message: format!( + "bare symbol '{}' in expression position; did you mean a list?", + node.text() + ), + }); + None + }), + _ => None, + } +} + +fn lower_atom_expr(node: &crate::sexpr::SyntaxNode) -> Option { + use crate::sexpr::SyntaxKind as SK; + + let token = node.first_token()?; + match token.kind() { + SK::BoolTrue => Some(Expr::BoolLit(true)), + SK::BoolFalse => Some(Expr::BoolLit(false)), + _ => None, + } +} + +fn extract_symbol(node: &crate::sexpr::SyntaxNode) -> Option { + use crate::sexpr::SyntaxKind as SK; + + if node.kind() == SK::Atom { + let token = node.first_token()?; + let kind = token.kind(); + if kind == SK::Symbol { + return Some(token.text().to_string()); + } + } + None +} + +fn extract_accessor(node: &crate::sexpr::SyntaxNode) -> Option { + let name = extract_symbol(node)?; + Some(Accessor::Field(name)) +} + +fn extract_value(node: &crate::sexpr::SyntaxNode) -> Option { + use crate::sexpr::SyntaxKind as SK; + + if node.kind() != SK::Atom { + return None; + } + let token = node.first_token()?; + let kind = token.kind(); + let text = token.text(); + + match kind { + SK::StringLit => { + let inner = &text[1..text.len() - 1]; + let unescaped = inner.replace("\\\"", "\"").replace("\\\\", "\\"); + Some(Value::Str(unescaped)) + } + SK::IntLit => text.parse::().ok().map(Value::Int), + SK::FloatLit => text.parse::().ok().map(Value::Float), + SK::BoolTrue => Some(Value::Bool(true)), + SK::BoolFalse => Some(Value::Bool(false)), + SK::Wildcard => Some(Value::Wildcard), + SK::Symbol => Some(Value::Str(text.to_string())), + _ => None, + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::links::LinkGraph; + use crate::model::{Artifact, Link}; + use std::collections::BTreeMap; + use std::path::PathBuf; + + fn test_artifact() -> Artifact { + Artifact { + id: "REQ-001".into(), + artifact_type: "requirement".into(), + title: "Test requirement".into(), + description: Some("A test requirement for STPA".into()), + status: Some("approved".into()), + tags: vec!["stpa".into(), "safety".into(), "eu".into()], + links: vec![ + Link { + link_type: "satisfies".into(), + target: "SC-1".into(), + }, + Link { + link_type: "satisfies".into(), + target: "SC-3".into(), + }, + Link { + link_type: "implements".into(), + target: "DD-001".into(), + }, + ], + fields: { + let mut m = BTreeMap::new(); + m.insert("priority".into(), serde_yaml::Value::String("must".into())); + m.insert( + "category".into(), + serde_yaml::Value::String("functional".into()), + ); + m.insert( + "baseline".into(), + serde_yaml::Value::String("v0.1.0".into()), + ); + m + }, + provenance: None, + source_file: Some(PathBuf::from("artifacts/requirements.yaml")), + } + } + + fn empty_graph() -> LinkGraph { + use crate::schema::Schema; + use crate::store::Store; + let store = Store::default(); + let schema = Schema::merge(&[]); + LinkGraph::build(&store, &schema) + } + + fn run(expr: &Expr, artifact: &Artifact) -> bool { + let graph = empty_graph(); + let ctx = EvalContext { + artifact, + graph: &graph, + }; + check(expr, &ctx) + } + + #[test] + fn filter_type_eq() { + let expr = parse_filter(r#"(= type "requirement")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_type_ne() { + let expr = parse_filter(r#"(= type "feature")"#).unwrap(); + assert!(!run(&expr, &test_artifact())); + } + + #[test] + fn filter_status() { + let expr = parse_filter(r#"(= status "approved")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_has_tag() { + let expr = parse_filter(r#"(has-tag "stpa")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + let expr = parse_filter(r#"(has-tag "automotive")"#).unwrap(); + assert!(!run(&expr, &test_artifact())); + } + + #[test] + fn filter_and() { + let expr = parse_filter(r#"(and (= type "requirement") (has-tag "eu"))"#).unwrap(); + assert!(run(&expr, &test_artifact())); + let expr = parse_filter(r#"(and (= type "requirement") (has-tag "missing"))"#).unwrap(); + assert!(!run(&expr, &test_artifact())); + } + + #[test] + fn filter_or() { + let expr = parse_filter(r#"(or (= type "feature") (has-tag "stpa"))"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_not() { + let expr = parse_filter(r#"(not (= type "feature"))"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_implies() { + let expr = parse_filter(r#"(implies (= type "requirement") (has-tag "stpa"))"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_has_field() { + let expr = parse_filter(r#"(has-field "priority")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + let expr = parse_filter(r#"(has-field "nonexistent")"#).unwrap(); + assert!(!run(&expr, &test_artifact())); + } + + #[test] + fn filter_in() { + let expr = parse_filter(r#"(in "safety" tags)"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_contains() { + let expr = parse_filter(r#"(contains title "requirement")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_matches_regex() { + let expr = parse_filter(r#"(matches id "^REQ-\\d+")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_linked_by() { + let expr = parse_filter(r#"(linked-by "satisfies" _)"#).unwrap(); + assert!(run(&expr, &test_artifact())); + let expr = parse_filter(r#"(linked-by "verifies" _)"#).unwrap(); + assert!(!run(&expr, &test_artifact())); + } + + #[test] + fn filter_linked_to() { + let expr = parse_filter(r#"(linked-to "SC-1")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_links_count() { + let expr = parse_filter(r#"(links-count "satisfies" > 1)"#).unwrap(); + assert!(run(&expr, &test_artifact())); + let expr = parse_filter(r#"(links-count "satisfies" = 2)"#).unwrap(); + assert!(run(&expr, &test_artifact())); + let expr = parse_filter(r#"(links-count "satisfies" > 5)"#).unwrap(); + assert!(!run(&expr, &test_artifact())); + } + + #[test] + fn filter_field_access() { + let expr = parse_filter(r#"(= priority "must")"#).unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn filter_nested() { + let expr = parse_filter( + r#"(and (= type "requirement") (or (has-tag "stpa") (has-tag "automotive")) (not (= status "draft")))"#, + ) + .unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn empty_filter_matches_all() { + let expr = parse_filter("").unwrap(); + assert!(run(&expr, &test_artifact())); + } + + #[test] + fn parse_error_reported() { + let result = parse_filter("(and a"); + assert!(result.is_err()); + } + + // ── Logical equivalence unit tests ────────────────────────────── + + #[test] + fn de_morgan_and() { + let a = test_artifact(); + let p = Expr::HasTag(Value::Str("stpa".into())); + let q = Expr::HasTag(Value::Str("eu".into())); + let lhs = Expr::Not(Box::new(Expr::And(vec![p.clone(), q.clone()]))); + let rhs = Expr::Or(vec![Expr::Not(Box::new(p)), Expr::Not(Box::new(q))]); + assert_eq!(run(&lhs, &a), run(&rhs, &a)); + } + + #[test] + fn de_morgan_or() { + let a = test_artifact(); + let p = Expr::HasTag(Value::Str("stpa".into())); + let q = Expr::HasTag(Value::Str("missing".into())); + let lhs = Expr::Not(Box::new(Expr::Or(vec![p.clone(), q.clone()]))); + let rhs = Expr::And(vec![Expr::Not(Box::new(p)), Expr::Not(Box::new(q))]); + assert_eq!(run(&lhs, &a), run(&rhs, &a)); + } + + #[test] + fn double_negation() { + let a = test_artifact(); + let p = Expr::HasTag(Value::Str("stpa".into())); + let double_neg = Expr::Not(Box::new(Expr::Not(Box::new(p.clone())))); + assert_eq!(run(&double_neg, &a), run(&p, &a)); + } + + #[test] + fn implies_equivalence() { + let a = test_artifact(); + let p = Expr::Eq( + Accessor::Field("type".into()), + Value::Str("requirement".into()), + ); + let q = Expr::HasTag(Value::Str("stpa".into())); + let lhs = Expr::Implies(Box::new(p.clone()), Box::new(q.clone())); + let rhs = Expr::Or(vec![Expr::Not(Box::new(p)), q]); + assert_eq!(run(&lhs, &a), run(&rhs, &a)); + } + + #[test] + fn excludes_equivalence() { + let a = test_artifact(); + let p = Expr::HasTag(Value::Str("stpa".into())); + let q = Expr::HasTag(Value::Str("missing".into())); + let lhs = Expr::Excludes(Box::new(p.clone()), Box::new(q.clone())); + let rhs = Expr::Not(Box::new(Expr::And(vec![p, q]))); + assert_eq!(run(&lhs, &a), run(&rhs, &a)); + } +} diff --git a/rivet-core/src/store.rs b/rivet-core/src/store.rs index 934df71..320e844 100644 --- a/rivet-core/src/store.rs +++ b/rivet-core/src/store.rs @@ -42,8 +42,14 @@ impl Store { // Remove from old type index if updating let is_update = if let Some(old) = self.artifacts.get(&id) { if old.artifact_type != artifact_type { - if let Some(ids) = self.by_type.get_mut(&old.artifact_type) { + let old_type = old.artifact_type.clone(); + if let Some(ids) = self.by_type.get_mut(&old_type) { ids.retain(|i| i != &id); + // Remove the type key entirely if no artifacts remain, + // so types() never reports a phantom zero-count type. + if ids.is_empty() { + self.by_type.remove(&old_type); + } } // Type changed: not yet in the new type's list false @@ -106,6 +112,11 @@ impl Store { .unwrap_or(0) } + /// Sum of per-type counts. Should always equal `len()`. + pub fn types_total(&self) -> usize { + self.by_type.values().map(|v| v.len()).sum() + } + /// Check whether an artifact ID exists in the store. #[inline] pub fn contains(&self, id: &str) -> bool { @@ -272,4 +283,33 @@ mod tests { assert_eq!(store.by_type("req").len(), 0); assert_eq!(store.by_type("feat").len(), 1); } + + #[test] + fn types_total_equals_len() { + let mut store = Store::new(); + store.upsert(minimal_artifact("A-1", "req")); + store.upsert(minimal_artifact("A-2", "feat")); + store.upsert(minimal_artifact("A-3", "req")); + assert_eq!(store.types_total(), store.len()); + } + + #[test] + fn types_total_after_type_change() { + let mut store = Store::new(); + store.upsert(minimal_artifact("A-1", "req")); + store.upsert(minimal_artifact("A-2", "req")); + // Change A-1's type from req to feat + store.upsert(minimal_artifact("A-1", "feat")); + + assert_eq!(store.len(), 2); + assert_eq!(store.types_total(), 2); + // The old "req" type should not appear as a phantom with 0 count + let type_names: Vec<&str> = store.types().collect(); + for t in &type_names { + assert!( + store.count_by_type(t) > 0, + "type '{t}' has 0 count but still listed in types()" + ); + } + } } diff --git a/rivet-core/tests/externals_sync.rs b/rivet-core/tests/externals_sync.rs new file mode 100644 index 0000000..f798ae4 --- /dev/null +++ b/rivet-core/tests/externals_sync.rs @@ -0,0 +1,219 @@ +//! Cross-repo artifact sync integration tests. +//! +//! Tests the full pipeline: syncing an external project from a local path, +//! loading its artifacts, validating cross-repo links, and computing backlinks. +//! +//! Uses the test fixture at `tests/fixtures/spar-external/` which simulates +//! what a spar rivet project would look like. + +use std::collections::{BTreeMap, HashSet}; + +use rivet_core::externals::{ + ResolvedExternal, load_all_externals, load_external_project, sync_external, validate_refs, +}; +use rivet_core::model::ExternalProject; +use serial_test::serial; + +/// Path to the spar external fixture relative to the workspace root. +fn spar_fixture_dir() -> std::path::PathBuf { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir.join("../tests/fixtures/spar-external") +} + +// rivet: verifies REQ-020 +#[test] +#[serial] +fn sync_spar_external_via_local_path() { + let fixture = spar_fixture_dir(); + assert!( + fixture.join("rivet.yaml").exists(), + "spar-external fixture must have rivet.yaml at {}", + fixture.display() + ); + + let dir = tempfile::tempdir().unwrap(); + let ext = ExternalProject { + git: Some("https://github.com/pulseengine/spar.git".into()), + path: Some(fixture.to_str().unwrap().into()), + git_ref: Some("84a7363".into()), + prefix: "spar".into(), + }; + + let cache_dir = dir.path().join(".rivet/repos"); + let result = sync_external(&ext, &cache_dir, dir.path(), true); + assert!(result.is_ok(), "sync_external failed: {:?}", result.err()); + + // The cache should contain a symlink to the fixture + let cached = cache_dir.join("spar"); + assert!(cached.exists(), "cached spar dir must exist after sync"); +} + +// rivet: verifies REQ-020 +#[test] +#[serial] +fn load_spar_external_artifacts() { + let fixture = spar_fixture_dir(); + let artifacts = load_external_project(&fixture).unwrap(); + + // The fixture has 4 artifacts: 3 aadl-component + 1 requirement + assert!( + artifacts.len() >= 4, + "expected at least 4 artifacts from spar fixture, got {}", + artifacts.len() + ); + + let ids: Vec<&str> = artifacts.iter().map(|a| a.id.as_str()).collect(); + assert!(ids.contains(&"SPAR-SYS-001"), "missing SPAR-SYS-001"); + assert!(ids.contains(&"SPAR-PROC-001"), "missing SPAR-PROC-001"); + assert!(ids.contains(&"SPAR-THR-001"), "missing SPAR-THR-001"); + assert!(ids.contains(&"SPAR-REQ-001"), "missing SPAR-REQ-001"); + + // Verify types are correct + let sys = artifacts.iter().find(|a| a.id == "SPAR-SYS-001").unwrap(); + assert_eq!(sys.artifact_type, "aadl-component"); + + let req = artifacts.iter().find(|a| a.id == "SPAR-REQ-001").unwrap(); + assert_eq!(req.artifact_type, "requirement"); +} + +// rivet: verifies REQ-020 +#[test] +#[serial] +fn cross_repo_link_resolution_with_spar() { + let fixture = spar_fixture_dir(); + let spar_artifacts = load_external_project(&fixture).unwrap(); + + // Build external ID sets + let spar_ids: HashSet = spar_artifacts.iter().map(|a| a.id.clone()).collect(); + let mut external_ids: BTreeMap> = BTreeMap::new(); + external_ids.insert("spar".into(), spar_ids); + + // Simulate local artifacts that reference spar artifacts + let local_ids: HashSet = ["REQ-001", "FEAT-001"] + .iter() + .map(|s| s.to_string()) + .collect(); + + // Valid cross-repo references + let refs = vec![ + "REQ-001", // local + "spar:SPAR-SYS-001", // valid external + "spar:SPAR-REQ-001", // valid external + ]; + let broken = validate_refs(&refs, &local_ids, &external_ids); + assert!( + broken.is_empty(), + "expected no broken refs for valid links, got: {:?}", + broken + ); + + // Broken cross-repo references + let bad_refs = vec![ + "spar:NONEXISTENT", // valid prefix, missing ID + "unknown:REQ-001", // unknown prefix + ]; + let broken2 = validate_refs(&bad_refs, &local_ids, &external_ids); + assert_eq!( + broken2.len(), + 2, + "expected 2 broken refs, got: {:?}", + broken2 + ); +} + +// rivet: verifies REQ-020 +#[test] +#[serial] +fn load_all_externals_with_spar() { + let fixture = spar_fixture_dir(); + + let dir = tempfile::tempdir().unwrap(); + let mut externals = BTreeMap::new(); + externals.insert( + "spar".into(), + ExternalProject { + git: None, + path: Some(fixture.to_str().unwrap().into()), + git_ref: None, + prefix: "spar".into(), + }, + ); + + // Sync first so that the cache is populated + rivet_core::externals::sync_all(&externals, dir.path(), true).unwrap(); + + // Then load all externals + let resolved = load_all_externals(&externals, dir.path()).unwrap(); + assert_eq!(resolved.len(), 1, "expected 1 external"); + assert_eq!(resolved[0].prefix, "spar"); + assert!( + resolved[0].artifacts.len() >= 4, + "expected at least 4 spar artifacts, got {}", + resolved[0].artifacts.len() + ); +} + +// rivet: verifies REQ-020 +#[test] +#[serial] +fn backlinks_from_spar_to_local() { + use rivet_core::externals::compute_backlinks; + use rivet_core::model::{Artifact, Link}; + + // Create a spar artifact that links to a local artifact + let spar_artifact = Artifact { + id: "SPAR-LINK-001".into(), + artifact_type: "aadl-component".into(), + title: "Component linking to local req".into(), + description: None, + status: None, + tags: vec![], + links: vec![Link { + link_type: "allocated-from".into(), + target: "REQ-001".into(), + }], + fields: BTreeMap::new(), + provenance: None, + source_file: None, + }; + + let resolved = vec![ResolvedExternal { + prefix: "spar".into(), + project_dir: spar_fixture_dir(), + artifacts: vec![spar_artifact], + }]; + + let mut local_ids = HashSet::new(); + local_ids.insert("REQ-001".into()); + + let backlinks = compute_backlinks(&resolved, &local_ids); + assert_eq!(backlinks.len(), 1, "expected 1 backlink"); + assert_eq!(backlinks[0].source_prefix, "spar"); + assert_eq!(backlinks[0].source_id, "SPAR-LINK-001"); + assert_eq!(backlinks[0].target, "REQ-001"); +} + +// rivet: verifies REQ-020 +#[test] +#[serial] +fn dogfood_rivet_yaml_with_spar_external() { + // Load the actual project's rivet.yaml and verify it parses with the spar external + let project_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); + let config_path = project_root.join("rivet.yaml"); + let config = rivet_core::load_project_config(&config_path).unwrap(); + + let externals = config + .externals + .as_ref() + .expect("externals must be configured"); + assert!(externals.contains_key("spar"), "spar must be in externals"); + + let spar = &externals["spar"]; + assert_eq!(spar.prefix, "spar"); + assert_eq!( + spar.git.as_deref(), + Some("https://github.com/pulseengine/spar.git") + ); + assert_eq!(spar.git_ref.as_deref(), Some("84a7363")); + assert_eq!(spar.path.as_deref(), Some("tests/fixtures/spar-external")); +} diff --git a/rivet-core/tests/proptest_sexpr.rs b/rivet-core/tests/proptest_sexpr.rs new file mode 100644 index 0000000..e1f6d4b --- /dev/null +++ b/rivet-core/tests/proptest_sexpr.rs @@ -0,0 +1,283 @@ +//! Property-based tests for the s-expression evaluator. +//! +//! Verifies logical equivalences mandated by CC-VAR-004: +//! - De Morgan's laws +//! - Double negation elimination +//! - Commutativity of and/or +//! - Implies expansion +//! - Excludes expansion +//! +//! These properties must hold for ALL artifacts and ALL predicate +//! combinations — not just hand-picked test cases. + +use std::collections::BTreeMap; + +use proptest::prelude::*; + +use rivet_core::links::LinkGraph; +use rivet_core::model::{Artifact, Link}; +use rivet_core::schema::Schema; +use rivet_core::sexpr_eval::{self, Accessor, EvalContext, Expr, Value}; +use rivet_core::store::Store; + +// ── Strategies ────────────────────────────────────────────────────────── + +/// Generate a random artifact with varied fields, tags, and links. +fn arb_artifact() -> impl Strategy { + ( + "[A-Z]{2,4}-[0-9]{1,3}", // id + prop::sample::select(vec![ + "requirement", + "feature", + "design-decision", + "test-case", + "loss", + "hazard", + "system-constraint", + ]), // type + "[a-z ]{5,30}", // title + prop::option::of("[a-z ]{10,50}"), // description + prop::option::of(prop::sample::select(vec![ + "draft", + "approved", + "implemented", + "obsolete", + ])), // status + prop::collection::vec( + prop::sample::select(vec![ + "stpa", + "safety", + "eu", + "automotive", + "core", + "cli", + "schema", + "testing", + "performance", + "future", + ]), + 0..=4, + ), // tags + prop::collection::vec( + ( + prop::sample::select(vec![ + "satisfies", + "implements", + "verifies", + "traces-to", + "refines", + "mitigates", + "linked-by", + ]), + "[A-Z]{2,4}-[0-9]{1,3}", + ), + 0..=3, + ), // links + ) + .prop_map(|(id, art_type, title, desc, status, tags, links)| { + let links = links + .into_iter() + .map(|(lt, tgt)| Link { + link_type: lt.to_string(), + target: tgt, + }) + .collect(); + Artifact { + id, + artifact_type: art_type.to_string(), + title, + description: desc, + status: status.map(|s| s.to_string()), + tags: tags.into_iter().map(|s| s.to_string()).collect(), + links, + fields: BTreeMap::new(), + provenance: None, + source_file: None, + } + }) +} + +/// Generate a random leaf predicate (no nesting). +fn arb_leaf_pred() -> impl Strategy { + prop_oneof![ + // Type equality + prop::sample::select(vec![ + "requirement", + "feature", + "design-decision", + "test-case", + ]) + .prop_map(|t| Expr::Eq(Accessor::Field("type".into()), Value::Str(t.to_string()),)), + // Status equality + prop::sample::select(vec!["draft", "approved", "implemented", "obsolete"]) + .prop_map(|s| Expr::Eq(Accessor::Field("status".into()), Value::Str(s.to_string()),)), + // Has-tag + prop::sample::select(vec!["stpa", "safety", "eu", "automotive", "core", "cli",]) + .prop_map(|t| Expr::HasTag(Value::Str(t.to_string()))), + // Has-field + prop::sample::select(vec!["description", "status", "priority", "nonexistent"]) + .prop_map(|f| Expr::HasField(Value::Str(f.to_string()))), + // Linked-by + prop::sample::select(vec!["satisfies", "implements", "verifies", "missing-link"]) + .prop_map(|lt| Expr::LinkedBy(Value::Str(lt.to_string()), Value::Wildcard)), + // Boolean literal + any::().prop_map(Expr::BoolLit), + ] +} + +/// Generate an expression tree of bounded depth. +fn arb_expr(depth: u32) -> impl Strategy { + if depth == 0 { + arb_leaf_pred().boxed() + } else { + prop_oneof![ + 4 => arb_leaf_pred(), + 1 => arb_expr(depth - 1).prop_map(|e| Expr::Not(Box::new(e))), + 1 => (arb_expr(depth - 1), arb_expr(depth - 1)) + .prop_map(|(a, b)| Expr::And(vec![a, b])), + 1 => (arb_expr(depth - 1), arb_expr(depth - 1)) + .prop_map(|(a, b)| Expr::Or(vec![a, b])), + ] + .boxed() + } +} + +/// Check an expression against an artifact with an empty link graph. +fn run_check(expr: &Expr, artifact: &Artifact) -> bool { + let store = Store::default(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let ctx = EvalContext { + artifact, + graph: &graph, + }; + sexpr_eval::check(expr, &ctx) +} + +// ── Properties ────────────────────────────────────────────────────────── + +proptest! { + #![proptest_config(ProptestConfig::with_cases(200))] + + /// De Morgan's law: ¬(A ∧ B) ≡ (¬A ∨ ¬B) + #[test] + fn prop_de_morgan_and( + a in arb_artifact(), + p in arb_expr(1), + q in arb_expr(1), + ) { + let lhs = Expr::Not(Box::new(Expr::And(vec![p.clone(), q.clone()]))); + let rhs = Expr::Or(vec![ + Expr::Not(Box::new(p)), + Expr::Not(Box::new(q)), + ]); + prop_assert_eq!(run_check(&lhs, &a), run_check(&rhs, &a)); + } + + /// De Morgan's law: ¬(A ∨ B) ≡ (¬A ∧ ¬B) + #[test] + fn prop_de_morgan_or( + a in arb_artifact(), + p in arb_expr(1), + q in arb_expr(1), + ) { + let lhs = Expr::Not(Box::new(Expr::Or(vec![p.clone(), q.clone()]))); + let rhs = Expr::And(vec![ + Expr::Not(Box::new(p)), + Expr::Not(Box::new(q)), + ]); + prop_assert_eq!(run_check(&lhs, &a), run_check(&rhs, &a)); + } + + /// Double negation: ¬¬A ≡ A + #[test] + fn prop_double_negation( + a in arb_artifact(), + p in arb_expr(2), + ) { + let double_neg = Expr::Not(Box::new(Expr::Not(Box::new(p.clone())))); + prop_assert_eq!(run_check(&double_neg, &a), run_check(&p, &a)); + } + + /// Commutativity of and: (A ∧ B) ≡ (B ∧ A) + #[test] + fn prop_and_commutative( + a in arb_artifact(), + p in arb_expr(1), + q in arb_expr(1), + ) { + let lhs = Expr::And(vec![p.clone(), q.clone()]); + let rhs = Expr::And(vec![q, p]); + prop_assert_eq!(run_check(&lhs, &a), run_check(&rhs, &a)); + } + + /// Commutativity of or: (A ∨ B) ≡ (B ∨ A) + #[test] + fn prop_or_commutative( + a in arb_artifact(), + p in arb_expr(1), + q in arb_expr(1), + ) { + let lhs = Expr::Or(vec![p.clone(), q.clone()]); + let rhs = Expr::Or(vec![q, p]); + prop_assert_eq!(run_check(&lhs, &a), run_check(&rhs, &a)); + } + + /// Implies expansion: (A → B) ≡ (¬A ∨ B) + #[test] + fn prop_implies_expansion( + a in arb_artifact(), + p in arb_expr(1), + q in arb_expr(1), + ) { + let lhs = Expr::Implies(Box::new(p.clone()), Box::new(q.clone())); + let rhs = Expr::Or(vec![Expr::Not(Box::new(p)), q]); + prop_assert_eq!(run_check(&lhs, &a), run_check(&rhs, &a)); + } + + /// Excludes expansion: excludes(A, B) ≡ ¬(A ∧ B) + #[test] + fn prop_excludes_expansion( + a in arb_artifact(), + p in arb_expr(1), + q in arb_expr(1), + ) { + let lhs = Expr::Excludes(Box::new(p.clone()), Box::new(q.clone())); + let rhs = Expr::Not(Box::new(Expr::And(vec![p, q]))); + prop_assert_eq!(run_check(&lhs, &a), run_check(&rhs, &a)); + } + + /// Parser round-trip: parse(s).text() == s for generated expressions. + #[test] + fn prop_parser_round_trip(s in arb_sexpr_string()) { + let (green, _errors) = rivet_core::sexpr::parse(&s); + let node = rivet_core::sexpr::SyntaxNode::new_root(green); + prop_assert_eq!(node.text().to_string(), s); + } +} + +/// Generate syntactically valid s-expression strings. +fn arb_sexpr_string() -> impl Strategy { + prop_oneof![ + // Bare atoms + Just("true".to_string()), + Just("false".to_string()), + Just("42".to_string()), + Just("_".to_string()), + "[a-z]{3,8}".prop_map(|s| format!("\"{s}\"")), + // Simple lists + "[a-z-]{2,10}".prop_map(|sym| format!("({sym})")), + ("[a-z-]{2,10}", "[a-z-]{2,10}").prop_map(|(a, b)| format!("({a} {b})")), + // Nested + ("[a-z-]{2,10}", "[a-z-]{2,10}", "[a-z-]{2,10}") + .prop_map(|(a, b, c)| format!("(and ({a} {b}) ({a} {c}))")), + // Realistic filter + prop::sample::select(vec![ + r#"(= type "requirement")"#.to_string(), + r#"(has-tag "stpa")"#.to_string(), + r#"(and (= type "feature") (= status "approved"))"#.to_string(), + r#"(or (has-tag "eu") (has-tag "us"))"#.to_string(), + r#"(not (= status "obsolete"))"#.to_string(), + ]), + ] +} diff --git a/rivet.yaml b/rivet.yaml index b5ab73e..9df57cb 100644 --- a/rivet.yaml +++ b/rivet.yaml @@ -22,11 +22,20 @@ docs: results: results +externals: + spar: + git: https://github.com/pulseengine/spar.git + ref: 84a7363 + path: tests/fixtures/spar-external + prefix: spar + baselines: - name: v0.1.0 description: Initial release — core validation, STPA, ASPICE schemas, CLI, dashboard - name: v0.2.0-dev description: Current development — cross-repo, mutations, STPA-Sec, export, AADL + - name: v0.4.0 + description: Planned — s-expression query language, product line engineering, variant management commits: format: trailers @@ -81,9 +90,11 @@ commits: UCA-Q-7, # Phase 3 STPA and draft artifacts (no code implementation yet) CC-C-10, CC-C-11, CC-C-12, CC-C-13, CC-C-14, CC-C-15, CC-C-16, CC-C-17, + CC-M-1, CC-M-2, CC-M-3, H-9, H-9.1, H-9.2, H-10, H-11, H-11.1, H-11.2, H-12, H-18, SC-11, SC-12, SC-13, SC-14, SC-20, UCA-C-10, UCA-C-11, UCA-C-12, UCA-C-13, UCA-C-14, UCA-C-15, UCA-C-16, UCA-C-17, + UCA-M-1, UCA-M-2, UCA-M-3, # Phase 3 planned features (draft, not yet implemented in code) FEAT-043, FEAT-044, FEAT-045, FEAT-047, FEAT-048, FEAT-049, FEAT-050, FEAT-051, FEAT-052, FEAT-053, FEAT-054, FEAT-055, FEAT-056, FEAT-057, @@ -106,4 +117,22 @@ commits: SH-1, SH-2, SH-3, SH-4, SH-5, SH-6, SH-7, SSC-1, SSC-2, SSC-3, SSC-4, SSC-5, SSC-6, SSC-7, SUCA-CLI-1, SUCA-CLI-2, SUCA-DASH-1, SUCA-DASH-2, SUCA-CORE-1, SUCA-OSLC-1, SUCA-OSLC-2, - SLS-1, SLS-2, SLS-3, SLS-4, SLS-5, SLS-6, SLS-7] + SLS-1, SLS-2, SLS-3, SLS-4, SLS-5, SLS-6, SLS-7, + # v0.3.1 implementation STPA artifacts + H-IMPL-001, H-IMPL-002, H-IMPL-003, H-IMPL-004, H-IMPL-005, H-IMPL-006, + L-IMPL-001, L-IMPL-002, L-IMPL-003, L-IMPL-004, + SC-IMPL-001, SC-IMPL-002, SC-IMPL-003, SC-IMPL-004, SC-IMPL-005, SC-IMPL-006, + # LSP diagnostic STPA artifacts + H-LSP-001, H-LSP-002, H-LSP-003, H-LSP-004, + L-LSP-001, L-LSP-002, L-LSP-003, + SC-LSP-001, SC-LSP-002, SC-LSP-003, SC-LSP-004, SC-LSP-005, SC-LSP-006, SC-LSP-007, + # v0.4.0 variant/PLE artifacts (draft, no code yet) + REQ-041, REQ-042, REQ-043, REQ-044, REQ-045, REQ-046, + DD-048, DD-049, DD-050, DD-051, + FEAT-106, FEAT-107, FEAT-108, FEAT-109, FEAT-110, FEAT-111, FEAT-112, FEAT-113, FEAT-114, + # v0.4.0 variant STPA artifacts + H-VAR-001, H-VAR-002, H-VAR-003, H-VAR-004, H-VAR-005, H-VAR-006, H-VAR-007, + SC-VAR-001, SC-VAR-002, SC-VAR-003, SC-VAR-004, SC-VAR-005, SC-VAR-006, SC-VAR-007, + UCA-VAR-001, UCA-VAR-002, UCA-VAR-003, UCA-VAR-004, UCA-VAR-005, + CC-VAR-001, CC-VAR-002, CC-VAR-003, CC-VAR-004, CC-VAR-005, + LS-VAR-001, LS-VAR-002, LS-VAR-003, LS-VAR-004] diff --git a/safety/stpa-sec/sec-constraints.yaml b/safety/stpa-sec/sec-constraints.yaml index 0086fdf..e93d02d 100644 --- a/safety/stpa-sec/sec-constraints.yaml +++ b/safety/stpa-sec/sec-constraints.yaml @@ -22,6 +22,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-1 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-2 type: sec-constraint @@ -37,6 +40,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-2 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-3 type: sec-constraint @@ -52,6 +58,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-3 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-4 type: sec-constraint @@ -66,6 +75,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-4 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-5 type: sec-constraint @@ -80,6 +92,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-5 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-6 type: sec-constraint @@ -94,6 +109,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-6 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-7 type: sec-constraint @@ -109,3 +127,6 @@ artifacts: links: - type: prevents-sec-hazard target: SH-7 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa-sec/sec-hazards.yaml b/safety/stpa-sec/sec-hazards.yaml index eb9e6e6..8d7c5ef 100644 --- a/safety/stpa-sec/sec-hazards.yaml +++ b/safety/stpa-sec/sec-hazards.yaml @@ -31,6 +31,9 @@ artifacts: fields: cia-impact: [integrity] severity: critical + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-2 type: sec-hazard @@ -50,6 +53,9 @@ artifacts: fields: cia-impact: [integrity] severity: critical + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-3 type: sec-hazard @@ -66,6 +72,9 @@ artifacts: fields: cia-impact: [confidentiality] severity: high + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-4 type: sec-hazard @@ -85,6 +94,9 @@ artifacts: fields: cia-impact: [confidentiality, integrity] severity: high + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-5 type: sec-hazard @@ -104,6 +116,9 @@ artifacts: fields: cia-impact: [confidentiality, integrity] severity: high + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-6 type: sec-hazard @@ -121,6 +136,9 @@ artifacts: fields: cia-impact: [availability] severity: medium + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-7 type: sec-hazard @@ -142,3 +160,6 @@ artifacts: fields: cia-impact: [integrity] severity: high + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa-sec/sec-losses.yaml b/safety/stpa-sec/sec-losses.yaml index b6788c9..9d0d93a 100644 --- a/safety/stpa-sec/sec-losses.yaml +++ b/safety/stpa-sec/sec-losses.yaml @@ -34,6 +34,9 @@ artifacts: fields: cia-impact: [confidentiality] stakeholders: [safety-engineers, tool-administrators, certification-authorities] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SL-2 type: sec-loss @@ -47,6 +50,9 @@ artifacts: fields: cia-impact: [integrity] stakeholders: [safety-engineers, certification-authorities, developers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SL-3 type: sec-loss @@ -60,6 +66,9 @@ artifacts: fields: cia-impact: [availability] stakeholders: [safety-engineers, developers, project-managers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SL-4 type: sec-loss @@ -74,6 +83,9 @@ artifacts: fields: cia-impact: [integrity, confidentiality] stakeholders: [certification-authorities, project-managers, safety-engineers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SL-5 type: sec-loss @@ -88,3 +100,6 @@ artifacts: fields: cia-impact: [integrity] stakeholders: [safety-engineers, developers, certification-authorities] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa-sec/sec-scenarios.yaml b/safety/stpa-sec/sec-scenarios.yaml index f0dfbce..494484b 100644 --- a/safety/stpa-sec/sec-scenarios.yaml +++ b/safety/stpa-sec/sec-scenarios.yaml @@ -47,6 +47,9 @@ artifacts: - Git remote credentials stolen via phishing or leaked CI token - No branch protection or required review on safety/ directory - Rivet does not verify artifact store against last signed commit + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SLS-2 type: sec-scenario @@ -76,6 +79,9 @@ artifacts: - tls-verify disabled for development and not re-enabled for production - No artifact signature scheme to detect OSLC-injected content - Adjacent network adversary (e.g., corporate LAN or CI network segment) + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SLS-3 type: sec-scenario @@ -103,6 +109,9 @@ artifacts: - Artifact YAML fields included in HTML without escaping - No Content-Security-Policy header restricting inline script execution - Insider has write access to artifact YAML files + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SLS-4 type: sec-scenario @@ -129,6 +138,9 @@ artifacts: causal-factors: - HTML export pipeline uses same rendering code as dashboard without additional escaping - Export files distributed outside the controlled repository environment + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SLS-5 type: sec-scenario @@ -158,6 +170,9 @@ artifacts: - serde_yaml (pre-0.9) follows YAML 1.1 aliases without recursion guard - No maximum document size or node count limit in parsing pipeline - CI allows external contributors to trigger the validate workflow on PRs + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SLS-6 type: sec-scenario @@ -186,6 +201,9 @@ artifacts: - No OSLC server URL allowlist configured or enforced - Sync credentials (env vars) are sent to the URL in rivet.yaml without URL verification - No review requirement on changes to rivet.yaml sync configuration + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SLS-7 type: sec-scenario @@ -212,3 +230,6 @@ artifacts: - Default bind address is network-accessible (0.0.0.0) - No authentication required for any dashboard endpoint - CI runner has public IP without egress/ingress filtering + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa-sec/sec-ucas.yaml b/safety/stpa-sec/sec-ucas.yaml index d41bf12..ae2fa74 100644 --- a/safety/stpa-sec/sec-ucas.yaml +++ b/safety/stpa-sec/sec-ucas.yaml @@ -43,6 +43,9 @@ artifacts: the OSLC server URL to an attacker-controlled host. On next sync, the CLI imports adversary-crafted artifacts without provenance verification. attacker-type: supply-chain + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SUCA-CLI-2 type: sec-uca @@ -67,6 +70,9 @@ artifacts: CLI export includes this verbatim in the HTML output, triggering XSS in any browser that opens the export. attacker-type: insider + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── CTRL-DASH (dashboard controller) ──────────────────────────────────── @@ -94,6 +100,9 @@ artifacts: developer machines. The dashboard responds with full artifact JSON including proprietary requirements and safety analyses. attacker-type: external-network + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SUCA-DASH-2 type: sec-uca @@ -120,6 +129,9 @@ artifacts: so the attacker's script executes in every reviewer's browser — potentially exfiltrating session state or injecting false data. attacker-type: insider + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── CTRL-CORE (validation engine) ─────────────────────────────────────── @@ -146,6 +158,9 @@ artifacts: description string. The CI runner exhausts memory, denying the pipeline to legitimate work. attacker-type: supply-chain + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── CTRL-OSLC (OSLC sync controller) ──────────────────────────────────── @@ -173,6 +188,9 @@ artifacts: artifacts to the attacker's server and accepts adversary-injected artifacts as authoritative. attacker-type: supply-chain + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SUCA-OSLC-2 type: sec-uca @@ -200,3 +218,6 @@ artifacts: self-signed certificate. With certificate validation disabled, Rivet connects, disclosing artifact payloads and accepting injected responses. attacker-type: external-network + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa-sec/v031-security.yaml b/safety/stpa-sec/v031-security.yaml index 3671c06..fdb973b 100644 --- a/safety/stpa-sec/v031-security.yaml +++ b/safety/stpa-sec/v031-security.yaml @@ -20,6 +20,9 @@ artifacts: appear compliant. fields: cia-impact: [integrity] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SL-IMPL-002 type: sec-loss @@ -30,6 +33,9 @@ artifacts: and project structure to unauthorized agents. fields: cia-impact: [confidentiality] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SL-IMPL-003 type: sec-loss @@ -41,6 +47,9 @@ artifacts: or resource exhaustion. fields: cia-impact: [integrity, availability] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Hazards ───────────────────────────────────────────────────────────── @@ -57,6 +66,9 @@ artifacts: target: SL-IMPL-001 fields: cia-impact: [integrity] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-IMPL-002 type: sec-hazard @@ -71,6 +83,9 @@ artifacts: target: SL-IMPL-002 fields: cia-impact: [confidentiality, integrity] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-IMPL-003 type: sec-hazard @@ -85,6 +100,9 @@ artifacts: target: SL-IMPL-003 fields: cia-impact: [integrity] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SH-IMPL-004 type: sec-hazard @@ -99,6 +117,83 @@ artifacts: target: SL-IMPL-001 fields: cia-impact: [availability] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + # ── Security UCAs ────────────────────────────────────────────────────── + + - id: SUCA-MCP-1 + type: sec-uca + title: MCP server accepts tool invocations without authentication + status: draft + description: > + The MCP stdio transport accepts tool invocations from any process + that can pipe to Rivet's stdin. On shared systems, a malicious + process could query all project artifacts. + links: + - type: issued-by + target: CTRL-CLI + - type: leads-to-sec-hazard + target: SH-IMPL-002 + fields: + uca-type: not-providing + context: > + Shared developer machines or CI runners where multiple processes + have access to stdio. + adversarial-causation: > + Insider attacker on a shared CI runner or developer machine spawns + a process that connects to Rivet's MCP stdin/stdout and invokes + rivet_list or rivet_validate to exfiltrate project artifacts. + attacker-type: insider + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + - id: SUCA-MCP-2 + type: sec-uca + title: MCP server returns full file system paths in error messages + status: draft + description: > + Error responses from MCP tools may include absolute file system + paths, revealing project directory structure to connected agents. + links: + - type: issued-by + target: CTRL-CLI + - type: leads-to-sec-hazard + target: SH-IMPL-002 + fields: + uca-type: providing + context: > + AI agent connected via MCP receives detailed error with paths. + adversarial-causation: > + External agent connected via MCP triggers an error (e.g., invalid + artifact ID) and receives an error response containing absolute + file system paths, disclosing the project directory structure and + potentially revealing sensitive path components. + attacker-type: external-network + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + # ── Security Scenarios ───────────────────────────────────────────────── + + - id: SLS-IMPL-001 + type: sec-scenario + title: Malicious VS Code extension queries artifacts via MCP + status: draft + description: > + A compromised VS Code extension spawns a rivet MCP subprocess + and calls rivet_list to enumerate all project artifacts including + security-sensitive design decisions and vulnerability assessments. + links: + - type: caused-by-sec-uca + target: SUCA-MCP-1 + - type: leads-to-sec-hazard + target: SH-IMPL-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ── Security Constraints ──────────────────────────────────────────────── @@ -113,6 +208,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-IMPL-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-IMPL-002 type: sec-constraint @@ -126,6 +224,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-IMPL-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-IMPL-003 type: sec-constraint @@ -138,6 +239,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-IMPL-003 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-IMPL-004 type: sec-constraint @@ -151,6 +255,9 @@ artifacts: links: - type: prevents-sec-hazard target: SH-IMPL-004 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SSC-IMPL-005 type: sec-constraint @@ -163,3 +270,20 @@ artifacts: links: - type: prevents-sec-hazard target: SH-IMPL-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + - id: SSC-IMPL-006 + type: sec-constraint + title: MCP tool results MUST NOT include absolute file paths + status: draft + description: > + MCP tool responses must use project-relative paths only. + Error messages must sanitize paths before returning to the client. + links: + - type: prevents-sec-hazard + target: SH-IMPL-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/control-structure.yaml b/safety/stpa/control-structure.yaml index b12b48c..eb69523 100644 --- a/safety/stpa/control-structure.yaml +++ b/safety/stpa/control-structure.yaml @@ -46,6 +46,9 @@ controllers: - Which artifacts have been modified since last validation - Whether external tools have pending changes - Coverage completeness of the traceability matrix + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- CLI controller --- - id: CTRL-CLI @@ -81,6 +84,9 @@ controllers: - Which adapter to use for the current operation - Current rivet.yaml configuration (schemas, sources, adapters) - Whether the last operation succeeded or failed + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- Core engine controller --- - id: CTRL-CORE @@ -111,6 +117,9 @@ controllers: - Link graph topology (nodes, edges, directionality) - Schema definitions and valid link-type pairs - Known artifact types and their required fields + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- OSLC sync controller --- - id: CTRL-OSLC @@ -142,6 +151,9 @@ controllers: - Mapping between local IDs and remote OSLC URIs - Connection status and authentication state - Which artifacts are locally authoritative vs. remotely authoritative + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- ReqIF adapter controller --- - id: CTRL-REQIF @@ -167,6 +179,9 @@ controllers: - SpecType-to-artifact-type mapping - Enum value lookup tables - Which ReqIF standard attributes are present (ForeignID, Name, Text) + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- CI pipeline controller --- - id: CTRL-CI @@ -192,6 +207,9 @@ controllers: - Which files changed in the current PR - Whether rivet validate passed or failed - CI workflow configuration (which checks are required) + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- Dashboard controller --- - id: CTRL-DASH @@ -216,6 +234,9 @@ controllers: - Cached rendering of current artifact state - Last refresh timestamp - Active filters and view state + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- MCP Server controller --- - id: CTRL-MCP @@ -243,6 +264,9 @@ controllers: - Set of registered tools and their parameter schemas - Cached project state (store, schema, link graph) - Whether cached state reflects current disk state + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z controlled-processes: - id: PROC-ARTIFACTS @@ -251,6 +275,9 @@ controlled-processes: YAML artifact files in the git working tree, organized by schema domain (stpa, aspice, cybersecurity, dev, aadl). The local source of truth for all traceability data. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: PROC-LINKGRAPH name: In-Memory Link Graph @@ -259,6 +286,9 @@ controlled-processes: between artifacts. Ephemeral — rebuilt from YAML on each invocation. Supports cycle detection, reachability, and coverage queries. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: PROC-EXTERNAL name: External ALM Tool Data @@ -266,6 +296,9 @@ controlled-processes: Artifact data residing in external tools (Polarion, DOORS, codebeamer, StrictDoc) accessed via OSLC or ReqIF. Rivet does not control this data directly — it observes and syncs. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: PROC-REPORTS name: Generated Reports and Metrics @@ -273,6 +306,9 @@ controlled-processes: Output artifacts: coverage matrices, compliance reports, validation summaries, ReqIF exports. Consumed by auditors, project managers, and downstream tools. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: PROC-GITREPO name: Git Repository @@ -280,3 +316,6 @@ controlled-processes: Version-controlled repository containing artifact YAML files, configuration, and code. Provides the audit trail via commit history and enables conflict detection via merge semantics. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/controller-constraints.yaml b/safety/stpa/controller-constraints.yaml index a2fd16a..3e01244 100644 --- a/safety/stpa/controller-constraints.yaml +++ b/safety/stpa/controller-constraints.yaml @@ -20,6 +20,9 @@ controller-constraints: since the last validation pass, before producing any output. ucas: [UCA-C-1] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-2 controller: CTRL-CORE @@ -28,6 +31,9 @@ controller-constraints: operation writes new or modified artifacts. ucas: [UCA-C-2] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-3 controller: CTRL-CORE @@ -36,6 +42,9 @@ controller-constraints: regardless of the import source. ucas: [UCA-C-3] hazards: [H-4, H-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-4 controller: CTRL-CORE @@ -44,6 +53,9 @@ controller-constraints: exist in the link graph. ucas: [UCA-C-4] hazards: [H-1, H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-5 controller: CTRL-CORE @@ -52,6 +64,9 @@ controller-constraints: relationships when computing coverage metrics. ucas: [UCA-C-5] hazards: [H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-6 controller: CTRL-CORE @@ -60,6 +75,9 @@ controller-constraints: declared type, and must reject artifacts with unknown types. ucas: [UCA-C-6] hazards: [H-4, H-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-7 controller: CTRL-CORE @@ -68,6 +86,9 @@ controller-constraints: sources have been fully loaded. ucas: [UCA-C-7] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-8 controller: CTRL-CORE @@ -76,6 +97,9 @@ controller-constraints: been validated in the current session. ucas: [UCA-C-8] hazards: [H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-9 controller: CTRL-CORE @@ -84,6 +108,9 @@ controller-constraints: files, not terminate after the first error. ucas: [UCA-C-9] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # OSLC Client constraints @@ -95,6 +122,9 @@ controller-constraints: since the last sync cursor when triggered. ucas: [UCA-O-1] hazards: [H-1, H-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-2 controller: CTRL-OSLC @@ -103,6 +133,9 @@ controller-constraints: when bidirectional sync is configured. ucas: [UCA-O-2] hazards: [H-5, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-3 controller: CTRL-OSLC @@ -111,6 +144,9 @@ controller-constraints: information to the CLI and developer. ucas: [UCA-O-3] hazards: [H-2, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-4 controller: CTRL-OSLC @@ -119,6 +155,9 @@ controller-constraints: modifications and require explicit resolution before writing. ucas: [UCA-O-4] hazards: [H-5, H-3, H-7] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-5 controller: CTRL-OSLC @@ -127,6 +166,9 @@ controller-constraints: confirmation when the deleted artifact is referenced by local links. ucas: [UCA-O-5] hazards: [H-2, H-1, H-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-6 controller: CTRL-OSLC @@ -135,6 +177,9 @@ controller-constraints: schema before writing them to the artifact store. ucas: [UCA-O-6] hazards: [H-8, H-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-7 controller: CTRL-OSLC @@ -143,6 +188,9 @@ controller-constraints: validation to the remote tool. ucas: [UCA-O-7] hazards: [H-8, H-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-8 controller: CTRL-OSLC @@ -151,6 +199,9 @@ controller-constraints: generation, or clearly indicate the sync state in the report. ucas: [UCA-O-8] hazards: [H-6, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-9 controller: CTRL-OSLC @@ -159,6 +210,9 @@ controller-constraints: or must use a staging area to avoid write conflicts. ucas: [UCA-O-9] hazards: [H-5, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-O-10 controller: CTRL-OSLC @@ -168,6 +222,9 @@ controller-constraints: consistent state on failure. ucas: [UCA-O-10] hazards: [H-2, H-1, H-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # ReqIF Adapter constraints @@ -179,6 +236,9 @@ controller-constraints: must not silently discard SpecObjects it cannot parse. ucas: [UCA-Q-1] hazards: [H-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-Q-2 controller: CTRL-REQIF @@ -187,6 +247,9 @@ controller-constraints: artifacts that have been removed from the local store. ucas: [UCA-Q-2] hazards: [H-1, H-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-Q-3 controller: CTRL-REQIF @@ -195,6 +258,9 @@ controller-constraints: must reject unmapped attributes with a hard error. ucas: [UCA-Q-3] hazards: [H-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-Q-4 controller: CTRL-REQIF @@ -203,6 +269,9 @@ controller-constraints: identifier and update them rather than creating duplicates. ucas: [UCA-Q-4] hazards: [H-3, H-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-Q-5 controller: CTRL-REQIF @@ -211,6 +280,9 @@ controller-constraints: during export, or warn when content simplification occurs. ucas: [UCA-Q-5] hazards: [H-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-Q-6 controller: CTRL-REQIF @@ -219,6 +291,9 @@ controller-constraints: and rivet.yaml configuration have been loaded. ucas: [UCA-Q-6] hazards: [H-4, H-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-Q-7 controller: CTRL-REQIF @@ -228,6 +303,9 @@ controller-constraints: record and reporting it separately. ucas: [UCA-Q-7] hazards: [H-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # CLI constraints @@ -239,6 +317,9 @@ controller-constraints: regardless of verbosity settings. ucas: [UCA-L-1] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-L-2 controller: CTRL-CLI @@ -247,6 +328,9 @@ controller-constraints: metric output, or clearly label the output as unvalidated. ucas: [UCA-L-2] hazards: [H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-L-3 controller: CTRL-CLI @@ -255,6 +339,9 @@ controller-constraints: without prompting the developer for confirmation. ucas: [UCA-L-3] hazards: [H-5, H-3, H-7] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-L-4 controller: CTRL-CLI @@ -263,6 +350,9 @@ controller-constraints: not inference from file extension or path patterns. ucas: [UCA-L-4] hazards: [H-4, H-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-L-5 controller: CTRL-CLI @@ -271,6 +361,9 @@ controller-constraints: being edited, or must use atomic file replacement. ucas: [UCA-L-5] hazards: [H-5, H-7] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # CI Pipeline constraints @@ -282,6 +375,9 @@ controller-constraints: artifact YAML files or schema definitions. ucas: [UCA-I-1] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-I-2 controller: CTRL-CI @@ -290,6 +386,9 @@ controller-constraints: blocks merge on failure. ucas: [UCA-I-2] hazards: [H-1, H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-I-3 controller: CTRL-CI @@ -298,6 +397,9 @@ controller-constraints: and fail the check on non-zero exit. ucas: [UCA-I-3] hazards: [H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-I-4 controller: CTRL-CI @@ -306,6 +408,9 @@ controller-constraints: validation, without using cached artifact state. ucas: [UCA-I-4] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # Dashboard constraints @@ -317,6 +422,9 @@ controller-constraints: coverage metrics. ucas: [UCA-D-1] hazards: [H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-D-2 controller: CTRL-DASH @@ -325,6 +433,9 @@ controller-constraints: and warn when displayed data may be stale. ucas: [UCA-D-2] hazards: [H-3, H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # Incremental validation constraints (salsa) @@ -337,6 +448,9 @@ controller-constraints: cached results are returned. ucas: [UCA-C-10] hazards: [H-9, H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-11 controller: CTRL-CORE @@ -346,6 +460,9 @@ controller-constraints: other fields are unchanged. ucas: [UCA-C-11] hazards: [H-9, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-12 controller: CTRL-CORE @@ -355,6 +472,9 @@ controller-constraints: conflicting rules, before any validation occurs. ucas: [UCA-C-12] hazards: [H-10] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-13 controller: CTRL-CORE @@ -364,6 +484,9 @@ controller-constraints: A periodic full-revalidation check must verify this invariant. ucas: [UCA-C-13] hazards: [H-9, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-14 controller: CTRL-CORE @@ -372,6 +495,9 @@ controller-constraints: conditional rule evaluation begins, via salsa query dependencies. ucas: [UCA-C-14] hazards: [H-9, H-10] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # MODULE.bazel parser constraints @@ -384,6 +510,9 @@ controller-constraints: the corresponding bazel_dep version/source. ucas: [UCA-C-15] hazards: [H-11, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-16 controller: CTRL-CORE @@ -393,6 +522,9 @@ controller-constraints: may be missing from the result. Silent skip is forbidden. ucas: [UCA-C-16] hazards: [H-11] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-17 controller: CTRL-CORE @@ -402,6 +534,9 @@ controller-constraints: supported function call types. ucas: [UCA-C-17] hazards: [H-11, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # STPA-Sec extension constraints @@ -415,6 +550,9 @@ controller-constraints: escaping. ucas: [UCA-D-3] hazards: [H-13] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-D-4 controller: CTRL-DASH @@ -424,6 +562,9 @@ controller-constraints: occurs. ucas: [UCA-D-4] hazards: [H-16] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-18 controller: CTRL-CORE @@ -434,6 +575,9 @@ controller-constraints: counted as coverage. ucas: [UCA-C-18] hazards: [H-15] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-19 controller: CTRL-CORE @@ -443,6 +587,9 @@ controller-constraints: prefixes (UCA-C-10), and controller constraints (CC-L-5). ucas: [UCA-C-19] hazards: [H-15, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-20 controller: CTRL-CORE @@ -452,6 +599,9 @@ controller-constraints: error. ucas: [UCA-C-20] hazards: [H-11, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-21 controller: CTRL-CORE @@ -461,6 +611,9 @@ controller-constraints: artifact store. ucas: [UCA-C-21] hazards: [H-14] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-22 controller: CTRL-CORE @@ -470,6 +623,9 @@ controller-constraints: diagnostic. ucas: [UCA-C-22] hazards: [H-14] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-23 controller: CTRL-CORE @@ -479,6 +635,9 @@ controller-constraints: outside those paths. ucas: [UCA-C-23] hazards: [H-14, H-17] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-24 controller: CTRL-CORE @@ -488,6 +647,9 @@ controller-constraints: hardcoded mappings. ucas: [UCA-C-24] hazards: [H-3, H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-25 controller: CTRL-CORE @@ -497,6 +659,9 @@ controller-constraints: artifact store. ucas: [UCA-C-25] hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-L-6 controller: CTRL-CLI @@ -506,6 +671,9 @@ controller-constraints: execute arbitrary URLs without user confirmation for new repos. ucas: [UCA-L-6] hazards: [H-17] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-L-7 controller: CTRL-CLI @@ -515,6 +683,9 @@ controller-constraints: external sync operations. ucas: [UCA-L-7] hazards: [H-17, H-14] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # LSP server constraints (Phase 3 extension) @@ -527,6 +698,9 @@ controller-constraints: 500 ms for stores under 5000 artifacts. ucas: [UCA-C-26] hazards: [H-19, H-9] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: CC-C-27 controller: CTRL-CORE @@ -537,3 +711,45 @@ controller-constraints: artifact data. ucas: [UCA-C-27] hazards: [H-20] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + # ========================================================================= + # MCP server constraints + # ========================================================================= + - id: CC-M-1 + controller: CTRL-CLI + constraint: > + MCP server must reload project state from disk on every tool call. + No caching of validation results, artifact lists, or coverage + metrics across tool calls. + ucas: [UCA-M-1] + hazards: [H-IMPL-003] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + - id: CC-M-2 + controller: CTRL-CLI + constraint: > + MCP server must report source directory loading failures as + explicit error diagnostics in tool responses. Partial loads must + be clearly indicated, not silently accepted. + ucas: [UCA-M-2] + hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + - id: CC-M-3 + controller: CTRL-CLI + constraint: > + MCP server must run validation before returning coverage metrics. + Coverage must be computed from the current file state, not from + a previous validation pass. + ucas: [UCA-M-3] + hazards: [H-3, H-IMPL-003] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/hazards.yaml b/safety/stpa/hazards.yaml index 9ee06ff..f3fc948 100644 --- a/safety/stpa/hazards.yaml +++ b/safety/stpa/hazards.yaml @@ -20,6 +20,9 @@ hazards: automated link validation, stale references create an illusion of complete traceability while gaps exist. losses: [L-1, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-2 title: Rivet silently drops artifacts or links during synchronization @@ -29,6 +32,9 @@ hazards: The lost data may include safety-critical requirements or their verification evidence. losses: [L-1, L-3, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-3 title: Rivet produces incorrect coverage metrics showing completeness when gaps exist @@ -37,6 +43,9 @@ hazards: than actually exists. Engineers and auditors rely on these metrics to judge readiness, so inflated metrics hide real traceability gaps. losses: [L-2, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-4 title: Rivet imports semantically mismatched data from external tools @@ -47,6 +56,9 @@ hazards: or a "refines" link might be imported as "satisfies," distorting the traceability argument. losses: [L-1, L-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-5 title: Rivet fails to detect conflicting concurrent modifications @@ -55,6 +67,9 @@ hazards: same artifact simultaneously. Without conflict detection, the last writer wins, silently overwriting safety-relevant changes. losses: [L-1, L-3, L-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-6 title: Rivet generates compliance reports from unverified traceability data @@ -63,6 +78,9 @@ hazards: validation — containing dangling links, schema violations, or outdated sync state. Auditors receive misleading evidence. losses: [L-2, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-7 title: Rivet allows unattributed modification of safety-critical artifacts @@ -71,6 +89,9 @@ hazards: analyses, verification records) occur without recording who made the change, when, or why. The audit trail is incomplete. losses: [L-3, L-5, L-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-8 title: Rivet propagates errors bidirectionally across connected tools @@ -81,6 +102,9 @@ hazards: amplifying a single-tool error into a multi-tool data integrity incident. losses: [L-1, L-3, L-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-9 title: Rivet incremental validation returns stale results due to missed invalidation @@ -92,6 +116,9 @@ hazards: where CI trusts incremental results, this silently passes a broken traceability state. losses: [L-1, L-2, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-10 title: Rivet conditional validation rules contradict, making compliance impossible @@ -103,6 +130,9 @@ hazards: detect the inconsistency, causing perpetual validation failures that engineers work around by disabling rules. losses: [L-1, L-4, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-11 title: Rivet MODULE.bazel parser silently misparses dependency declarations @@ -112,6 +142,9 @@ hazards: validation runs against the wrong repo versions, reporting traceability coverage against mismatched baselines. losses: [L-1, L-2, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-12 title: Rivet formal proofs verify a model that diverges from the implementation @@ -122,6 +155,9 @@ hazards: has bugs that the proofs do not cover, creating false assurance of correctness. losses: [L-2, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-13 title: Rivet document renderer produces HTML containing unescaped artifact content @@ -137,6 +173,9 @@ hazards: data, exfiltrate session information, or modify the visual presentation of compliance metrics. losses: [L-1, L-2, L-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-14 title: Rivet WASM adapter executes untrusted code that corrupts import results @@ -150,6 +189,9 @@ hazards: distributed as binary components without source review, a supply chain attack could introduce falsified traceability data. losses: [L-1, L-3, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-15 title: Rivet commit traceability analysis produces false coverage from misidentified artifact references @@ -163,6 +205,9 @@ hazards: does not match. Both inflate or deflate commit-artifact coverage metrics, misrepresenting implementation completeness. losses: [L-1, L-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-16 title: Rivet dashboard serves stale data after hot-reload fails silently @@ -177,6 +222,9 @@ hazards: and reloaded, they see the old (passing) state and conclude their fix is working when it actually introduced a new error. losses: [L-1, L-3, L-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-17 title: Rivet cross-repo sync clones arbitrary git repositories specified in rivet.yaml @@ -191,6 +239,9 @@ hazards: supply chain attack, the sync command becomes a vector for arbitrary code execution via git hooks. losses: [L-3, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-18 title: Rivet fails to resolve external repository paths across platforms @@ -200,6 +251,9 @@ hazards: don't exist on CI runners. If path resolution fails silently, cross-repo links break without warning and traceability is lost. losses: [L-1, L-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-19 title: LSP server provides stale validation results after file change @@ -211,6 +265,9 @@ hazards: trusts the editor's inline diagnostics, stale PASS results hide newly introduced traceability violations. losses: [L-1, L-2, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-20 title: Dashboard WebView in VS Code exposes artifact data via iframe postMessage @@ -222,6 +279,9 @@ hazards: environment where sensitive requirements or safety analyses are displayed, this enables data exfiltration without filesystem access. losses: [L-3, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-21 title: Rivet MCP server provides stale validation state to AI agents @@ -232,6 +292,9 @@ hazards: where the agent has autonomy to commit and push, stale MCP results propagate into the main branch. losses: [L-1, L-2, L-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-24 title: Rivet YAML round-trip alters artifact content or formatting @@ -241,6 +304,9 @@ hazards: read-modify-write cycle. Artifacts that were previously valid become invalid, or human-maintained formatting conventions are destroyed. losses: [L-1, L-4, L-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z sub-hazards: # --- H-1 refinements: types of stale references --- @@ -251,6 +317,9 @@ sub-hazards: An artifact referenced by a Rivet link is deleted in an external ALM tool. The OSLC TRS change log records the deletion, but Rivet does not process it, leaving a dangling forward reference. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-1.2 parent: H-1 @@ -259,6 +328,9 @@ sub-hazards: An artifact's identifier changes (e.g., REQ-042 becomes REQ-042a after a rebase in the external tool). Rivet's links still point to the old identifier. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-1.3 parent: H-1 @@ -267,6 +339,9 @@ sub-hazards: An artifact originally typed as "requirement" is reclassified as "information" in the external tool, but Rivet still treats the link as a requirement-level trace. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- H-4 refinements: types of semantic mismatch --- - id: H-4.1 @@ -276,6 +351,9 @@ sub-hazards: Lifecycle status strings from external tools (e.g., "In Review", "Baselined", "Obsolete") are mapped to the wrong Rivet status, causing artifacts to appear validated when they are still draft. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-4.2 parent: H-4 @@ -284,6 +362,9 @@ sub-hazards: Link type semantics differ between tools. A "derives" link in DOORS may mean "refines" in Rivet's schema, but an incorrect mapping treats it as "satisfies," inflating coverage metrics. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-4.3 parent: H-4 @@ -292,6 +373,9 @@ sub-hazards: ReqIF XHTML content or OSLC rich-text descriptions are stripped to plain text, losing tables, formulas, or embedded diagrams that are essential to understanding the requirement. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- H-9 refinements: incremental invalidation failures --- - id: H-9.1 @@ -301,6 +385,9 @@ sub-hazards: A schema file is modified (e.g., adding a conditional rule) but the salsa input query for schemas is not invalidated. Validation continues using the old schema, missing the new rule. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-9.2 parent: H-9 @@ -309,6 +396,9 @@ sub-hazards: An external repository's artifacts change (new commit fetched), but the salsa queries for cross-repo link resolution are not invalidated. Broken cross-repo links are not detected. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- H-11 refinements: parser misparse scenarios --- - id: H-11.1 @@ -319,6 +409,9 @@ sub-hazards: git_override(commit="abc123"). The parser extracts the registry version but misses the override, causing validation against the wrong repo checkout. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-11.2 parent: H-11 @@ -328,6 +421,9 @@ sub-hazards: load() statements that the Starlark subset parser does not handle. The parser silently skips the unrecognized construct, missing a dependency declaration. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # --- H-13 refinements: XSS rendering paths --- - id: H-13.1 @@ -338,6 +434,9 @@ sub-hazards: `onerror` attributes. The dashboard's HTML rendering pipeline includes the content without sanitization in the artifact detail view, enabling script execution in the viewer's browser. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-13.2 parent: H-13 @@ -348,3 +447,6 @@ sub-hazards: only allows http/https/# URLs, but the img tag rendering path may not apply the same restriction, enabling script injection via crafted image sources. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/loss-scenarios.yaml b/safety/stpa/loss-scenarios.yaml index ed2dc12..56b473f 100644 --- a/safety/stpa/loss-scenarios.yaml +++ b/safety/stpa/loss-scenarios.yaml @@ -38,6 +38,9 @@ loss-scenarios: - Link resolution algorithm scopes search to per-file or per-directory - No integration test covers cross-source link resolution - Original design assumed single-source artifact stores + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-2 title: Coverage counts untyped links as valid traces @@ -56,6 +59,9 @@ loss-scenarios: - Coverage algorithm does not filter by link type semantics - Schema does not enforce required link types per artifact type - No distinction between informational and trace links + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-3 title: Schema fallback accepts unknown artifact types @@ -79,6 +85,9 @@ loss-scenarios: - No explicit "unknown type" rejection in the validation pipeline - Permissive default behavior chosen over strict validation - ReqIF adapter may produce types not in the schema registry + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-4 title: Partial source loading during incremental validation @@ -104,6 +113,9 @@ loss-scenarios: - No per-source load-status tracking in the process model - Filesystem errors during directory traversal are logged but not fatal - No pre-validation check that all sources are available + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # OSLC Client scenarios @@ -126,6 +138,9 @@ loss-scenarios: - Cursor advancement is not transactional with local writes - No journal or write-ahead log for sync operations - Retry logic starts from the current cursor, not from the last committed cursor + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-O-2 title: Last-writer-wins during concurrent modification @@ -147,6 +162,9 @@ loss-scenarios: - No ETag or timestamp comparison before local writes - No merge/diff presentation for conflicting changes - OSLC client assumes remote is authoritative for all fields + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-O-3 title: Phantom deletion propagates through sync @@ -170,6 +188,9 @@ loss-scenarios: - TRS protocol does not distinguish deletion from archival - No confirmation prompt before applying remote deletions - No "soft delete" or quarantine mechanism for remotely-deleted artifacts + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-O-4 title: Semantic mismatch in status mapping @@ -194,6 +215,9 @@ loss-scenarios: - Status mapping is hardcoded rather than configurable - No validation that all remote statuses have explicit mappings - External tool added new statuses after the mapping was created + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-O-5 title: Authentication token expiry during long sync @@ -212,6 +236,9 @@ loss-scenarios: - HTTP 401 responses not distinguished from empty TRS pages - No token refresh mechanism during long-running operations - Sync completion is reported based on reaching an empty page rather than the TRS base URI + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-O-6 title: Error amplification through bidirectional sync chain @@ -231,6 +258,9 @@ loss-scenarios: - No pre-push validation gate in the OSLC client - Outbound sync treats all local artifacts as authoritative - No circuit-breaker to halt sync when error rates exceed a threshold + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # ReqIF Adapter scenarios @@ -254,6 +284,9 @@ loss-scenarios: - Serde deserialization assumes grouped (non-interleaved) XML elements - ReqIF standard does not mandate element ordering within containers - Parser was tested only against ReqIF exports with grouped elements + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-Q-2 title: UUID-based identifiers lose human readability @@ -277,6 +310,9 @@ loss-scenarios: - ReqIF standard allows both patterns (UUID vs. human-readable IDENTIFIER) - No configurable ID-source field in the adapter - Fixed by adding ReqIF.ForeignID lookup, but other non-standard patterns may exist + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-Q-3 title: SpecRelation targets reference missing SpecObjects @@ -296,6 +332,9 @@ loss-scenarios: - ReqIF import processes files independently, not as a correlated set - Cross-file SpecRelation references are common in multi-module exports - No warning emitted for unresolvable SpecRelation targets + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # CLI scenarios @@ -317,6 +356,9 @@ loss-scenarios: - CLI writes directly to working-tree YAML files, no staging area - No file-locking mechanism to coordinate with editors - No atomic write (write-to-temp-then-rename) pattern used + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-L-2 title: Wrong adapter selected for ambiguous file format @@ -337,6 +379,9 @@ loss-scenarios: - Adapter selection based on file extension rather than content sniffing - No explicit --adapter flag required for ambiguous formats - No content-type validation before adapter dispatch + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # CI Pipeline scenarios @@ -357,6 +402,9 @@ loss-scenarios: - CI path filters are manually maintained, not derived from rivet.yaml - No CI test that validates the path filter matches all configured sources - Adding a new source directory is a multi-step process with no checklist + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-I-2 title: CI caches stale artifacts across workflow runs @@ -379,6 +427,9 @@ loss-scenarios: - Cache key does not include artifact file hashes - rivet validate may read cached intermediate state - No cache-busting mechanism for artifact-only changes + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # Control-path scenarios (Type B — not tied to a specific UCA) @@ -399,6 +450,9 @@ loss-scenarios: - YAML is line-based but semantically structured; git merges lines, not structure - Post-merge validation is not automatically triggered - Pre-commit hooks may not run during merge commits + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-CP-2 title: Network partition during OSLC sync corrupts local state @@ -417,6 +471,9 @@ loss-scenarios: - No transactional write mechanism for bulk artifact updates - Partial writes are committed to the working tree immediately - No rollback capability for interrupted sync operations + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # Incremental validation scenarios (UCA-C-10 through UCA-C-14) @@ -444,6 +501,9 @@ loss-scenarios: - No file-system watcher to detect external file modifications - Salsa inputs are set once at load time, not refreshed - Dashboard reload may not invalidate all salsa inputs + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-6 title: Conditional rule evaluation uses cached field values @@ -470,6 +530,9 @@ loss-scenarios: - Conditional rule query does not declare artifact fields as inputs - Salsa dependency tracking is opt-in per query - No test verifies conditional rule re-evaluation after field change + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-7 title: Contradictory conditional rules not detected at schema load @@ -489,6 +552,9 @@ loss-scenarios: - Schema merge is purely additive (union of all rules) - No SAT-solver or constraint check for rule compatibility - Conditional rules lack a priority or override mechanism + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-8 title: Incremental validation diverges from full validation after rename @@ -509,6 +575,9 @@ loss-scenarios: - Link resolution queries depend on the link graph, not individual files - Salsa does not track cross-file artifact ID dependencies - No periodic full-revalidation check is implemented + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-9 title: Schema loading races with conditional rule evaluation @@ -529,6 +598,9 @@ loss-scenarios: - Schema merge is incremental (file-by-file) rather than all-at-once - No barrier between "all schemas loaded" and "evaluation begins" - Salsa query ordering is determined by demand, not explicit sequencing + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # Parser scenarios (UCA-C-15 through UCA-C-17) @@ -551,6 +623,9 @@ loss-scenarios: - Parser's function recognition list is incomplete - git_override is parsed separately from bazel_dep with no linkage - No test covers the override-applies-to-dep scenario + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-11 title: Parser silently skips load() statement containing dependency @@ -569,6 +644,9 @@ loss-scenarios: - Starlark load() support is not implemented - Parser treats unrecognized lines as comments - No diagnostic emitted for skipped lines + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-12 title: Parser swaps keyword argument name and value @@ -586,6 +664,9 @@ loss-scenarios: - Parser uses positional extraction rather than keyword matching - No unit test for non-standard argument ordering - CST construction assumes a fixed parameter order + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # New UCA scenarios (STPA-Sec extension) @@ -609,6 +690,9 @@ loss-scenarios: - Multiple rendering paths with inconsistent escaping - serve.rs generates HTML via string formatting, not a template engine - No Content-Security-Policy header to mitigate XSS + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-13 title: WASM adapter returns fabricated artifacts @@ -633,6 +717,9 @@ loss-scenarios: - WASM adapter output is trusted without independent verification - No artifact-count or hash-based integrity check on adapter output - Binary WASM components cannot be source-reviewed by users + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-14 title: Commit trailer false positive from non-artifact ID @@ -652,6 +739,9 @@ loss-scenarios: - is_artifact_id() uses an overly broad pattern - No validation against the actual artifact store during extraction - Standard identifiers (ISO numbers, MISRA rules) match the pattern + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-L-3 title: Git clone executes malicious post-checkout hook @@ -672,6 +762,9 @@ loss-scenarios: - git clone enables hooks by default - No URL allowlist or domain restriction for external repos - sync command does not use --config core.hooksPath=/dev/null + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-L-4 title: Salsa memoization returns cached diagnostics from a previous file version @@ -696,6 +789,9 @@ loss-scenarios: - didSave handler does not re-read file and update salsa input - Salsa input queries are set once at file-open time - No test verifies that diagnostics change after a file save + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-L-5 title: VS Code extension postMessage handler accepts navigation commands from any origin @@ -716,6 +812,9 @@ loss-scenarios: - WebView message handler does not validate event.origin - No Content-Security-Policy restricting frame-ancestors - VS Code extension API allows cross-extension WebView messaging + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: LS-C-15 title: Lifecycle check misses cybersecurity downstream requirements @@ -741,6 +840,9 @@ loss-scenarios: - expected_downstream() is hardcoded, not derived from schema rules - No test covers lifecycle checks with non-dev schemas - The module was written for the dev schema and not generalized + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ========================================================================= # MCP Server scenarios @@ -764,3 +866,6 @@ loss-scenarios: - MCP server caches project state across tool calls without invalidation - No file-change detection between MCP invocations - AI agent trusts MCP results without independent verification + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/losses.yaml b/safety/stpa/losses.yaml index 73d4519..c94d635 100644 --- a/safety/stpa/losses.yaml +++ b/safety/stpa/losses.yaml @@ -31,6 +31,9 @@ losses: (ISO 26262, DO-178C, ASPICE), incomplete traceability can mask gaps that lead to fielded defects in safety-relevant functions. stakeholders: [safety-engineers, certification-authorities, developers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-2 title: Loss of compliance evidence @@ -40,6 +43,9 @@ losses: organization cannot pass certification audits, potentially blocking product release or triggering recalls. stakeholders: [certification-authorities, project-managers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-3 title: Loss of data sovereignty @@ -50,6 +56,9 @@ losses: content to unauthorized systems, the organization loses control of its engineering records. stakeholders: [safety-engineers, tool-administrators, developers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-4 title: Loss of engineering productivity @@ -58,6 +67,9 @@ losses: debugging sync failures, or reconciling conflicting artifact versions instead of performing value-adding engineering work. stakeholders: [developers, project-managers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-5 title: Loss of safety assurance @@ -67,6 +79,9 @@ losses: safety argument. This can result in hazardous system behavior reaching production. stakeholders: [safety-engineers, certification-authorities] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-6 title: Loss of audit trail @@ -76,3 +91,6 @@ losses: root-cause analysis after incidents is impaired and regulatory post-market obligations cannot be met. stakeholders: [certification-authorities, safety-engineers, project-managers] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/lsp-diagnostics.yaml b/safety/stpa/lsp-diagnostics.yaml index ec7921c..4c635c6 100644 --- a/safety/stpa/lsp-diagnostics.yaml +++ b/safety/stpa/lsp-diagnostics.yaml @@ -10,6 +10,9 @@ losses: The LSP fails to report a validation error, and the user commits artifacts that violate schema rules. In safety-critical contexts (ASPICE, EU AI Act), this means non-compliant deliverables. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-LSP-002 title: User wastes time fixing false positive diagnostics @@ -17,6 +20,9 @@ losses: The LSP reports an error that doesn't actually exist. The user (or AI agent) spends effort fixing a non-issue, or worse, introduces a real bug while "fixing" the false positive. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-LSP-003 title: AI agent enters infinite retry loop on wrong diagnostic @@ -24,6 +30,9 @@ losses: An AI coding agent receives a diagnostic from the LSP, attempts a fix, but the diagnostic persists because it was either wrong or the fix approach was incorrect. The agent retries indefinitely. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z hazards: - id: H-LSP-001 @@ -35,6 +44,9 @@ hazards: links: - type: leads-to-loss target: L-LSP-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-LSP-002 title: LSP reports errors that don't exist (false positive) @@ -47,6 +59,9 @@ hazards: target: L-LSP-002 - type: leads-to-loss target: L-LSP-003 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-LSP-003 title: LSP diagnostic points to wrong location or file @@ -56,6 +71,9 @@ hazards: links: - type: leads-to-loss target: L-LSP-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-LSP-004 title: LSP provides misleading fix suggestion @@ -66,6 +84,9 @@ hazards: links: - type: leads-to-loss target: L-LSP-003 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z system-constraints: - id: SC-LSP-001 @@ -77,6 +98,9 @@ system-constraints: links: - type: prevents target: H-LSP-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-LSP-002 title: All validation phases in CLI MUST also run in LSP @@ -87,6 +111,9 @@ system-constraints: links: - type: prevents target: H-LSP-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-LSP-003 title: LSP must report diagnostics at the correct byte range from the rowan CST @@ -97,6 +124,9 @@ system-constraints: links: - type: prevents target: H-LSP-003 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-LSP-004 title: Schema changes MUST invalidate cached diagnostics @@ -107,6 +137,9 @@ system-constraints: links: - type: prevents target: H-LSP-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-LSP-005 title: YAML type coercion MUST be handled explicitly @@ -117,6 +150,9 @@ system-constraints: links: - type: prevents target: H-LSP-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-LSP-006 title: Diagnostic messages MUST include actionable fix guidance @@ -127,6 +163,9 @@ system-constraints: links: - type: prevents target: H-LSP-004 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-LSP-007 title: LSP MUST NOT cascade errors from parse failures @@ -137,6 +176,9 @@ system-constraints: links: - type: prevents target: H-LSP-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-LSP-008 title: Incremental validation MUST produce identical results to full validation @@ -149,3 +191,6 @@ system-constraints: target: H-LSP-001 - type: prevents target: H-LSP-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/system-constraints.yaml b/safety/stpa/system-constraints.yaml index 196e49b..a5b1c23 100644 --- a/safety/stpa/system-constraints.yaml +++ b/safety/stpa/system-constraints.yaml @@ -19,6 +19,9 @@ system-constraints: typed targets. Dangling or type-mismatched links must be reported as errors, not warnings. hazards: [H-1] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-2 title: Rivet must never silently discard artifacts or links during any operation @@ -28,6 +31,9 @@ system-constraints: an explicit diagnostic and either fail the operation or quarantine the unprocessable item — never silently drop it. hazards: [H-2] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-3 title: Rivet must compute coverage metrics only from validated, current data @@ -36,6 +42,9 @@ system-constraints: has passed validation in the current session. Cached or stale metrics must be clearly labeled with their validation timestamp. hazards: [H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-4 title: Rivet must verify semantic compatibility when mapping between schemas @@ -45,6 +54,9 @@ system-constraints: flows through them. Unmapped or ambiguously mapped fields must cause a hard error, not a silent default. hazards: [H-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-5 title: Rivet must detect and surface conflicting modifications before merging @@ -53,6 +65,9 @@ system-constraints: the conflict (via timestamps, ETags, or content hashing), present it to the user, and require explicit resolution before committing. hazards: [H-5] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-6 title: Rivet must generate compliance reports only from verified traceability data @@ -61,6 +76,9 @@ system-constraints: If validation has not been run or has failed, report generation must either fail or embed a prominent "UNVERIFIED" watermark. hazards: [H-6] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-7 title: Rivet must maintain a complete audit trail for all artifact modifications @@ -70,6 +88,9 @@ system-constraints: preserved in the git history. Sync operations must record the remote source and change event identifier. hazards: [H-7] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-8 title: Rivet must isolate sync errors and prevent cross-tool error propagation @@ -79,6 +100,9 @@ system-constraints: to outbound sync channels or to local authoritative stores without explicit user approval. hazards: [H-8] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-9 title: Rivet must preserve rich-text structure during import and export @@ -88,6 +112,9 @@ system-constraints: has been simplified, so users can verify no semantic information was lost. hazards: [H-4] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-10 title: Rivet must validate external artifact existence before accepting links @@ -97,6 +124,9 @@ system-constraints: exists and is reachable at link-creation time, recording the verification timestamp. hazards: [H-1, H-3] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-11 title: Rivet incremental validation must produce identical results to full validation @@ -106,6 +136,9 @@ system-constraints: validation pass. If incremental and full results ever diverge, the system must detect the divergence and fall back to full validation. hazards: [H-9] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-12 title: Rivet must verify conditional rule consistency before applying rules @@ -115,6 +148,9 @@ system-constraints: on a single artifact. Inconsistent rule sets must be rejected at schema load time with a diagnostic identifying the conflicting rules. hazards: [H-10] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-13 title: Rivet build-system parsers must reject unrecognized constructs with diagnostics @@ -125,6 +161,9 @@ system-constraints: is forbidden. The parser must report what it could not parse and what dependencies may be missing from the result. hazards: [H-11] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-14 title: Rivet formal proofs must be validated against the implementation under test @@ -135,6 +174,9 @@ system-constraints: Rocq specifications must be generated from or validated against the Rust source via coq-of-rust, not hand-written independently. hazards: [H-12] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-15 title: Rivet must HTML-escape all artifact content before rendering in dashboard or document views @@ -146,6 +188,9 @@ system-constraints: boundary (rendering), not at the input boundary (parsing), to ensure defense-in-depth. hazards: [H-13] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-16 title: Rivet must validate WASM adapter outputs against the source data independently @@ -158,6 +203,9 @@ system-constraints: insufficient because a compromised adapter could produce schema-conforming but fabricated artifacts. hazards: [H-14] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-17 title: Rivet commit analysis must validate extracted artifact IDs against the known artifact set before counting coverage @@ -168,6 +216,9 @@ system-constraints: store.contains() check. Unresolved references must be reported as broken refs, not counted as coverage. hazards: [H-15] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-18 title: Rivet dashboard must report reload failures and indicate stale data state @@ -179,6 +230,9 @@ system-constraints: reload occurs, (c) log the specific error that caused the reload failure. hazards: [H-16] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-19 title: Rivet must not execute git clone/fetch against untrusted URLs without hook protection @@ -189,6 +243,9 @@ system-constraints: cloned repository (--config core.hooksPath=/dev/null) to prevent arbitrary code execution. hazards: [H-17] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-20 title: Rivet must normalize and validate external paths before use @@ -198,6 +255,9 @@ system-constraints: if a path is unreachable rather than silently skipping the external. Platform-specific path separators must be normalized. hazards: [H-18] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-21 title: LSP must re-validate within 500ms of file save for stores under 5000 artifacts @@ -208,6 +268,9 @@ system-constraints: fewer than 5000 artifacts. Stale cached results must never be returned after a save event. hazards: [H-19] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-22 title: VS Code WebView must use sandbox restrictions for dashboard content @@ -218,6 +281,9 @@ system-constraints: postMessage commands from the extension host and must not relay artifact data to untrusted iframe origins. hazards: [H-20] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-23 title: Rivet MCP server must not return stale data after disk changes @@ -227,6 +293,9 @@ system-constraints: returning results. Alternatively, the server must provide an explicit reload mechanism and document that results may be stale. hazards: [H-21] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-24 title: Rivet must preserve YAML content byte-for-byte during round-trip operations @@ -236,3 +305,6 @@ system-constraints: specific insertion or modification should differ. Verified by the rowan round-trip test suite (66 files, 83 edge cases). hazards: [H-24] + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/ucas.yaml b/safety/stpa/ucas.yaml index e7ebe45..14a6264 100644 --- a/safety/stpa/ucas.yaml +++ b/safety/stpa/ucas.yaml @@ -37,6 +37,9 @@ core-ucas: rationale: > Stale links from the previous validation state are used to generate coverage metrics, hiding newly introduced gaps. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-2 description: > @@ -49,6 +52,9 @@ core-ucas: rationale: > Coverage metrics computed from the stale graph do not account for links added or removed by the sync. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-3 description: > @@ -60,6 +66,9 @@ core-ucas: rationale: > Schema-violating artifacts may have mistyped fields, missing required attributes, or incorrect link semantics. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-C-4 @@ -73,6 +82,9 @@ core-ucas: rationale: > A false-positive validation result causes downstream reports and CI gates to treat the traceability as complete. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-5 description: > @@ -85,6 +97,9 @@ core-ucas: rationale: > Coverage percentage is inflated because unresolvable links are counted as coverage rather than gaps. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-6 description: > @@ -96,6 +111,9 @@ core-ucas: rationale: > Artifacts with incorrect or missing fields pass validation, allowing semantically invalid data into the traceability store. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: - id: UCA-C-7 @@ -109,6 +127,9 @@ core-ucas: Links to artifacts in not-yet-loaded sources are reported as dangling, producing false negatives, or worse — if the incomplete set passes validation, gaps are hidden. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-8 description: > @@ -121,6 +142,9 @@ core-ucas: rationale: > The report reflects the previously validated state, not the current state, potentially showing stale coverage data. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z stopped-too-soon: - id: UCA-C-9 @@ -134,6 +158,9 @@ core-ucas: rationale: > Engineers fix only the first error and assume the rest is clean, leaving other validation failures undetected. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ============================================================================= # OSLC Client UCAs — CA-OSLC-1: Fetch remote artifacts @@ -156,6 +183,9 @@ oslc-ucas: rationale: > Local artifact store becomes increasingly stale relative to the external tool, and links to external artifacts may dangle. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-O-2 description: > @@ -167,6 +197,9 @@ oslc-ucas: rationale: > The external tool's view of traceability diverges from the local authoritative state, causing inconsistencies. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-O-3 description: > @@ -178,6 +211,9 @@ oslc-ucas: rationale: > Developer believes sync succeeded when it did not, and proceeds to generate reports from stale data. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-O-4 @@ -191,6 +227,9 @@ oslc-ucas: rationale: > The last-writer-wins behavior silently discards the local engineer's changes, including potential safety-critical edits. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-O-5 description: > @@ -203,6 +242,9 @@ oslc-ucas: rationale: > Phantom deletion — the artifact disappears from the local store, creating dangling links and coverage gaps. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-O-6 description: > @@ -215,6 +257,9 @@ oslc-ucas: rationale: > Corruption flows from the external tool into Rivet's local store and potentially onward to other connected tools. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-O-7 description: > @@ -226,6 +271,9 @@ oslc-ucas: rationale: > Error propagation in the outbound direction corrupts the external tool's data, affecting all users of that tool. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: - id: UCA-O-8 @@ -239,6 +287,9 @@ oslc-ucas: rationale: > The compliance report does not reflect the current state of traceability, misleading auditors. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-O-9 description: > @@ -251,6 +302,9 @@ oslc-ucas: rationale: > Interleaved writes corrupt the YAML structure or silently overwrite in-progress edits. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z stopped-too-soon: - id: UCA-O-10 @@ -265,6 +319,9 @@ oslc-ucas: Partial sync creates an inconsistent local state — some links resolve to updated targets while others point to stale data, and the sync cursor may be advanced past un-applied changes. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ============================================================================= # ReqIF Adapter UCAs — CA-REQIF-1: Import ReqIF artifacts @@ -286,6 +343,9 @@ reqif-ucas: rationale: > Requirements from the external tool are silently lost during import, creating gaps in the traceability chain. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-Q-2 description: > @@ -298,6 +358,9 @@ reqif-ucas: rationale: > Downstream tools retain a deleted artifact, creating phantom links and inconsistent traceability state. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-Q-3 @@ -311,6 +374,9 @@ reqif-ucas: rationale: > Semantically mismatched artifacts pass validation against a permissive schema but distort the traceability argument. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-Q-4 description: > @@ -324,6 +390,9 @@ reqif-ucas: rationale: > Duplicate artifacts inflate coverage metrics and create ambiguous link targets. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-Q-5 description: > @@ -336,6 +405,9 @@ reqif-ucas: rationale: > Downstream tools receive impoverished content that may be misinterpreted, breaking the semantic chain. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: - id: UCA-Q-6 @@ -349,6 +421,9 @@ reqif-ucas: rationale: > Without schema context, the adapter cannot validate or correctly map imported artifact types. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z stopped-too-soon: - id: UCA-Q-7 @@ -362,6 +437,9 @@ reqif-ucas: rationale: > A single bad record causes loss of all subsequent records, potentially dropping hundreds of requirements. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z # ============================================================================= # CLI UCAs — CA-CLI-1: Invoke core operations @@ -384,6 +462,9 @@ cli-ucas: rationale: > Warnings about potential issues (e.g., weak link types, deprecated schemas) are ignored, accumulating into failures. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-L-2 description: > @@ -395,6 +476,9 @@ cli-ucas: rationale: > Reports are generated from unvalidated data, potentially containing dangling links or schema violations. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-L-3 @@ -408,6 +492,9 @@ cli-ucas: rationale: > Developer's in-progress work is silently destroyed by the sync operation. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-L-4 description: > @@ -419,6 +506,9 @@ cli-ucas: rationale: > Using the wrong adapter applies incorrect schema mappings, producing semantically invalid artifacts. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: - id: UCA-L-5 @@ -432,6 +522,9 @@ cli-ucas: rationale: > Race condition between sync writes and editor saves causes data loss or YAML corruption. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z stopped-too-soon: [] @@ -455,6 +548,9 @@ ci-ucas: rationale: > Broken links or schema violations are merged into the main branch without detection. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-I-2 description: > @@ -466,6 +562,9 @@ ci-ucas: rationale: > Developers see the failure but can merge anyway, allowing known traceability issues into the baseline. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-I-3 @@ -479,6 +578,9 @@ ci-ucas: rationale: > CI green-lights a merge despite validation failures, creating false confidence in the traceability state. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: - id: UCA-I-4 @@ -492,6 +594,9 @@ ci-ucas: rationale: > Validation passes against old data while the current data contains new issues. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z stopped-too-soon: [] @@ -513,6 +618,9 @@ dashboard-ucas: rationale: > Engineers see green metrics without the associated caveats, developing false confidence in traceability completeness. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-D-2 @@ -526,6 +634,9 @@ dashboard-ucas: rationale: > Stale metrics mislead developers and auditors about the current state of traceability. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] @@ -552,6 +663,9 @@ incremental-ucas: rationale: > The fundamental incremental correctness property is violated: stale cached validation results create false assurance. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-11 description: > @@ -567,6 +681,9 @@ incremental-ucas: The newly-approved artifact lacks verification-criteria but the conditional rule does not fire because salsa returns the cached result from when the artifact was still draft. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-C-12 @@ -581,6 +698,9 @@ incremental-ucas: rationale: > Contradictory rules make compliance impossible. Engineers disable or work around rules, undermining the validation system. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-13 description: > @@ -593,6 +713,9 @@ incremental-ucas: rationale: > Divergence between incremental and full results means the tool cannot be trusted. Safety-critical tooling must be deterministic. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: - id: UCA-C-14 @@ -606,6 +729,9 @@ incremental-ucas: rationale: > Missing conditional rules means violations go undetected. Rules added later in the schema merge are never applied. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z stopped-too-soon: [] @@ -629,6 +755,9 @@ parser-ucas: rationale: > Validation runs against the wrong version of the external repo, producing coverage results that don't match the actual baseline. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-16 description: > @@ -641,6 +770,9 @@ parser-ucas: rationale: > Missing dependencies are not reported. Cross-repo validation has blind spots where repos are not discovered. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-C-17 @@ -654,6 +786,9 @@ parser-ucas: rationale: > Cross-repo links resolve against a different module than intended, producing silently incorrect traceability. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] @@ -678,6 +813,9 @@ dashboard-rendering-ucas: rationale: > Script injection in the dashboard compromises the integrity of all displayed traceability data for the current session. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z not-providing: - id: UCA-D-4 @@ -691,6 +829,9 @@ dashboard-rendering-ucas: rationale: > User believes they are viewing current data when the dashboard is serving stale state from before the edit. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] @@ -715,6 +856,9 @@ commit-ucas: rationale: > False positive artifact references inflate commit coverage metrics, creating an illusion of implementation completeness. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z not-providing: - id: UCA-C-19 @@ -731,6 +875,9 @@ commit-ucas: False negative artifact references create coverage gaps. STPA artifacts (the most safety-critical) are systematically missed by the commit traceability engine. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] @@ -754,6 +901,9 @@ cross-repo-ucas: rationale: > Circular dependencies cause the tool to hang or crash during sync, preventing any validation from completing. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: [] too-early-too-late: [] @@ -781,6 +931,9 @@ wasm-ucas: Fabricated artifacts pass schema validation because they conform to the declared types, but they introduce false traceability links that inflate coverage. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-22 description: > @@ -794,6 +947,9 @@ wasm-ucas: rationale: > Denial of service during import blocks all validation and reporting operations. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-C-23 description: > @@ -807,6 +963,9 @@ wasm-ucas: rationale: > Information disclosure of host filesystem structure aids further attacks. Arbitrary file read enables data exfiltration. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] @@ -837,6 +996,9 @@ lifecycle-ucas: Lifecycle gap analysis produces false negatives for schemas other than the dev schema, missing genuine coverage gaps in cybersecurity and STPA domains. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] @@ -862,6 +1024,9 @@ document-validation-ucas: Broken artifact embeds in documents are invisible to validation, producing documents that appear complete but contain broken references. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: [] too-early-too-late: [] @@ -890,6 +1055,9 @@ external-sync-ucas: The rivet.yaml file is the trust boundary for external dependencies. A compromised config file enables arbitrary code execution on the developer's machine. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: UCA-L-7 description: > @@ -904,6 +1072,9 @@ external-sync-ucas: The lock file is meant to ensure reproducible baselines. If it can be tampered with undetected, the baseline guarantee is void. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] @@ -929,6 +1100,9 @@ lsp-ucas: rationale: > Stale diagnostics in the editor create false confidence that the artifact file is valid, hiding newly introduced traceability errors. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z providing: - id: UCA-C-27 @@ -945,6 +1119,72 @@ lsp-ucas: rationale: > Unrestricted postMessage handling allows data exfiltration of sensitive artifacts without requiring filesystem access. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z too-early-too-late: [] stopped-too-soon: [] + +# ============================================================================= +# MCP Server UCAs — CA-MCP-1: Return validation results +# CA-MCP-2: Return artifact data and coverage metrics +# ============================================================================= +mcp-ucas: + control-action: Return validation results, artifact data, coverage metrics + controller: CTRL-CLI + + not-providing: + - id: UCA-M-1 + description: > + MCP server does not reload project state between tool calls, + returning stale validation results after files change. + context: > + An AI agent modifies an artifact YAML file via a tool call, + then immediately queries validation status. The MCP server + returns cached results from before the modification. + hazards: [H-IMPL-003] + rationale: > + Stale validation results cause the agent to believe its changes + are valid when they may have introduced errors, or to continue + fixing issues that no longer exist. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + providing: + - id: UCA-M-2 + description: > + MCP server returns partial artifact list when loading fails + for one source directory, without indicating the failure. + context: > + One of the configured source directories contains a YAML file + with a parse error. The MCP server loads artifacts from other + directories successfully but silently omits the failing directory. + hazards: [H-1, H-3] + rationale: > + An incomplete artifact list produces incorrect coverage metrics + and missing link targets. The agent cannot detect the omission + because no error is reported. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + too-early-too-late: + - id: UCA-M-3 + description: > + MCP server returns coverage metrics before validation completes + on modified files. + context: > + An agent requests coverage immediately after modifying artifacts. + The MCP server computes coverage from the pre-modification + validation state because re-validation has not yet run. + hazards: [H-3, H-IMPL-003] + rationale: > + Coverage metrics reflect the old artifact state, misleading the + agent about the impact of its changes. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z + + stopped-too-soon: [] diff --git a/safety/stpa/v031-implementation.yaml b/safety/stpa/v031-implementation.yaml index 658632b..1f9b66d 100644 --- a/safety/stpa/v031-implementation.yaml +++ b/safety/stpa/v031-implementation.yaml @@ -10,6 +10,9 @@ losses: The HTML export shows delta values, coverage, or diagnostics that don't match the actual project state. A compliance reviewer (or notified body for EU AI Act) relies on incorrect data. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-IMPL-002 title: MCP tool returns invalid or inconsistent results @@ -17,6 +20,9 @@ losses: An AI agent queries rivet via MCP and receives wrong data (stale validation, incorrect artifact list, wrong stats). Agent makes decisions based on incorrect information. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-IMPL-003 title: Schema bridge creates incorrect traceability @@ -24,6 +30,9 @@ losses: A bridge schema links artifact types that shouldn't be linked, or fails to link types that should be. Coverage reports show false completeness or false gaps. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: L-IMPL-004 title: Convergence tracker gives wrong escalation guidance @@ -31,6 +40,9 @@ losses: The convergence tracker either fails to detect a stuck loop (agent burns tokens forever) or falsely escalates when the agent is actually making progress. + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z hazards: - id: H-IMPL-001 @@ -42,6 +54,9 @@ hazards: links: - type: leads-to-loss target: L-IMPL-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-IMPL-002 title: Delta auto-detection picks wrong baseline @@ -52,6 +67,9 @@ hazards: links: - type: leads-to-loss target: L-IMPL-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-IMPL-003 title: MCP server loads stale project state @@ -62,6 +80,9 @@ hazards: links: - type: leads-to-loss target: L-IMPL-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-IMPL-004 title: Embed resolver renders outdated data in export @@ -72,6 +93,9 @@ hazards: links: - type: leads-to-loss target: L-IMPL-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-IMPL-005 title: Bridge schema creates bidirectional rule conflicts @@ -82,6 +106,9 @@ hazards: links: - type: leads-to-loss target: L-IMPL-003 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: H-IMPL-006 title: Failure signature hash collision @@ -92,6 +119,9 @@ hazards: links: - type: leads-to-loss target: L-IMPL-004 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z system-constraints: - id: SC-IMPL-001 @@ -103,6 +133,9 @@ system-constraints: links: - type: prevents target: H-IMPL-001 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-IMPL-002 title: Delta MUST compare against the PREVIOUS release snapshot @@ -113,6 +146,9 @@ system-constraints: links: - type: prevents target: H-IMPL-002 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-IMPL-003 title: MCP tools MUST reload project state on each call @@ -123,6 +159,9 @@ system-constraints: links: - type: prevents target: H-IMPL-003 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-IMPL-004 title: Embed resolver MUST use current AppState, not cached data @@ -133,6 +172,9 @@ system-constraints: links: - type: prevents target: H-IMPL-004 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-IMPL-005 title: Bridge schemas MUST NOT define artifact types @@ -143,6 +185,9 @@ system-constraints: links: - type: prevents target: H-IMPL-005 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z - id: SC-IMPL-006 title: Convergence signature MUST include all diagnostic fields @@ -154,3 +199,6 @@ system-constraints: links: - type: prevents target: H-IMPL-006 + provenance: + created-by: ai-assisted + timestamp: 2026-04-07T03:40:26Z diff --git a/safety/stpa/variant-hazards.yaml b/safety/stpa/variant-hazards.yaml new file mode 100644 index 0000000..82dcc9b --- /dev/null +++ b/safety/stpa/variant-hazards.yaml @@ -0,0 +1,479 @@ +# ============================================================================= +# STPA Analysis — Variant/PLE System Hazards +# ============================================================================= +# +# Safety analysis for the product line engineering (PLE) and variant +# management features added in v0.4.0. These hazards are specific to +# the three-layer architecture (feature model / variant config / binding) +# and the s-expression query/constraint language. +# +# The variant system introduces new failure modes beyond base rivet: +# variant-scoped validation can miss gaps that exist in specific +# configurations even when the union of all artifacts passes. The +# constraint solver can be unsound (accepting invalid configs) or +# incomplete (rejecting valid ones). Bindings can drift from both +# the feature model and the artifacts they reference. +# +# Reference: STPA Handbook, §2.3.2 +# ============================================================================= + +hazards: + - id: H-VAR-001 + title: Variant-scoped validation passes but traceability gaps exist in deployed configuration + description: > + The constraint solver accepts a variant configuration and per-variant + validation reports PASS, but the effective artifact subset has + incomplete traceability chains. This is the most severe variant hazard + because it produces false confidence in a specific product + configuration's safety compliance. Causes include: unsound constraint + propagation missing a required feature, stale bindings referencing + deleted artifacts still counting as "bound," or the evaluator + incorrectly scoping the artifact set. + losses: [L-1, L-2, L-5] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: H-VAR-002 + title: Feature model constraints are satisfiable but do not capture real-world exclusions + description: > + The feature model's cross-tree constraints are satisfiable (the solver + says the model is valid), but they fail to encode actual domain + constraints. For example, a real regulatory constraint that "autonomous + driving requires ASIL D" is missing from the feature model, so + a variant selecting autonomous + ASIL B is accepted. The tool cannot + detect what domain experts forgot to encode — it can only validate + what's written. + losses: [L-1, L-5] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: H-VAR-003 + title: Binding model drifts from actual artifact set without detection + description: > + Artifacts are added, removed, or refactored but the binding model is + not updated. Features reference artifact IDs that no longer exist or + source globs that no longer match any files. Stale bindings create + phantom coverage — the variant appears to have artifacts bound to + features, but the bound artifacts are gone. This is especially + dangerous in long-lived projects where binding maintenance is + neglected. + losses: [L-1, L-2, L-4] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: H-VAR-004 + title: S-expression query produces incorrect results due to evaluator bug + description: > + A bug in the s-expression evaluator (predicate evaluation, quantifier + scoping, or Datalog compilation) causes a filter or traceability rule + to return wrong results. This affects all downstream consumers: CLI + --filter shows wrong artifacts, traceability rules pass when they + should fail, constraint solver accepts invalid configurations. The + s-expression evaluator is the single point of failure for all query + and validation logic. + losses: [L-1, L-2, L-5] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: H-VAR-005 + title: Variant validation omits artifacts that should be in scope + description: > + The variant-scoped artifact collection (feature → binding → artifact) + misses artifacts that should be included. This can happen when an + artifact satisfies a requirement that is bound to a feature, but the + artifact itself is not directly bound. The binding model captures + direct bindings but may miss indirect dependencies via the link graph. + Result: coverage appears lower than reality (optimistic: less bad) + or higher than reality (if the missing artifact had failing checks). + losses: [L-1, L-4] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: H-VAR-006 + title: Constraint solver non-termination or excessive runtime on large feature models + description: > + The Datalog evaluator or constraint propagation enters a long-running + or non-terminating computation on a feature model with many cross-tree + constraints. While Datalog guarantees termination in theory, a bug in + the semi-naive evaluator (e.g., missing fixpoint check) or pathological + constraint interaction could cause the solver to spin. In CI, this + manifests as a hung validation step. + losses: [L-4] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: H-VAR-007 + title: Different users get different validation results for the same variant + description: > + Non-determinism in the evaluator (e.g., hash map iteration order, + parallel evaluation races, or platform-dependent floating-point in + aggregation) causes different validation results on different + machines or runs. This breaks reproducibility — a variant that passes + in CI fails on a developer's machine or vice versa. + losses: [L-2, L-4] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + +# ============================================================================= +# System Constraints for Variant Hazards +# ============================================================================= + +system-constraints: + - id: SC-VAR-001 + title: Per-variant validation must detect all traceability gaps within the variant's artifact subset + hazards: [H-VAR-001] + description: > + When validating a variant, the system must apply all traceability rules + to the variant's effective artifact set and report any gaps. The variant + validation result must be identical to what would be obtained by + extracting only the variant's artifacts into a standalone project and + running full validation. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: SC-VAR-002 + title: Constraint solver must be sound — accepted configurations must satisfy all constraints + hazards: [H-VAR-002] + description: > + If the constraint solver accepts a variant configuration, every + cross-tree constraint in the feature model must hold for the derived + effective feature set. Soundness is non-negotiable; completeness + (rejecting valid configs) is acceptable as a conservative failure mode. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: SC-VAR-003 + title: Binding validation must detect stale references on every validation run + hazards: [H-VAR-003] + description: > + Every validation run must verify that all artifact IDs in bindings + exist in the current artifact store and all source globs match at + least one file. Stale bindings must produce error-level diagnostics, + not warnings, because they directly affect variant scoping correctness. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: SC-VAR-004 + title: S-expression evaluator must produce identical results to a reference implementation + hazards: [H-VAR-004] + description: > + The s-expression evaluator must be testable against a reference + implementation or formal specification. For every predicate and + quantifier, the evaluator's result must match a specification-defined + truth table. Property-based tests must verify equivalences (e.g., + (not (and A B)) == (or (not A) (not B))). + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: SC-VAR-005 + title: Variant artifact scoping must include transitive dependencies + hazards: [H-VAR-005] + description: > + When collecting artifacts for a variant, the system must follow + link graph edges to include transitively reachable artifacts, not + only directly bound ones. The scoping algorithm must be documented + and configurable (direct-only vs transitive). + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: SC-VAR-006 + title: Datalog evaluator must enforce a fixpoint iteration bound + hazards: [H-VAR-006] + description: > + The semi-naive evaluator must enforce a maximum iteration count + (proportional to the number of facts) and abort with a clear error + if the fixpoint is not reached. This provides a safety net even + though Datalog theoretically guarantees termination. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: SC-VAR-007 + title: Validation results must be deterministic across platforms and runs + hazards: [H-VAR-007] + description: > + The evaluator must use deterministic data structures (BTreeMap, sorted + iteration) and avoid floating-point in boolean logic. Validation + results for the same inputs must be byte-identical across platforms, + compiler versions, and repeated runs. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + +# ============================================================================= +# UCAs for Variant Controller (CTRL-CORE handling variant commands) +# ============================================================================= + +ucas: + - id: UCA-VAR-001 + title: Core engine validates variant without loading binding model + controller: CTRL-CORE + control-action: CA-CORE-1 + uca-type: not-providing + context: > + User runs rivet validate --variant X, but the binding model file is + missing or not declared in rivet.yaml. + hazards: [H-VAR-001] + rationale: > + Without bindings, the system cannot determine which artifacts belong + to the variant. Validating the entire artifact set as if it were the + variant gives a false PASS. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: UCA-VAR-002 + title: Core engine accepts variant configuration without running constraint solver + controller: CTRL-CORE + control-action: CA-CORE-1 + uca-type: not-providing + context: > + User defines a variant that violates cross-tree constraints, but the + system skips constraint solving and proceeds to validation. + hazards: [H-VAR-002] + rationale: > + An invalid variant configuration (e.g., autonomous + ASIL B when the + model requires ASIL D for autonomous) should be rejected before + artifact validation begins. Skipping the solver masks configuration errors. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: UCA-VAR-003 + title: Core engine does not re-validate bindings when artifacts change + controller: CTRL-CORE + control-action: CA-CORE-1 + uca-type: too-late + context: > + Artifacts are modified (IDs renamed, deleted) between binding file + creation and validation run. salsa caches stale binding validation. + hazards: [H-VAR-003] + rationale: > + Binding validation must be invalidated when the artifact store changes. + If salsa's dependency tracking doesn't cover the binding→artifact edge, + stale bindings persist across incremental runs. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: UCA-VAR-004 + title: S-expression evaluator applies wrong quantifier scope in nested expressions + controller: CTRL-CORE + control-action: CA-CORE-1 + uca-type: wrong-action + context: > + User writes (forall (where type "req") (exists (linked-by satisfies) + (where type "spec"))) — evaluator binds the inner (where) to the + wrong scope, checking all specs instead of only linked specs. + hazards: [H-VAR-004] + rationale: > + Nested quantifier scoping is the most error-prone part of the evaluator. + Wrong scoping can make a traceability rule vacuously true (exists + over the whole artifact set instead of the linked subset). + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: UCA-VAR-005 + title: CLI reports variant validation PASS without indicating that binding coverage is incomplete + controller: CTRL-CLI + control-action: CA-CLI-1 + uca-type: not-providing + context: > + Variant validation passes all traceability rules, but 40% of the + variant's features have no artifact bindings. The CLI does not + report unbound features. + hazards: [H-VAR-001, H-VAR-003] + rationale: > + A variant that "passes" but has many unbound features is misleadingly + reported as compliant. The CLI must report binding coverage alongside + traceability coverage. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + +# ============================================================================= +# Controller Constraints for Variant UCAs +# ============================================================================= + +controller-constraints: + - id: CC-VAR-001 + title: validate --variant must require binding model presence + controller: CTRL-CORE + constraint: > + The validation engine must emit an error-level diagnostic if + --variant is specified but no binding model is loaded. Variant + validation must not fall back to union validation silently. + ucas: [UCA-VAR-001] + hazards: [H-VAR-001] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: CC-VAR-002 + title: Constraint solver must run before variant-scoped validation + controller: CTRL-CORE + constraint: > + The pipeline order must be: (1) parse feature model, (2) solve + constraints for selected variant, (3) if valid, collect bound + artifacts, (4) validate traceability within scope. Step 4 must + not execute if step 2 fails. + ucas: [UCA-VAR-002] + hazards: [H-VAR-002] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: CC-VAR-003 + title: Binding staleness check must precede variant scoping + controller: CTRL-CORE + constraint: > + Before using bindings to scope artifacts, the system must verify + all referenced artifact IDs exist and all source globs resolve. + Stale binding detection must invalidate salsa cached results. + ucas: [UCA-VAR-003] + hazards: [H-VAR-003] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: CC-VAR-004 + title: S-expression evaluator must have property-based tests for logical equivalences + controller: CTRL-CORE + constraint: > + Property-based tests (proptest) must verify: De Morgan's laws, + double negation elimination, implies expansion, quantifier duality, + and commutativity of and/or. These serve as regression detection + for evaluator correctness. + ucas: [UCA-VAR-004] + hazards: [H-VAR-004] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: CC-VAR-005 + title: CLI must report binding coverage in variant validation output + controller: CTRL-CLI + constraint: > + When --variant is used, the CLI output must include: (1) effective + feature count, (2) bound vs unbound features, (3) bound artifact + count, (4) per-rule traceability coverage within the variant scope. + Unbound features must be listed explicitly. + ucas: [UCA-VAR-005] + hazards: [H-VAR-001, H-VAR-003] + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + +# ============================================================================= +# Loss Scenarios for Variant Hazards +# ============================================================================= + +loss-scenarios: + - id: LS-VAR-001 + title: Constraint implication chain not propagated — invalid variant accepted + type: inadequate-control-algorithm + uca: UCA-VAR-002 + hazards: [H-VAR-002] + scenario: > + The feature model defines (implies adas (or asil-b asil-c asil-d)). + A user creates variant selecting [autonomous, asil-a]. The constraint + (implies autonomous (and adas asil-d)) should reject this, but a bug + in implication chaining only checks direct constraints, missing the + transitive chain autonomous → adas → asil-b+. The variant is accepted, + and validation runs on an artifact set missing ASIL-D-specific safety + requirements. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: LS-VAR-002 + title: Binding references deleted artifact — phantom coverage + type: inadequate-process-model + uca: UCA-VAR-003 + hazards: [H-VAR-003] + scenario: > + Feature "pedestrian-detection" binds to [REQ-042, REQ-043, SPEC-021]. + During a refactoring, SPEC-021 is deleted and replaced by SPEC-025. + The binding file is not updated. Variant validation counts SPEC-021 + as bound (it's in the binding) but the artifact doesn't exist, so + no traceability check runs on it. The feature appears fully covered + when it actually has a gap. This is detected only if binding validation + checks artifact existence before scoping. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: LS-VAR-003 + title: Nested quantifier scope error makes traceability rule vacuously true + type: inadequate-control-algorithm + uca: UCA-VAR-004 + hazards: [H-VAR-004] + scenario: > + A traceability rule (forall (where type "requirement") (exists + (linked-by satisfies) (where type "specification"))) should check + that each requirement has a satisfies link to some specification. + A scoping bug evaluates the inner (exists) against ALL specifications + in the store rather than only those linked from the current requirement. + Since at least one specification exists in any real project, the rule + passes vacuously for every requirement, even orphaned ones. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z + + - id: LS-VAR-004 + title: --all-variants passes each variant individually but misses cross-variant gap + type: inadequate-control-algorithm + uca: UCA-VAR-005 + hazards: [H-VAR-001, H-VAR-005] + scenario: > + Each variant passes validation independently. However, a shared + component (bound to multiple features across variants) has a + traceability gap that only manifests when two specific features + are combined in the same variant. No defined variant includes both + features, so the gap is never checked. This is a limitation of + variant-based validation — it only checks defined configurations, + not all possible combinations. + provenance: + created-by: ai-assisted + model: claude-opus-4-6 + timestamp: 2026-04-12T12:00:00Z diff --git a/schemas/dev.yaml b/schemas/dev.yaml index 6009fbd..c7c360b 100644 --- a/schemas/dev.yaml +++ b/schemas/dev.yaml @@ -56,6 +56,10 @@ artifact-types: - name: design-decision description: An architectural or design decision with rationale fields: + - name: decision + type: text + required: false + description: Concise statement of the decision taken - name: rationale type: text required: true diff --git a/schemas/eu-ai-act.yaml b/schemas/eu-ai-act.yaml index 37f42c9..a65ed66 100644 --- a/schemas/eu-ai-act.yaml +++ b/schemas/eu-ai-act.yaml @@ -208,6 +208,52 @@ artifact-types: required: true cardinality: one-or-many +# ── Art. 12(2): Runtime evidence (tamper-evident log records) ─────────── + + - name: runtime-evidence + description: > + Signed runtime evidence record per Art. 12(2). References tamper-evident + logs from actual AI system operation. Each record attests that the + logging described by a monitoring-measure actually executed during a + time period. Hash-chained via previous-digest for append-only integrity. + Imported via rivet import-results --format evidence. + fields: + - name: evidence-type + type: string + required: true + allowed-values: [log-digest, attestation, drift-snapshot, override-record, incident-record] + description: Category of runtime evidence + - name: time-range + type: string + required: true + description: "ISO 8601 interval covered (e.g., 2026-04-01T00:00Z/2026-04-07T23:59Z)" + - name: digest + type: string + required: true + description: "SHA-256 hex digest of the referenced log bundle (64 hex chars)" + - name: signature + type: string + required: false + description: "Detached signature (cosign, sigstore) over the digest" + - name: signer-identity + type: string + required: false + description: "Identity that produced the signature (OIDC subject or key fingerprint)" + - name: storage-uri + type: string + required: false + description: "URI to the actual evidence (S3, OCI registry, IPFS)" + - name: previous-digest + type: string + required: false + description: "SHA-256 digest of the prior evidence record in the chain" + link-fields: + - name: monitoring + link-type: evidences + target-types: [monitoring-measure] + required: true + cardinality: one-or-many + # ── Annex IV §4 + Art. 15: Performance evaluation ────────────────────── - name: performance-evaluation @@ -556,6 +602,10 @@ link-types: inverse: post-market-monitored-by description: Post-market monitoring plan covers this AI system + - name: evidences + inverse: evidenced-by + description: Runtime evidence attests execution of a monitoring measure (Art. 12) + - name: transparency-for inverse: transparent-to description: Transparency record documents this AI system @@ -664,3 +714,15 @@ traceability-rules: required-backlink: monitors-post-market from-types: [post-market-plan] severity: error + + # Art. 12(2): Runtime evidence for monitoring measures + - name: monitoring-has-evidence + description: > + Each monitoring measure should have at least one runtime evidence + record attesting actual execution (Art. 12(2)). Warning severity + because evidence accumulates over time — a newly created monitoring + measure won't have evidence yet. + source-type: monitoring-measure + required-backlink: evidences + from-types: [runtime-evidence] + severity: warning diff --git a/schemas/stpa.yaml b/schemas/stpa.yaml index a6318f6..84a3fcf 100644 --- a/schemas/stpa.yaml +++ b/schemas/stpa.yaml @@ -107,6 +107,11 @@ artifact-types: type: string required: false allowed-values: [human, automated, human-and-automated] + - name: type + type: string + required: false + allowed-values: [human, automated, human-and-automated] + description: Shorthand alias for controller-type - name: source-file type: string required: false @@ -115,6 +120,14 @@ artifact-types: type: list required: false description: Assumptions the controller holds about the process state + - name: control-actions + type: list + required: false + description: Control actions issued by this controller (each with ca, target, action) + - name: feedback + type: list + required: false + description: Feedback received by this controller (each with from, info) link-fields: [] - name: controlled-process @@ -167,6 +180,7 @@ artifact-types: - document-validation-ucas - external-sync-ucas - lsp-ucas + - mcp-ucas shorthand-links: hazards: leads-to-hazard controller: issued-by @@ -183,6 +197,10 @@ artifact-types: type: string required: true allowed-values: [not-providing, providing, too-early-too-late, stopped-too-soon] + - name: control-action + type: string + required: false + description: The control action this UCA pertains to - name: context type: text required: false @@ -258,6 +276,26 @@ artifact-types: - actuator-failure - sensor-failure - control-path + - name: type + type: string + required: false + allowed-values: + - controller-failure + - inadequate-control-algorithm + - inadequate-process-model + - inadequate-feedback + - process-model-flaw + - coordination-failure + - actuator-failure + - sensor-failure + - control-path + description: Shorthand alias for scenario-type + - name: process-model-flaw + type: text + required: false + description: > + Description of the incorrect process-model belief that contributes + to this loss scenario - name: causal-factors type: list required: false diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..1fd0bc4 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Install git hooks for rivet project. +# +# Hooks installed: +# commit-msg — validates commit trailers reference artifact IDs +# pre-commit — runs rivet validate, blocks on errors +# +# Usage: +# ./scripts/install-hooks.sh + +set -euo pipefail + +HOOKS_DIR="$(git rev-parse --git-dir)/hooks" +RIVET_BIN="${RIVET_BIN:-rivet}" + +echo "Installing git hooks to $HOOKS_DIR..." + +# ── commit-msg hook ────────────────────────────────────────────────── +cat > "$HOOKS_DIR/commit-msg" << 'HOOK' +#!/usr/bin/env bash +# Validate commit message trailers reference valid artifact IDs. +# Installed by scripts/install-hooks.sh +RIVET_BIN="${RIVET_BIN:-rivet}" +if command -v "$RIVET_BIN" &>/dev/null; then + "$RIVET_BIN" commit-msg-check "$1" +fi +HOOK +chmod +x "$HOOKS_DIR/commit-msg" +echo " commit-msg hook installed" + +# ── pre-commit hook ────────────────────────────────────────────────── +cat > "$HOOKS_DIR/pre-commit" << 'HOOK' +#!/usr/bin/env bash +# Run rivet validate before commit. Blocks on errors. +# Installed by scripts/install-hooks.sh +RIVET_BIN="${RIVET_BIN:-rivet}" +if command -v "$RIVET_BIN" &>/dev/null; then + output=$("$RIVET_BIN" validate --format json 2>/dev/null) + errors=$(echo "$output" | python3 -c "import json,sys; print(json.load(sys.stdin).get('errors',0))" 2>/dev/null || echo "0") + if [ "$errors" -gt 0 ]; then + echo "rivet validate: $errors error(s) found. Fix before committing." + echo "Run 'rivet validate' for details." + exit 1 + fi +fi +HOOK +chmod +x "$HOOKS_DIR/pre-commit" +echo " pre-commit hook installed" + +echo "Done. Hooks will use '$RIVET_BIN' binary." diff --git a/tests/fixtures/spar-external/artifacts/components.yaml b/tests/fixtures/spar-external/artifacts/components.yaml new file mode 100644 index 0000000..c10674b --- /dev/null +++ b/tests/fixtures/spar-external/artifacts/components.yaml @@ -0,0 +1,35 @@ +# Test fixture: AADL components from a simulated spar external project. +# Used to verify cross-repo artifact sync and link resolution. +artifacts: + - id: SPAR-SYS-001 + type: aadl-component + title: Top-level system component + status: approved + category: system + aadl-package: spar_test + tags: [aadl, system] + + - id: SPAR-PROC-001 + type: aadl-component + title: Main processing thread + status: approved + category: process + aadl-package: spar_test + links: + - type: contains + target: SPAR-THR-001 + tags: [aadl, process] + + - id: SPAR-THR-001 + type: aadl-component + title: Worker thread + status: draft + category: thread + aadl-package: spar_test + tags: [aadl, thread] + + - id: SPAR-REQ-001 + type: requirement + title: Spar shall parse AADL v2.2 models + status: approved + tags: [parsing] diff --git a/tests/fixtures/spar-external/rivet.yaml b/tests/fixtures/spar-external/rivet.yaml new file mode 100644 index 0000000..83c3605 --- /dev/null +++ b/tests/fixtures/spar-external/rivet.yaml @@ -0,0 +1,13 @@ +# Minimal rivet.yaml for testing cross-repo artifact sync. +# Simulates what spar's rivet.yaml would look like if it tracked +# its own AADL components as rivet artifacts. +project: + name: spar + version: "0.1.0" + schemas: + - common + - aadl + +sources: + - path: artifacts + format: generic-yaml