From 6d303d327cd8e78eea7194326d51749a2bdfcad3 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 19:54:31 +0200 Subject: [PATCH 01/33] docs: design Matrix V2 legacy plan profile --- .../adr/0005-matrix-v2-legacy-plan-profile.md | 106 +++++ ...25-matrix-v2-legacy-plan-profile-design.md | 392 ++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 docs/adr/0005-matrix-v2-legacy-plan-profile.md create mode 100644 docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md diff --git a/docs/adr/0005-matrix-v2-legacy-plan-profile.md b/docs/adr/0005-matrix-v2-legacy-plan-profile.md new file mode 100644 index 0000000..69f2d24 --- /dev/null +++ b/docs/adr/0005-matrix-v2-legacy-plan-profile.md @@ -0,0 +1,106 @@ +# ADR 0005: Explicit Matrix V2 legacy plan profile + +- Status: Proposed +- Date: 2026-08-25 +- Decision owner: Marco Porcellato +- Design: + `docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md` + +## Context + +Matrix V2 configuration schema `2.0` remained parse-compatible while later CCP +versions expanded normalized plan structures. The canonical hashing algorithm +did not change, but the additional serialized fields changed outer and +per-runtime configuration digests. Repositories whose trusted GitHub base still +verifies the historical plan contract cannot accept a receipt from the current +producer even when the configured checks and runtime semantics are unchanged. + +Running the historical producer is not an acceptable migration path. It +predates current admission leases and heartbeats and later runtime, recovery, +source-snapshot, cache, and artifact-free execution safeguards. Temporarily +weakening a repository ruleset would also defeat the receipt-first trust +boundary. + +## Decision + +CCP will support one explicit Matrix-only compatibility profile, +`matrix-v2-legacy-v1`, on `plan`, `doctor`, `dry-run`, and `run`. + +The current producer will: + +1. normalize the configuration through its current parser; +2. prove that every current semantic field is exactly representable by the + historical Matrix V2 contract; +3. derive outer and per-runtime digests from dedicated historical + serialization types reconstructed from source commit + `044697dee9a0d678d30a4847d62ddf9b4970505b`; +4. execute the equivalent current plan under the modern coordinator and + runtime safeguards; +5. disclose the selected profile through the producer version while retaining + the strict historical Matrix V2 receipt shape. + +The profile is Matrix V2-only. Single-runtime policy `1.1` performs exact +producer-tuple matching and trusted-plan reconstruction through the current +normalizer; extending that policy requires a separate policy-version decision. + +One typed envelope retains the current execution plan and the complete legacy +outer/per-runtime digest bases. Representability and every derived digest are +rechecked before admission, at runtime conversion, and before receipt sealing. +The profile label is disclosure, not authority. + +The profile is never inferred from a receipt, policy, filename, repository, or +expected digest. Production code contains no expected repository digest +constants. Unknown profiles and non-representable semantics fail before +admission or shared-state mutation. + +The default current profile and its serialized output, digests, runtime +behavior, and receipts remain unchanged. + +## Consequences + +Benefits: + +- trusted-base migrations can retain exact receipt verification without + executing an obsolete coordinator; +- compatibility is deterministic, independently reproducible, and reusable; +- policies remain external authority and cannot be selected by evidence; +- later execution and recovery hardening remains active. + +Costs: + +- CCP carries two Matrix V2 canonical representations during migration; +- all four configuration-consuming commands must remain profile-consistent; +- receipt provenance must be disclosed without extending a historical strict + schema; +- producer-constrained policy `1.1` cannot use this Matrix-only profile; +- every future normalized plan field needs an explicit representability rule or + a fail-closed rejection. + +## Rejected alternatives + +- **Backport modern execution into the historical producer:** too large and + retains obsolete runtime behavior. +- **Hard-code trusted digests:** non-general, non-auditable, and equivalent to + a policy exception. +- **Rewrite or translate a receipt after execution:** breaks receipt identity + and provenance. +- **Ruleset bypass:** weakens the control being migrated. +- **Automatic profile inference:** lets untrusted evidence or ambient state + influence the digest contract. + +## Verification gates + +1. Golden fixtures independently reproduce historical generic and adopter + digests from canonical values. +2. Default output and digests remain byte-identical. +3. Semantic mutations change the derived digest; non-representable fields fail + before admission. +4. `plan`, `doctor`, `dry-run`, and `run` share one profile-aware plan builder. +5. Historical strict verification accepts a legacy-profile receipt and still + rejects altered policy, commit, digest, producer evidence, or receipt bytes. +6. Focused tests, formatting, warnings-denied build, strict Clippy, + all-target tests, independent review, and one separately authorized + exact-head CCP qualification pass before merge. + +No installation, ruleset migration, receipt publication, adopter run, or +scientific execution is authorized by this ADR. diff --git a/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md b/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md new file mode 100644 index 0000000..1c81174 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md @@ -0,0 +1,392 @@ +# Matrix V2 legacy plan profile design + +Status: proposed for owner review +Date: 2026-08-25 +Baseline: `2b4b55ce1a4be0a2b610656ae4a56a7641b29f26` +Historical plan authority: `044697dee9a0d678d30a4847d62ddf9b4970505b` +Scope: let the current producer execute a semantically representable Matrix V2 +configuration under the exact historical digest contract without weakening the +current coordinator, runtime, receipt, or verification boundaries + +## Decision + +CCP will add one explicit Matrix-only plan profile named +`matrix-v2-legacy-v1`. The profile will reconstruct the historical canonical +plan representation from normalized inputs and use that representation as the +configuration-digest authority while the current producer retains its current +admission coordinator, runtime containment, source snapshot, cache, recovery, +and artifact safeguards. + +The current profile remains the default and remains byte-for-byte unchanged. +The legacy profile is opt-in on `plan`, `doctor`, `dry-run`, and `run`; it is +never inferred from a repository policy, receipt, digest, filename, or expected +hash. + +The implementation must not contain the three Latent-TRIZ expected digest +values or any lookup table that maps a repository/configuration to a digest. +Golden tests may record expected outputs, but production code must derive every +digest from canonical serialized values. + +## Problem and exact evidence + +Latent-TRIZ PR #105 is frozen at source +`34b52c42ef08cfe7043dde53f300154cc01d22f9` with trusted base +`188eb65b5e249923baddadeba52659f07fcd1609`. + +The current installed producer, source +`3fccc197e5055a2759ee7afe51b91133938ec904`, generated a terminal passing local +Matrix receipt with these digests: + +- outer: `25b35b942a6ff9b6237ebed7cefbdbc96b968bbe8954a38b606942f36b8df4b2`; +- Python 3.11: + `b3d8beef1542566d9d925bfee77d2244995dc74adcd879128ef65e82ed1d354b`; +- Python 3.12: + `d446c4ca0602c09eee61c796ad2972f58ab0eebe84a39f928fd90aac5bfb535c`. + +The trusted GitHub workflow builds historical CCP source +`044697dee9a0d678d30a4847d62ddf9b4970505b` and expects: + +- outer: `13f4cb39b7e1a8ed31cae64502cc8e4d80d040230d3fb410a6afc3bad3b76178`; +- Python 3.11: + `eff5b7d55bb0220890dbfb050bb68a1e0fbba8f9a30a69e2f66085354fcc8562`; +- Python 3.12: + `7afb3e6dd435d9d5a317e4d9d85e80527431044312bbe299e9a70b6ba9e994c8`. + +The hosted verifier accepted receipt integrity and rejected only the three +policy/configuration bindings. An isolated build of the historical producer +reproduced all three trusted digests, proving the mismatch is deterministic. + +SHA-256 canonicalization itself did not change. The serialized plan shape did: + +- `environment_allow` became a structured `environment` value; +- newly normalized runtime, storage, artifact, and fixed-environment fields + entered current internal plans, even when their values are default or empty; +- Matrix runtime plan projection therefore emits different canonical bytes. + +The old producer cannot safely be used as the execution engine. It predates +the current lease/heartbeat coordinator and later runtime, artifact-free, +source-snapshot, cache, and cleanup hardening. + +## Alternatives considered + +### A. Backport the current coordinator and runtime fixes onto the historical producer + +Rejected. Admission changes span large ownership, lease, quarantine, recovery, +status, and CLI surfaces. The historical producer also predates other execution +fixes. A migration-only fork would be harder to qualify and would retain older +runtime behavior merely to preserve serialization. + +### B. Temporarily bypass or weaken the GitHub ruleset + +Rejected. A one-time administrative policy bypass would make the trusted-base +transition possible but would remove the exact control the receipt-first design +is intended to preserve. It would also provide no reusable solution for other +repositories with historical trusted digests. + +### C. Add a versioned legacy plan projection to the current producer + +Selected. This preserves the current execution engine, keeps compatibility +explicit and fail-closed, and makes the historical digest a deterministic +result of a reviewed projection rather than a privileged constant. + +## CLI contract + +The four configuration-consuming command families gain the same optional +argument: + +```console +--matrix-plan-profile current-v2 +--matrix-plan-profile matrix-v2-legacy-v1 +``` + +The option is accepted by: + +```console +commit-ci-preflight plan +commit-ci-preflight doctor +commit-ci-preflight dry-run +commit-ci-preflight run +``` + +`current-v2` is the default. Omitting the option must preserve current command +output, plan digests, execution behavior, and receipts exactly. + +Supplying `matrix-v2-legacy-v1` with a non-Matrix configuration fails before +runtime, admission, cache, workspace, journal, or receipt mutation. + +The profile is a caller selection, not repository authority. `verify` continues +to use only the externally selected repository policy and expected commit. A +receipt cannot request a profile, select a policy, or authorize its own digest. + +This tranche supports Matrix V2 policy only. It does not add legacy projection +to single-runtime policy `1.1`, whose trusted-plan loader reconstructs the +current plan directly from trusted configuration. Supporting that policy would +require a separately versioned policy field and verifier design. The CLI +therefore rejects the legacy Matrix profile for every single-runtime config. + +## Canonical representation + +### Profile type + +Introduce a closed `MatrixPlanProfile` enum with two variants: + +- `CurrentV2`; +- `LegacyV1` serialized for CLI use as `matrix-v2-legacy-v1`. + +Parsing rejects unknown names and profile aliases. The enum is shared by the +four command paths so they cannot silently construct different plans. + +### Dual representation + +The legacy profile has two related values: + +1. a **legacy digest basis** that reproduces the exact historical serialized + structures; +2. a **current execution plan** used by the modern runtime. + +The digest basis uses dedicated private serialization types reconstructed from +historical source `044697dee...`. They contain exactly the historical fields: + +- outer Matrix plan: schema version, project, receipt, `environment_allow`, + caches, and runtime plans; +- per-runtime plan: schema version, project, normalized runtime, receipt, + `environment_allow`, caches, and checks; +- runtime and check representations omit fields that did not exist in the + historical plan. + +The projection is computed from normalized semantic values, not TOML bytes. +Whitespace, table order, and map-key order therefore retain current canonical +behavior. + +The current execution plan remains the source for actual runtime construction. +Before returning a legacy envelope, CCP proves that every current-only semantic +field is representable by the historical basis. Any non-default storage, +fixed-environment value, runtime pull/swap policy, artifact contract, or future +field without an explicit equivalence rule fails with a typed +`LegacyPlanNotRepresentable` error naming the first rejected field. + +No field may be silently dropped merely because its serialized value is empty. +The equivalence rule for every omitted field must be explicit and tested. + +### Digest verification + +The plan builder returns one typed, profile-bearing envelope that owns both the +current execution plan and, for the legacy profile, the complete historical +outer/per-runtime digest bases. Current-profile canonical verification +continues to recompute the digest from the current plan. Legacy verification +recomputes every digest from the legacy bases and re-runs the semantic +representability proof. + +The envelope stores no independently writable digest override. Its exposed +outer and per-runtime digests are derived accessors. The builder, runtime +envelope conversion, pre-admission check, per-runtime execution boundary, and +pre-seal receipt path each recheck the same invariant: + +```text +current normalized execution plan + -> exact representability proof + -> historical per-runtime bases and derived digests + -> historical outer basis and derived digest + -> matching inner and outer receipt configuration digests +``` + +Any mismatch is terminal and cannot publish a receipt. The profile label is +disclosure only; it is never accepted as proof that this invariant holds. + +The default JSON output must remain unchanged. Legacy `plan --json` adds a +top-level `matrix_plan_profile: "matrix-v2-legacy-v1"` disclosure outside the +hashed plan value and exposes the normalized legacy digest basis so an +independent implementation can reproduce the digest. + +The three expected Latent-TRIZ hashes appear only in fixtures/tests and adopter +documentation, never in the profile implementation. + +## Runtime and receipt behavior + +The modern producer executes the current plan only after the legacy projection +and both outer/per-runtime digest checks succeed. + +Every runtime receipt and the outer Matrix receipt use the derived legacy +configuration digest. The current Matrix V2 receipt schema remains unchanged so +historical strict verifiers can parse it. + +To disclose the compatibility path without adding a receipt field that an old +strict Matrix parser would reject, the producer version in all inner and outer +receipts gains the deterministic suffix: + +```text +0.1.0+matrix-v2-legacy-v1 +``` + +The default profile continues to emit the ordinary package version. The +profile-specific producer version must be set before sealing inner receipts; +all inner and outer receipts must agree exactly. + +This suffix is compatible with the current Matrix V2 policy because that policy +does not contain a producer allowlist. It is not universally compatible with +single-runtime policy `1.1`, which matches producer name/version tuples exactly. +The profile is unavailable to that command path. If a future Matrix policy adds +producer constraints, the trusted policy must explicitly allow this exact +suffix; CCP must not infer prefix or semantic-version compatibility. + +The legacy profile does not weaken: + +- clean exact-HEAD requirements; +- resource and admission gates; +- lease/heartbeat coordination; +- Docker/runtime probes and containment; +- source snapshot and repository evidence; +- check argv, timeout, runtime image, and platform evidence; +- atomic receipt publication and no-overwrite rules; +- independent verification against an externally supplied policy. + +## Error and recovery behavior + +Profile parsing, representability, canonicalization, or digest mismatch errors +are pre-execution failures. They must produce: + +- no admission ticket or lease; +- no Docker/container call; +- no cache generation or pin; +- no run journal; +- no receipt; +- no source-tree mutation. + +After admission begins, current terminal failure, recovery, cleanup, and receipt +rules apply unchanged. The legacy profile does not authorize retries or convert +an inconclusive/failed terminal outcome to PASS. + +## Verification strategy + +Implementation is test-driven. Each production change follows a witnessed +RED-GREEN cycle. + +### Golden compatibility fixtures + +Store one generic two-runtime Matrix configuration that is valid at both the +historical and current commits. Generate the historical expected JSON and +digests from an independently built exact historical producer and retain this +fixture provenance: + +- source commit: `044697dee9a0d678d30a4847d62ddf9b4970505b`; +- source tree: `5220164edf17831ce0c42dae1c14300ed1045015`; +- isolated binary SHA-256: + `71d64cdbb1bb509bb459aebd6c53e06d819150de42be4fe3715c35bd73426af7`; +- exact plan command and raw canonical output. + +The fixture is not Latent-TRIZ-specific. A separate adopter regression may use +the public Latent-TRIZ configuration and trusted digests as end-to-end evidence. + +Tests must prove: + +- legacy outer and per-runtime digests equal the historical producer; +- current profile digests remain exactly unchanged; +- reordering TOML tables or keys does not change either profile; +- changing any semantic field changes the applicable digest; +- no expected digest string exists in production source; +- default CLI output is byte-identical to the baseline fixture; +- legacy JSON discloses the profile and reconstructible digest basis. + +### Fail-closed profile tests + +Add negative fixtures for: + +- legacy profile with a single-runtime configuration; +- unknown or conflicting profile values; +- non-default current-only storage behavior; +- fixed environment values; +- runtime pull/swap policy; +- artifact contracts; +- mutation of the legacy digest basis after plan construction; +- mismatch between inner and outer profile/provenance; +- receipt-selected policy or expected digest attempts. + +Every pre-execution failure asserts zero calls to admission, runtime, +supervisor, cache, journal, and receipt publication ports. + +The positive end-to-end fixture must pass the actual verifier built from +historical source `044697dee...` against an externally selected Matrix V2 +policy. Negative mutations of producer evidence, expected commit, outer digest, +per-runtime digest, required check, runtime binding, and receipt bytes must fail +through that same verifier. Parse success alone is not acceptance evidence. + +### Command parity + +For the same configuration/profile, `plan`, `doctor`, `dry-run`, and `run` +must observe the same outer and per-runtime digests. Doctor and dry-run remain +non-executing with respect to project checks; plan remains runtime-free. + +Focused suites cover matrix, plan CLI, runtime CLI, receipts, verification, and +admission non-acquisition. The final static qualification runs formatting, +warnings-denied checks, strict Clippy, documentation checks, and all locked +targets without Docker or a CCP heavy run. + +## Documentation + +Update: + +- `docs/CONFIGURATION.md` with the profile and representability boundary; +- `docs/MULTI_RUNTIME_RECEIPTS.md` with dual-plan semantics and producer + disclosure; +- `docs/RECEIPT_SPEC.md` with the profile-specific producer version; +- `docs/LOCAL_RUN.md` with the four-command parity rule; +- `docs/GITHUB_GATE.md` and `docs/ADOPTION_GUIDE.md` with the bootstrap + migration sequence; +- `docs/INVARIANT_EVIDENCE_MATRIX.md` and + `docs/TESTING_AND_FAULT_INJECTION.md` with proof obligations. + +The public term is `legacy plan profile`. Documentation must not describe the +profile as a digest override, policy bypass, or receipt rewrite. + +## Delivery and migration sequence + +1. Implement and statically qualify the profile from this exact current + baseline or a newly verified `origin/main` descendant. +2. Obtain independent architecture/security review. +3. Build one isolated hash-bound candidate and prove both current and legacy + plan outputs without installing it. +4. Separately authorize and perform one CCP exact-head qualification of the CCP + candidate using the ordinary current profile. +5. Publish and merge the CCP change only after its exact-head gates pass. +6. Use the qualified candidate by absolute path for a fresh Latent-TRIZ + `plan`, `doctor`, and `dry-run` under `matrix-v2-legacy-v1`. +7. Stop for a new authorization binding the Latent-TRIZ source HEAD, candidate + SHA-256, profile, generation, maximum run count, and stop boundary. +8. After one terminal run, independently verify the receipt against the + trusted-base policy and expected commit. +9. Only then request separate authorization to publish the replacement evidence + branch, update PR #105 if its exact head/base still match, and merge after + all hosted gates are green. +10. Adopt the proposed Latent-TRIZ static-analysis stack only in a later, + separately planned Matrix migration so this bootstrap fix remains isolated. + +## Non-goals + +- No hard-coded repository, policy, or digest exception. +- No automatic profile inference. +- No admission-root migration or legacy coordinator execution. +- No ruleset bypass or reduction of required checks. +- No receipt editing, translation, or re-signing after execution. +- No support for current-only semantics that lack an exact historical + representation. +- No model, tokenizer, sealed-target, or scientific execution. +- No change to Latent-TRIZ scientific claims or frozen protocols. + +## Completion criteria + +The compatibility feature is complete only when all of the following are +proven: + +- the default profile is byte- and digest-stable; +- the legacy projection independently reproduces the historical generic and + Latent-TRIZ fixtures without production digest constants; +- all four command families share one profile-aware planning path; +- incompatible semantics fail before shared-state mutation; +- receipts disclose the legacy profile through producer version and remain + accepted by the historical strict Matrix V2 verifier and its exact external + policy; no compatibility claim is made for producer-constrained v1.1 policy; +- current admission, runtime, cache, recovery, and receipt invariants remain + qualified; +- the CCP change is independently reviewed and exact-head qualified; +- the Latent-TRIZ replacement run, publication, and PR merge each occur only + under their own exact authorization and terminal gates. From 5f0404afc2d0d096aa3e57dbdf72a9063f25c03e Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 22:34:09 +0200 Subject: [PATCH 02/33] docs: plan Matrix V2 legacy compatibility --- .../adr/0005-matrix-v2-legacy-plan-profile.md | 9 +- ...026-08-25-matrix-v2-legacy-plan-profile.md | 592 ++++++++++++++++++ ...25-matrix-v2-legacy-plan-profile-design.md | 30 +- 3 files changed, 629 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-25-matrix-v2-legacy-plan-profile.md diff --git a/docs/adr/0005-matrix-v2-legacy-plan-profile.md b/docs/adr/0005-matrix-v2-legacy-plan-profile.md index 69f2d24..b1ca1ca 100644 --- a/docs/adr/0005-matrix-v2-legacy-plan-profile.md +++ b/docs/adr/0005-matrix-v2-legacy-plan-profile.md @@ -1,6 +1,6 @@ # ADR 0005: Explicit Matrix V2 legacy plan profile -- Status: Proposed +- Status: Accepted - Date: 2026-08-25 - Decision owner: Marco Porcellato - Design: @@ -75,6 +75,13 @@ Costs: - producer-constrained policy `1.1` cannot use this Matrix-only profile; - every future normalized plan field needs an explicit representability rule or a fail-closed rejection. +- the legacy plan digests deliberately create a separate managed-cache + namespace; current-profile cache entries are not relabelled or promoted. + +The change does not introduce a new configuration, policy, receipt, admission, +or cache schema. Matrix receipts remain an outer schema `2.0` envelope with +inner schema `1.0` receipts; the single-runtime policy `1.1` embedded-plan +contract remains out of scope. ## Rejected alternatives diff --git a/docs/superpowers/plans/2026-08-25-matrix-v2-legacy-plan-profile.md b/docs/superpowers/plans/2026-08-25-matrix-v2-legacy-plan-profile.md new file mode 100644 index 0000000..9f4ba88 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-matrix-v2-legacy-plan-profile.md @@ -0,0 +1,592 @@ +# Matrix V2 Legacy Plan Profile Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an explicit `matrix-v2-legacy-v1` plan profile that lets the current CCP producer reproduce historical Matrix V2 outer and per-runtime digests while retaining the current coordinator, runtime, recovery, cache, and verification safeguards. + +**Architecture:** Keep the current Matrix plan as the only executable representation. A focused `matrix_legacy` module projects representable current normalized values into the exact historical serialization shape and derives all compatibility digests from canonical bytes. A profile-bearing Matrix envelope owns the current plan plus the optional immutable legacy basis; all four configuration-consuming commands use one builder, and runtime/receipt boundaries revalidate the projection before execution and sealing. + +**Tech Stack:** Rust 2024, Rust 1.87 minimum, Clap 4.6, Serde/serde_json, SHA-256 through the existing canonical receipt helpers, existing Matrix V2 and strict verifier contracts. + +**Spec:** `docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md` + +## Global Constraints + +- Baseline is `2b4b55ce1a4be0a2b610656ae4a56a7641b29f26`; historical plan authority is `044697dee9a0d678d30a4847d62ddf9b4970505b` with tree `5220164edf17831ce0c42dae1c14300ed1045015`. +- `current-v2` is the default and its CLI JSON, human output, digests, receipts, and execution semantics remain byte-for-byte unchanged. +- `matrix-v2-legacy-v1` is accepted only for configuration schema `2.0`; it is never inferred from policy, receipt, repository, filename, or digest. +- Production code contains no Latent-TRIZ expected digest constant or repository-to-digest lookup. +- The current normalized plan is the only runtime input; legacy structures are digest bases, never executable plans. +- Every current-only semantic field omitted by the historical shape has an explicit representability check; unknown or non-default semantics fail closed before admission or mutation. +- Matrix receipts remain outer schema `2.0` with inner schema `1.0` receipts. No policy, config, receipt, admission, or cache schema version changes are allowed. +- The legacy profile emits producer version `0.1.0+matrix-v2-legacy-v1`; current profile remains `0.1.0`. +- Legacy digests deliberately select a distinct managed-cache namespace; current cache entries are not relabelled or promoted. +- `plan` is runtime-free, `doctor` runs probes only, `dry-run` spawns no project process, and only `run` acquires admission and executes checks. +- No new dependency is introduced. +- Every production change follows a witnessed RED-GREEN test cycle. +- Every user-visible change updates `CHANGELOG.md`; the accepted architecture decision remains recorded in `docs/adr/0005-matrix-v2-legacy-plan-profile.md`. +- Every shell command in this repository begins with `rtk`. + +--- + +## File map + +- Create `src/matrix_legacy.rs`: historical private serialization types, representability proof, canonical legacy bases, and derived digest accessors. +- Modify `src/lib.rs`: expose the focused compatibility module to the Matrix implementation and integration tests. +- Modify `src/matrix.rs`: closed profile enum, profile-bearing envelope, shared builder, invariant rechecks, runtime-envelope conversion, and receipt provenance agreement. +- Modify `src/run.rs`: accept an explicit already-validated producer version at receipt sealing instead of reading the package version inside the seal path. +- Modify `src/main.rs`: add one shared Clap value enum/argument to `plan`, `doctor`, `dry-run`, and `run`; route all Matrix command paths through the same profile-aware builder; preserve default output bytes. +- Create `tests/fixtures/config-v2-legacy-compatible.toml`: generic two-runtime Matrix input valid at historical and current producers. +- Create `tests/fixtures/matrix-v2-legacy-plan-044697.json`: raw historical canonical plan output with provenance sidecar fields kept outside the hashed plan document. +- Create `tests/fixtures/config-v2-legacy-nonrepresentable-*.toml`: negative current-only semantic fixtures if parser-reachable; otherwise construct typed mutations in unit tests. +- Modify `tests/matrix_contract.rs`: projection, digest, mutation, ordering, tamper, receipt, producer, and verifier contracts. +- Modify `tests/plan_cli.rs`: CLI parsing, current byte stability, legacy disclosure, and single-runtime rejection. +- Modify `tests/runtime_cli.rs`: doctor/dry-run digest parity and non-execution behavior. +- Modify `tests/receipt_contract.rs`: explicit producer version sealing and current default stability. +- Modify `tests/verification_contract.rs` and `tests/verify_cli.rs`: historical Matrix verifier acceptance and fail-closed mutations. +- Modify `tests/repository_hygiene_contract.rs`: prohibit adopter digest constants in production source and require compatibility documentation. +- Modify `docs/CONFIGURATION.md`, `docs/LOCAL_RUN.md`, `docs/MULTI_RUNTIME_RECEIPTS.md`, `docs/RECEIPT_SPEC.md`, `docs/GITHUB_GATE.md`, `docs/ADOPTION_GUIDE.md`, `docs/CACHE_AND_WORKSPACE.md`, `docs/TROUBLESHOOTING.md`, `docs/INVARIANT_EVIDENCE_MATRIX.md`, `docs/TESTING_AND_FAULT_INJECTION.md`, and `CHANGELOG.md`: operator contract, boundaries, evidence, adoption, and failure diagnostics. + +--- + +### Task 1: Independent historical golden fixture + +**Files:** +- Create: `tests/fixtures/config-v2-legacy-compatible.toml` +- Create: `tests/fixtures/matrix-v2-legacy-plan-044697.json` +- Create: `tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json` +- Test: `tests/matrix_contract.rs` + +**Interfaces:** +- Consumes: historical source `044697dee9a0d678d30a4847d62ddf9b4970505b` and its ordinary `plan --json` command. +- Produces: immutable historical canonical JSON fixture and `legacy_compatible_config() -> &'static str` test input for Tasks 2-7. + +- [ ] **Step 1: Add the generic compatibility configuration** + +Use schema `2.0`, project `example/legacy-matrix`, receipt `.ccp/receipt.json`, inherited `SOURCE_DATE_EPOCH`, one cache `cargo` at `.cache/cargo`, two digest-pinned Docker-compatible runtimes `python311` and `python312`, and one required `python -V` check bound to each runtime. Use only fields accepted by both exact producers. + +- [ ] **Step 2: Materialize the historical plan fixture independently** + +Build the exact historical source in an isolated `/private/tmp` checkout and run only the read-only plan command: + +```bash +rtk cargo build --locked --manifest-path /private/tmp/ccp-044697/Cargo.toml +rtk /private/tmp/ccp-044697/target/debug/commit-ci-preflight plan --config tests/fixtures/config-v2-legacy-compatible.toml --json +``` + +Store stdout byte-for-byte in `matrix-v2-legacy-plan-044697.json`. Store commit, tree, binary SHA-256, command argv, fixture SHA-256, output SHA-256, outer digest, and ordered per-runtime digests in the provenance JSON. Do not copy any of these digest values into production Rust. + +- [ ] **Step 3: Write the failing fixture-integrity test** + +Add a test that parses the raw JSON, recomputes `canonical_digest(json["plan"])`, asserts it equals `json["plan_digest"]`, and asserts the provenance digest fields equal the raw document. The test must fail before the files are complete or if any fixture byte is changed without updating provenance. + +- [ ] **Step 4: Run the focused test and witness RED, then GREEN** + +Run: + +```bash +rtk cargo test --test matrix_contract historical_legacy_fixture_is_self_consistent -- --exact --nocapture +``` + +Expected RED: missing fixture or provenance mismatch. Expected GREEN after the exact historical output and provenance are installed. + +- [ ] **Step 5: Commit the independent evidence fixture** + +```bash +rtk git add tests/fixtures/config-v2-legacy-compatible.toml tests/fixtures/matrix-v2-legacy-plan-044697.json tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json tests/matrix_contract.rs +rtk git commit -m "test: pin historical Matrix V2 plan fixture" +``` + +--- + +### Task 2: Closed profile and historical projection + +**Files:** +- Create: `src/matrix_legacy.rs` +- Modify: `src/lib.rs:15-34` +- Modify: `src/matrix.rs:147-317,899-980` +- Test: `tests/matrix_contract.rs` + +**Interfaces:** +- Consumes: `MatrixPlanV2`, `ExecutionPlanV1`, `NormalizedRuntime`, `NormalizedEnvironment`, `NormalizedCheck`, `NormalizedCache`, `NormalizedReceipt`. +- Produces: + - `pub enum MatrixPlanProfile { CurrentV2, LegacyV1 }` + - `impl Default for MatrixPlanProfile` returning `CurrentV2` + - `pub fn build_matrix_plan(config: MatrixConfigV2, profile: MatrixPlanProfile) -> Result` + - `pub(crate) struct LegacyMatrixDigestBasisV1` + - `pub(crate) fn project_legacy_basis(plan: &MatrixPlanV2) -> Result` + - `LegacyMatrixDigestBasisV1::outer_digest() -> Result` + - `LegacyMatrixDigestBasisV1::runtime_digest(id: &str) -> Result<&str, MatrixError>` + +- [ ] **Step 1: Write failing profile and golden projection tests** + +Test that `CurrentV2` remains the default, `LegacyV1` projects the generic fixture to the exact historical outer/per-runtime digests, key/table reordering is stable, and an unknown runtime lookup fails. Assert that current and historical expected Latent values occur only in test/fixture/doc paths. + +- [ ] **Step 2: Run the projection tests and witness RED** + +```bash +rtk cargo test --test matrix_contract legacy_profile_reproduces_historical_plan -- --exact --nocapture +``` + +Expected: compile failure because `MatrixPlanProfile` and `build_matrix_plan` do not exist. + +- [ ] **Step 3: Implement exact private historical types** + +In `matrix_legacy.rs`, define `Serialize`-only private types with the exact historical field order and names: + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyExecutionPlanV1 { + schema_version: String, + project: String, + runtime: LegacyNormalizedRuntime, + receipt: NormalizedReceipt, + environment_allow: Vec, + caches: Vec, + checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyMatrixPlanV2 { + schema_version: String, + project: String, + receipt: NormalizedReceipt, + environment_allow: Vec, + caches: Vec, + runtimes: Vec, +} +``` + +`LegacyNormalizedRuntime` contains only `kind`, `image`, `cpu_count`, `memory_mib`, `pids_limit`, and `network`. `LegacyNormalizedCheck` contains only `id`, `required`, `argv`, `working_directory`, `timeout_seconds`, `depends_on`, and `artifacts`. `LegacyMatrixRuntimePlanV2` contains `id`, derived `configuration_digest`, legacy runtime, and legacy checks. + +- [ ] **Step 4: Implement explicit representability checks** + +Reject the first non-representable field with `MatrixError::LegacyPlanNotRepresentable(&'static str)` when: + +```rust +runtime.pull_policy.is_some() +runtime.swap_mode.is_some() +!plan.environment.fixed.is_empty() +!plan.environment.runtime_internal.is_empty() +!plan.environment.remote_secret_only.is_empty() +plan.storage.is_some() +checks.iter().any(|check| !check.artifact_contracts.is_empty()) +``` + +Map only `plan.environment.inherit` to historical `environment_allow`. Build every runtime legacy plan first, derive its digest with `canonical_digest`, then build and hash the legacy outer plan. Do not accept digest inputs. + +- [ ] **Step 5: Implement the profile-bearing envelope** + +Keep the current `MatrixPlanV2` public shape unchanged. Extend `MatrixPlanEnvelopeV2` with non-serialized private fields `profile: MatrixPlanProfile` and `legacy_basis: Option`. Provide derived accessors `profile()`, `plan_digest()`, and `runtime_configuration_digest(id)`. For current profile, derive from the current plan; for legacy, derive from the stored basis after re-projecting and equality checking. During construction, set each public `MatrixRuntimePlanV2.configuration_digest` to the selected profile's derived runtime digest; all executable runtime/check fields remain the current normalized values. + +- [ ] **Step 6: Run focused tests and witness GREEN** + +```bash +rtk cargo test --test matrix_contract legacy_profile -- --nocapture +rtk cargo test --test matrix_contract v2_config_is_canonical_across_runtime_declaration_order -- --exact +``` + +Expected: all selected tests PASS; current digest/order test remains unchanged. + +- [ ] **Step 7: Commit the projection boundary** + +```bash +rtk git add src/lib.rs src/matrix.rs src/matrix_legacy.rs tests/matrix_contract.rs +rtk git commit -m "feat: derive historical Matrix V2 plan profile" +``` + +--- + +### Task 3: Invariant-preserving runtime envelopes and cache identity + +**Files:** +- Modify: `src/matrix.rs:280-317,328-427` +- Modify: `src/workspace.rs` only if a test reveals direct public-field dependence rather than accessor use +- Test: `tests/matrix_contract.rs` +- Test: `tests/runtime_cli.rs` + +**Interfaces:** +- Consumes: `MatrixPlanEnvelopeV2::profile()`, `plan_digest()`, `runtime_configuration_digest(id)`. +- Produces: `MatrixPlanEnvelopeV2::runtime_envelopes() -> Result, MatrixError>` whose execution plans remain current but whose envelope digests are profile-derived. + +- [ ] **Step 1: Write failing tamper and namespace tests** + +Add tests that mutate a current runtime, check, environment, or legacy basis after construction and require `PlanDigestMismatch` or `LegacyPlanNotRepresentable` before runtime conversion. Assert the legacy and current runtime envelope digests differ and therefore resolve to distinct workspace/cache paths for the same runtime. + +- [ ] **Step 2: Witness RED** + +```bash +rtk cargo test --test matrix_contract legacy_runtime_envelopes_recheck_projection -- --exact --nocapture +rtk cargo test --test runtime_cli legacy_profile_uses_distinct_plan_cache_identity -- --exact --nocapture +``` + +Expected: current code recomputes current digests and cannot preserve the historical basis. + +- [ ] **Step 3: Implement invariant rechecks** + +Make `canonical_bytes()` and `runtime_envelopes()` call one `validate_profile_binding()` method. For legacy, reconstruct the basis from the current plan, compare it to the owned basis, recompute all digests, and use the derived historical runtime digest in `ExecutionPlanEnvelopeV1.plan_digest`. Never alter `ExecutionPlanV1` fields to obtain that digest. + +- [ ] **Step 4: Run focused tests and witness GREEN** + +```bash +rtk cargo test --test matrix_contract legacy_runtime_envelopes_recheck_projection -- --exact +rtk cargo test --test runtime_cli legacy_profile_uses_distinct_plan_cache_identity -- --exact +``` + +- [ ] **Step 5: Commit the execution-envelope binding** + +```bash +rtk git add src/matrix.rs src/workspace.rs tests/matrix_contract.rs tests/runtime_cli.rs +rtk git commit -m "feat: bind legacy Matrix digests to runtime envelopes" +``` + +--- + +### Task 4: Shared CLI profile across plan, doctor, dry-run, and run + +**Files:** +- Modify: `src/main.rs:91-153,444-469,951-1295,1419-1541,2202-2303` +- Test: `tests/plan_cli.rs` +- Test: `tests/runtime_cli.rs` + +**Interfaces:** +- Consumes: `MatrixPlanProfile` through Clap `ValueEnum` or an equivalent closed parser. +- Produces: a flattened `MatrixPlanProfileArgs { matrix_plan_profile: MatrixPlanProfile }` shared verbatim by all four command variants and one `load_matrix_plan(path, profile)` helper. + +- [ ] **Step 1: Capture default CLI output baseline** + +Before production changes, run current `plan --json` and the four `--help` commands against checked-in fixtures and pin only the default plan JSON bytes needed for regression. Do not pin terminal-width-dependent help formatting. + +- [ ] **Step 2: Write failing CLI tests** + +Cover: + +```text +plan|doctor|dry-run|run --matrix-plan-profile matrix-v2-legacy-v1 +plan --matrix-plan-profile current-v2 +unknown profile -> exit 2 +legacy profile + schema 1.0/1.1/1.2/1.3 -> exit 2 before cache/admission +omitted profile -> exact baseline stdout +``` + +For legacy `plan --json`, assert a top-level `matrix_plan_profile` and a reconstructible `legacy_digest_basis`; for current/default JSON, assert these keys are absent and bytes match baseline. + +- [ ] **Step 3: Witness RED** + +```bash +rtk cargo test --test plan_cli matrix_legacy_profile -- --nocapture +rtk cargo test --test runtime_cli matrix_legacy_profile -- --nocapture +``` + +- [ ] **Step 4: Implement one shared argument and loader** + +Add the identical flattened argument to the four commands, pass it through the dispatch match, and call: + +```rust +fn load_matrix_plan( + path: &Path, + profile: MatrixPlanProfile, +) -> Result +``` + +Reject a non-current profile before single-runtime `load_plan`, cache resolution, journal initialization, or admission. Avoid four separate profile parsers/builders. + +- [ ] **Step 5: Preserve default output and disclose legacy output** + +Keep `MatrixPlanEnvelopeV2`'s current serialized envelope unchanged for default output. Serialize a dedicated `LegacyMatrixPlanReportV1` only when legacy is selected; it contains `matrix_plan_profile`, derived `plan_digest`, current reviewable plan, and the normalized legacy digest basis. Add the profile label to legacy human output only. + +- [ ] **Step 6: Run focused tests and witness GREEN** + +```bash +rtk cargo test --test plan_cli +rtk cargo test --test runtime_cli +``` + +- [ ] **Step 7: Commit command parity** + +```bash +rtk git add src/main.rs tests/plan_cli.rs tests/runtime_cli.rs +rtk git commit -m "feat: expose Matrix V2 compatibility profile" +``` + +--- + +### Task 5: Producer provenance and pre-seal receipt invariants + +**Files:** +- Modify: `src/run.rs:55-61,373-422,638-703` +- Modify: `src/main.rs:1111-1126` +- Modify: `src/matrix.rs:328-427,480-550` +- Test: `tests/receipt_contract.rs` +- Test: `tests/matrix_contract.rs` + +**Interfaces:** +- Consumes: `RunRequest { producer_version: &'a str }` and `MatrixPlanProfile::producer_version() -> &'static str`. +- Produces: inner and outer Matrix receipts whose producer name/version match exactly and whose configuration digests derive from the selected profile. + +- [ ] **Step 1: Write failing producer-version tests** + +Assert current single-runtime and Matrix paths still emit `0.1.0`; legacy Matrix inner and outer receipts emit `0.1.0+matrix-v2-legacy-v1`; mixed inner versions and digest mutations fail before outer sealing. + +- [ ] **Step 2: Witness RED** + +```bash +rtk cargo test --test receipt_contract explicit_producer_version_is_sealed -- --exact --nocapture +rtk cargo test --test matrix_contract legacy_receipt_provenance_is_uniform -- --exact --nocapture +``` + +- [ ] **Step 3: Move producer selection to the request boundary** + +Add `producer_version: &'a str` to `RunRequest`. Replace the internal `env!("CARGO_PKG_VERSION")` assignment with `request.producer_version.to_owned()`. Update every constructor in source and tests to pass `env!("CARGO_PKG_VERSION")`, except the legacy Matrix orchestrator, which passes `envelope.profile().producer_version()`. + +- [ ] **Step 4: Recheck immediately before execution and sealing** + +At `execute_matrix_run_v2` entry, before each inner runtime, and before `MatrixReceiptEnvelopeV2::seal`, call `validate_profile_binding()`. Require every inner receipt producer to equal the first receipt producer and equal the selected profile producer tuple. Require every inner/outer configuration digest to equal the profile-derived accessors. + +- [ ] **Step 5: Run focused tests and witness GREEN** + +```bash +rtk cargo test --test receipt_contract +rtk cargo test --test matrix_contract +rtk cargo test --lib run::tests +``` + +- [ ] **Step 6: Commit provenance binding** + +```bash +rtk git add src/run.rs src/main.rs src/matrix.rs tests/receipt_contract.rs tests/matrix_contract.rs +rtk git commit -m "feat: disclose Matrix compatibility receipt provenance" +``` + +--- + +### Task 6: Actual historical verifier compatibility and fail-closed mutations + +**Files:** +- Create: `tests/fixtures/policy-v2-legacy-compatible.toml` +- Modify: `tests/verification_contract.rs` +- Modify: `tests/verify_cli.rs` +- Modify: `tests/matrix_contract.rs` + +**Interfaces:** +- Consumes: a deterministic synthetic legacy-profile Matrix receipt and externally selected Matrix V2 policy. +- Produces: end-to-end evidence that the exact historical verifier accepts the valid receipt and rejects mutations. + +- [ ] **Step 1: Write the synthetic sealed-receipt builder** + +Build two inner schema `1.0` receipts and one outer schema `2.0` receipt with fixed commit, times, platform, check evidence, legacy producer suffix, and profile-derived digests. Do not execute Docker or project commands. + +- [ ] **Step 2: Write failing historical-verifier tests** + +Invoke the verifier built from exact historical source `044697...` against the synthetic receipt, exact policy, expected commit, and fixed evaluation time. Require PASS for the valid document and FAIL for mutations to producer evidence, expected commit, outer digest, one runtime digest, required check binding, runtime binding, and one receipt byte. + +- [ ] **Step 3: Witness RED** + +```bash +rtk cargo test --test verification_contract historical_matrix_verifier_accepts_legacy_profile_receipt -- --exact --nocapture +``` + +Expected: valid receipt fails until Tasks 2-5 supply exact historical digests and provenance. + +- [ ] **Step 4: Make the harness hermetic** + +The test must consume an exact prebuilt historical verifier path supplied by `CCP_HISTORICAL_VERIFIER_044697`, verify its SHA-256 against the pinned provenance fixture before invocation, and otherwise mark the test ignored with an explicit setup message. A separate pure-Rust current verifier test must always run. The test must never download or build historical source implicitly. + +- [ ] **Step 5: Run current and historical verification suites** + +```bash +rtk cargo test --test verification_contract +rtk cargo test --test verify_cli +rtk env CCP_HISTORICAL_VERIFIER_044697=/private/tmp/ccp-044697/target/debug/commit-ci-preflight cargo test --test verification_contract historical_matrix_verifier -- --nocapture +``` + +Expected: valid receipt PASS; every mutation FAIL with the expected finding class. + +- [ ] **Step 6: Commit verifier evidence** + +```bash +rtk git add tests/fixtures/policy-v2-legacy-compatible.toml tests/verification_contract.rs tests/verify_cli.rs tests/matrix_contract.rs +rtk git commit -m "test: prove historical Matrix verifier compatibility" +``` + +--- + +### Task 7: Fail-before-mutation command tests + +**Files:** +- Modify: `tests/runtime_cli.rs` +- Modify: `tests/plan_cli.rs` +- Modify: `tests/matrix_contract.rs` + +**Interfaces:** +- Consumes: profile-aware command paths from Task 4 and representability errors from Task 2. +- Produces: observable proof that invalid compatibility requests touch no admission, cache, journal, runtime, or receipt surface. + +- [ ] **Step 1: Write filesystem and fake-runtime assertions** + +For single-runtime misuse and every parser-reachable non-representable Matrix input, invoke `run` with isolated nonexistent cache/admission roots and a fake Docker executable that creates a marker if called. Assert exit code 2, named field in stderr, no cache root, no journal, no receipt, no marker, and byte-identical source tree. + +- [ ] **Step 2: Witness RED** + +```bash +rtk cargo test --test runtime_cli legacy_profile_rejection_precedes_shared_state -- --exact --nocapture +``` + +- [ ] **Step 3: Move validation earlier if any marker appears** + +All profile parsing, schema selection, legacy projection, and invariant validation must complete before `resolve_cache_root`, `ManagedCache::initialize`, `RunJournalStore::initialize`, `AdmissionCoordinator::platform_for`, or runtime construction. + +- [ ] **Step 4: Run focused suites and witness GREEN** + +```bash +rtk cargo test --test runtime_cli +rtk cargo test --test plan_cli +rtk cargo test --test matrix_contract +``` + +- [ ] **Step 5: Commit pre-mutation safety** + +```bash +rtk git add tests/runtime_cli.rs tests/plan_cli.rs tests/matrix_contract.rs src/main.rs src/matrix.rs +rtk git commit -m "test: enforce pre-admission compatibility failures" +``` + +--- + +### Task 8: Operator documentation and evidence matrix + +**Files:** +- Modify: `docs/CONFIGURATION.md` +- Modify: `docs/LOCAL_RUN.md` +- Modify: `docs/MULTI_RUNTIME_RECEIPTS.md` +- Modify: `docs/RECEIPT_SPEC.md` +- Modify: `docs/GITHUB_GATE.md` +- Modify: `docs/ADOPTION_GUIDE.md` +- Modify: `docs/CACHE_AND_WORKSPACE.md` +- Modify: `docs/TROUBLESHOOTING.md` +- Modify: `docs/INVARIANT_EVIDENCE_MATRIX.md` +- Modify: `docs/TESTING_AND_FAULT_INJECTION.md` +- Modify: `CHANGELOG.md` +- Modify: `tests/repository_hygiene_contract.rs` + +**Interfaces:** +- Consumes: completed CLI and receipt behavior. +- Produces: one consistent public operator contract and repository-enforced documentation coverage. + +- [ ] **Step 1: Write failing documentation contract tests** + +Require the docs to name the exact profile, Matrix-only scope, producer suffix, command parity, cache namespace separation, no policy inference, historical-verifier boundary, and rollback to `current-v2`. Scan `src/` to reject the three Latent adopter digest strings and the generic golden digest strings. + +- [ ] **Step 2: Witness RED** + +```bash +rtk cargo test --test repository_hygiene_contract matrix_legacy_profile_is_documented_without_production_digest_constants -- --exact --nocapture +``` + +- [ ] **Step 3: Update operator documentation** + +Document the exact sequence: + +```console +commit-ci-preflight plan --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight doctor --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight dry-run --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight run --matrix-plan-profile matrix-v2-legacy-v1 --generation N --json +``` + +State that the operator copies reviewed digests into Matrix policy v2, never from a completed receipt; legacy and current cache identities are separate; `verify` has no profile flag; evidence branches remain append-once; and an old trusted verifier must accept the exact receipt before policy migration. + +- [ ] **Step 4: Update evidence and fault-injection matrices** + +Map each invariant to its focused test, including projection reproducibility, representability rejection, command parity, cache separation, producer uniformity, historical verifier acceptance, mutation rejection, and zero pre-admission mutation. + +- [ ] **Step 5: Add the changelog entry and run docs tests** + +```bash +rtk cargo test --test repository_hygiene_contract +rtk cargo test --test release_hardening_contract +``` + +- [ ] **Step 6: Commit documentation** + +```bash +rtk git add CHANGELOG.md docs tests/repository_hygiene_contract.rs +rtk git commit -m "docs: document Matrix V2 compatibility profile" +``` + +--- + +### Task 9: Static qualification and review checkpoint + +**Files:** +- Modify only files required to fix defects found by the gates; every fix gets a focused regression test first. + +**Interfaces:** +- Consumes: Tasks 1-8. +- Produces: clean exact HEAD, complete static gate evidence, candidate binary path/SHA, and a new stop boundary before any CCP heavy run, installation, publication, or adopter execution. + +- [ ] **Step 1: Run formatting and warnings-denied build** + +```bash +rtk cargo fmt --all -- --check +rtk env RUSTFLAGS=-Dwarnings cargo build --locked --all-targets +``` + +- [ ] **Step 2: Run strict Clippy** + +```bash +rtk cargo clippy --locked --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 3: Run all tests and release metadata checks** + +```bash +rtk cargo test --locked --all-targets --all-features +rtk cargo test --test release_hardening_contract +rtk cargo test --test repository_hygiene_contract +``` + +- [ ] **Step 4: Run the exact historical verifier integration** + +Verify the historical binary SHA-256 immediately before the test, then run the non-ignored exact-verifier suite with `CCP_HISTORICAL_VERIFIER_044697` set. Record binary path, full SHA-256, historical commit/tree, test command, and terminal result. + +- [ ] **Step 5: Verify CLI contracts without executing a run** + +```bash +rtk cargo run --locked -- plan --config tests/fixtures/config-v2-legacy-compatible.toml --matrix-plan-profile current-v2 --json +rtk cargo run --locked -- plan --config tests/fixtures/config-v2-legacy-compatible.toml --matrix-plan-profile matrix-v2-legacy-v1 --json +rtk cargo run --locked -- dry-run --config tests/fixtures/config-v2-legacy-compatible.toml --matrix-plan-profile matrix-v2-legacy-v1 --cache-dir /private/tmp/ccp-legacy-dry-run-cache --repository . --json +``` + +Do not run `doctor` unless a bounded runtime-probe permission is explicitly available; do not run `run` in this task. + +- [ ] **Step 6: Perform independent two-stage review** + +First review spec conformance and evidence boundaries; then review implementation quality, error ordering, serialization stability, and test non-tautology. Resolve every accepted defect through RED-GREEN tests. + +- [ ] **Step 7: Build and hash an isolated candidate** + +Build from the clean exact HEAD into an isolated target directory, report absolute path, complete SHA-256, `--version`, source commit/tree, and working-tree status. Do not install or replace the stable binary. + +- [ ] **Step 8: Stop at the authorization boundary** + +Report the exact HEAD, tree, candidate path/SHA, current and legacy fixture digests, test results, remaining limitations, and the exact authorization text required for one CCP exact-head qualification. Do not start CCP, Docker-heavy execution, publish a receipt, push, open a PR, modify a ruleset, or run Latent-TRIZ. + +- [ ] **Step 9: Commit only gate-driven fixes and the final checkpoint** + +```bash +rtk git status --short --branch +rtk git log -1 --format='%H %T %s' +``` + +The branch must be clean before requesting the next authorization. + +--- + +## Self-review record + +- Spec coverage: every decision, CLI, canonical representation, runtime/receipt, recovery, verification, documentation, and stop-boundary requirement maps to Tasks 1-9. +- Placeholder scan: no unresolved markers, deferred implementation instruction, unnamed error-handling step, or unspecified test family remains. +- Type consistency: `MatrixPlanProfile`, `build_matrix_plan`, `MatrixPlanEnvelopeV2` accessors, `LegacyMatrixDigestBasisV1`, and `RunRequest::producer_version` are named once and consumed consistently. +- Scope separation: this plan changes CCP only. It performs no Latent-TRIZ receipt rewrite, ruleset bypass, adopter run, model access, or scientific execution. +- Official-contract alignment: Matrix V2 remains outer v2 plus inner v1; policy remains external; only `run` acquires admission; cache isolation follows derived digest identity. diff --git a/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md b/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md index 1c81174..f91581a 100644 --- a/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md +++ b/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md @@ -1,6 +1,6 @@ # Matrix V2 legacy plan profile design -Status: proposed for owner review +Status: approved for implementation after official-contract reconciliation Date: 2026-08-25 Baseline: `2b4b55ce1a4be0a2b610656ae4a56a7641b29f26` Historical plan authority: `044697dee9a0d678d30a4847d62ddf9b4970505b` @@ -27,6 +27,34 @@ values or any lookup table that maps a repository/configuration to a digest. Golden tests may record expected outputs, but production code must derive every digest from canonical serialized values. +## Official current-contract reconciliation + +The design was rechecked on 2026-08-25 against live `origin/main` +`2b4b55ce1a4be0a2b610656ae4a56a7641b29f26`, the generated CLI help, and the +current coordination, local-run, runtime, configuration, Matrix receipt, +verification-policy, GitHub-gate, cache, and troubleshooting documentation. +That review fixes the following boundaries: + +- `plan` remains runtime-free; `doctor` may probe configured runtimes but runs + no project checks; `dry-run` renders execution without spawning project + processes; only `run` acquires the heavy-command slot and executes checks; +- Matrix V2 continues to publish one outer schema `2.0` receipt containing + sealed inner schema `1.0` receipts. The profile does not add the embedded + execution-plan/source-snapshot contract used by single-runtime policy `1.1`; +- no configuration, policy, receipt, admission, or cache schema version changes + are introduced. The profile is an explicit CLI/build-time plan selection; +- the legacy outer and per-runtime plan digests intentionally select a distinct + managed-cache namespace. Current-profile cache entries must not be promoted, + relabelled, or silently reused under a legacy digest; +- policy values still come from a separately reviewed `plan` result. Neither a + policy nor a completed receipt may cause CCP to infer the compatibility + profile; +- the GitHub gate remains a trusted verifier that executes no pull-request + code. Compatibility succeeds only if the historical trusted verifier accepts + the exact published Matrix receipt against the externally selected policy. + +These constraints refine the selected design without changing its direction. + ## Problem and exact evidence Latent-TRIZ PR #105 is frozen at source From 568ed06ecf53fc664c019eefbe42bf9f340a975f Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 23:02:37 +0200 Subject: [PATCH 03/33] test: pin historical Matrix V2 plan fixture --- .../task-1-report.md | 44 +++++++++++++++++ .../fixtures/config-v2-legacy-compatible.toml | 47 +++++++++++++++++++ .../matrix-v2-legacy-plan-044697.json | 1 + ...trix-v2-legacy-plan-044697.provenance.json | 14 ++++++ tests/matrix_contract.rs | 35 ++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 .superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md create mode 100644 tests/fixtures/config-v2-legacy-compatible.toml create mode 100644 tests/fixtures/matrix-v2-legacy-plan-044697.json create mode 100644 tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json diff --git a/.superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md b/.superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md new file mode 100644 index 0000000..cec5e8e --- /dev/null +++ b/.superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md @@ -0,0 +1,44 @@ +# Task 1 report: historical Matrix V2 legacy fixture + +## Scope + +Added the compatibility configuration, independently materialized historical +plan JSON, provenance record, and fixture-integrity test owned by Task 1. + +Historical source: + +- commit: `044697dee9a0d678d30a4847d62ddf9b4970505b` +- tree: `5220164edf17831ce0c42dae1c14300ed1045015` + +The plan was produced from an isolated `/private/tmp/ccp-044697` checkout using +the historical binary built with `cargo build --locked`. The fixture records +the exact command arguments, configuration/output hashes, plan digest, outer +digest, and ordered runtime configuration digests. + +## Verification + +RED was witnessed before the fixture set was complete: the focused test could +not compile while the included fixture files were absent. A subsequent test +attempt against the repository target was blocked by the sandbox's inability +to create `.cargo-build-lock`; this is an environment limitation, not a +product result. + +GREEN command: + +```text +rtk env CARGO_TARGET_DIR=/private/tmp/ccp-task1-target cargo test --test matrix_contract historical_legacy_fixture_is_self_consistent -- --exact --nocapture +``` + +Result: `1 passed, 0 failed`. + +`git diff --check` passed. `cargo fmt` could not write the source file because +the repository target path was denied by the sandbox; the changed Rust code is +formatted manually and the focused test compiles and passes in the isolated +target. + +## Limitations and concerns + +- No CCP run, Docker runtime, network, cache/admission mutation, installation, + publication, push, PR, or merge was performed. +- This report proves fixture self-consistency and historical plan production; + it does not qualify runtime execution or current-source behavior. diff --git a/tests/fixtures/config-v2-legacy-compatible.toml b/tests/fixtures/config-v2-legacy-compatible.toml new file mode 100644 index 0000000..cc9058d --- /dev/null +++ b/tests/fixtures/config-v2-legacy-compatible.toml @@ -0,0 +1,47 @@ +schema_version = "2.0" +project = "example/legacy-matrix" + +[receipt] +output = ".ccp/receipt.json" +freshness_seconds = 300 + +[environment] +allow = ["SOURCE_DATE_EPOCH"] + +[[caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[runtimes]] +id = "python311" +kind = "docker_compatible" +image = "example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +cpu_count = 1 +memory_mib = 256 +pids_limit = 64 +network = false + +[[runtimes]] +id = "python312" +kind = "docker_compatible" +image = "example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +cpu_count = 1 +memory_mib = 256 +pids_limit = 64 +network = false + +[[checks]] +id = "python311-version" +runtime_id = "python311" +required = true +argv = ["python", "-V"] +working_directory = "." +timeout_seconds = 30 + +[[checks]] +id = "python312-version" +runtime_id = "python312" +required = true +argv = ["python", "-V"] +working_directory = "." +timeout_seconds = 30 diff --git a/tests/fixtures/matrix-v2-legacy-plan-044697.json b/tests/fixtures/matrix-v2-legacy-plan-044697.json new file mode 100644 index 0000000..9f64acd --- /dev/null +++ b/tests/fixtures/matrix-v2-legacy-plan-044697.json @@ -0,0 +1 @@ +{"plan":{"caches":[{"id":"cargo","mount_path":".cache/cargo"}],"environment_allow":["SOURCE_DATE_EPOCH"],"project":"example/legacy-matrix","receipt":{"freshness_seconds":300,"output":".ccp/receipt.json"},"runtimes":[{"checks":[{"argv":["python","-V"],"artifacts":[],"depends_on":[],"id":"python311-version","required":true,"timeout_seconds":30,"working_directory":"."}],"configuration_digest":"sha256:755f77f6815b1ed7b4415b3312c48a6528e2d752270775916efa6c10f1ffe192","id":"python311","runtime":{"cpu_count":1,"image":"example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"docker_compatible","memory_mib":256,"network":false,"pids_limit":64}},{"checks":[{"argv":["python","-V"],"artifacts":[],"depends_on":[],"id":"python312-version","required":true,"timeout_seconds":30,"working_directory":"."}],"configuration_digest":"sha256:be2eb7d200946e9f1dc84cebd8c0cca8739e424163f91c86063bbe4caed936f9","id":"python312","runtime":{"cpu_count":1,"image":"example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","kind":"docker_compatible","memory_mib":256,"network":false,"pids_limit":64}}],"schema_version":"2.0"},"plan_digest":"sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13"} diff --git a/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json b/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json new file mode 100644 index 0000000..709ce3f --- /dev/null +++ b/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json @@ -0,0 +1,14 @@ +{ + "commit": "044697dee9a0d678d30a4847d62ddf9b4970505b", + "tree": "5220164edf17831ce0c42dae1c14300ed1045015", + "binary_sha256": "6372674cff4b7f8bef6272403f1493cbc88228f031759d588aa532589cde0106", + "command_argv": ["commit-ci-preflight", "plan", "--config", "tests/fixtures/config-v2-legacy-compatible.toml", "--json"], + "config_sha256": "cb8c3abf7231ff8b25cfab561ba0080f0cc70952f7e63f9ee2832ecbcf572a67", + "output_sha256": "224cc7b31c1541d4270960980412058f23e4ed396d63c79a4454c3c1bd25653d", + "plan_digest": "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13", + "outer_digest": "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13", + "runtime_digests": { + "python311": "sha256:755f77f6815b1ed7b4415b3312c48a6528e2d752270775916efa6c10f1ffe192", + "python312": "sha256:be2eb7d200946e9f1dc84cebd8c0cca8739e424163f91c86063bbe4caed936f9" + } +} diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 4e7800a..51418dc 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -14,6 +14,7 @@ use commit_ci_preflight::receipt::{ CheckEvidence, EvidenceStatus, PlatformEvidence, ProducerEvidence, ReceiptEnvelopeV1, ReceiptV1, RepositoryEvidence, RunEvidence, }; +use serde_json::Value; use commit_ci_preflight::verify::{ AcceptedPlatformV1, VerificationDecision, VerificationPolicyDocument, VerificationStatus, verify_receipt_document_for_policy, @@ -319,3 +320,37 @@ fn runtime(id: &str, image: &str) -> String { "[[runtimes]]\nid = \"{id}\"\nkind = \"docker_compatible\"\nimage = \"{image}\"\ncpu_count = 1\nmemory_mib = 256\npids_limit = 64\nnetwork = false\n" ) } + +fn legacy_compatible_config() -> &'static str { + include_str!("fixtures/config-v2-legacy-compatible.toml") +} + +#[test] +fn historical_legacy_fixture_is_self_consistent() { + let raw = include_str!("fixtures/matrix-v2-legacy-plan-044697.json"); + let provenance: Value = serde_json::from_str(include_str!( + "fixtures/matrix-v2-legacy-plan-044697.provenance.json" + )) + .expect("provenance JSON"); + let document: Value = serde_json::from_str(raw).expect("plan JSON"); + let plan = document.get("plan").expect("plan"); + let plan_digest = document + .get("plan_digest") + .and_then(Value::as_str) + .expect("plan_digest"); + assert_eq!( + commit_ci_preflight::receipt::canonical_digest(plan).expect("canonical digest"), + plan_digest + ); + assert_eq!(provenance["output_sha256"], sha256_hex(raw.as_bytes())); + assert_eq!(provenance["plan_digest"], plan_digest); + assert_eq!(provenance["config_sha256"], sha256_hex(legacy_compatible_config().as_bytes())); +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} From 122ce4430e1b2ec38facebb1692b368c752a31a8 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 23:32:20 +0200 Subject: [PATCH 04/33] test: tighten historical fixture provenance --- .../task-1-report.md | 44 ------------------- ...trix-v2-legacy-plan-044697.provenance.json | 1 + tests/matrix_contract.rs | 39 +++++++++++++++- 3 files changed, 38 insertions(+), 46 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md diff --git a/.superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md b/.superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md deleted file mode 100644 index cec5e8e..0000000 --- a/.superpowers/sdd/2026-08-25-matrix-v2-legacy-plan-profile/task-1-report.md +++ /dev/null @@ -1,44 +0,0 @@ -# Task 1 report: historical Matrix V2 legacy fixture - -## Scope - -Added the compatibility configuration, independently materialized historical -plan JSON, provenance record, and fixture-integrity test owned by Task 1. - -Historical source: - -- commit: `044697dee9a0d678d30a4847d62ddf9b4970505b` -- tree: `5220164edf17831ce0c42dae1c14300ed1045015` - -The plan was produced from an isolated `/private/tmp/ccp-044697` checkout using -the historical binary built with `cargo build --locked`. The fixture records -the exact command arguments, configuration/output hashes, plan digest, outer -digest, and ordered runtime configuration digests. - -## Verification - -RED was witnessed before the fixture set was complete: the focused test could -not compile while the included fixture files were absent. A subsequent test -attempt against the repository target was blocked by the sandbox's inability -to create `.cargo-build-lock`; this is an environment limitation, not a -product result. - -GREEN command: - -```text -rtk env CARGO_TARGET_DIR=/private/tmp/ccp-task1-target cargo test --test matrix_contract historical_legacy_fixture_is_self_consistent -- --exact --nocapture -``` - -Result: `1 passed, 0 failed`. - -`git diff --check` passed. `cargo fmt` could not write the source file because -the repository target path was denied by the sandbox; the changed Rust code is -formatted manually and the focused test compiles and passes in the isolated -target. - -## Limitations and concerns - -- No CCP run, Docker runtime, network, cache/admission mutation, installation, - publication, push, PR, or merge was performed. -- This report proves fixture self-consistency and historical plan production; - it does not qualify runtime execution or current-source behavior. diff --git a/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json b/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json index 709ce3f..6c0c03a 100644 --- a/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json +++ b/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json @@ -2,6 +2,7 @@ "commit": "044697dee9a0d678d30a4847d62ddf9b4970505b", "tree": "5220164edf17831ce0c42dae1c14300ed1045015", "binary_sha256": "6372674cff4b7f8bef6272403f1493cbc88228f031759d588aa532589cde0106", + "binary_sha256_status": "observed_at_fixture_generation; temporary_binary_not_retained", "command_argv": ["commit-ci-preflight", "plan", "--config", "tests/fixtures/config-v2-legacy-compatible.toml", "--json"], "config_sha256": "cb8c3abf7231ff8b25cfab561ba0080f0cc70952f7e63f9ee2832ecbcf572a67", "output_sha256": "224cc7b31c1541d4270960980412058f23e4ed396d63c79a4454c3c1bd25653d", diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 51418dc..5d155fc 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -14,11 +14,11 @@ use commit_ci_preflight::receipt::{ CheckEvidence, EvidenceStatus, PlatformEvidence, ProducerEvidence, ReceiptEnvelopeV1, ReceiptV1, RepositoryEvidence, RunEvidence, }; -use serde_json::Value; use commit_ci_preflight::verify::{ AcceptedPlatformV1, VerificationDecision, VerificationPolicyDocument, VerificationStatus, verify_receipt_document_for_policy, }; +use serde_json::Value; const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const IMAGE_311: &str = "example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -338,13 +338,48 @@ fn historical_legacy_fixture_is_self_consistent() { .get("plan_digest") .and_then(Value::as_str) .expect("plan_digest"); + assert_eq!( + provenance["commit"], + "044697dee9a0d678d30a4847d62ddf9b4970505b" + ); + assert_eq!( + provenance["tree"], + "5220164edf17831ce0c42dae1c14300ed1045015" + ); + assert_eq!( + provenance["command_argv"], + serde_json::json!([ + "commit-ci-preflight", + "plan", + "--config", + "tests/fixtures/config-v2-legacy-compatible.toml", + "--json" + ]) + ); + assert_eq!(provenance["plan_digest"], plan_digest); + assert_eq!(provenance["outer_digest"], plan_digest); + let runtimes = plan["runtimes"].as_array().expect("runtimes"); + assert_eq!( + runtimes[0]["configuration_digest"], + provenance["runtime_digests"]["python311"] + ); + assert_eq!( + runtimes[1]["configuration_digest"], + provenance["runtime_digests"]["python312"] + ); + let binary_hash = provenance["binary_sha256"].as_str().expect("binary hash"); + assert_eq!(binary_hash.len(), 64); + assert!(binary_hash.bytes().all(|byte| byte.is_ascii_hexdigit())); assert_eq!( commit_ci_preflight::receipt::canonical_digest(plan).expect("canonical digest"), plan_digest ); assert_eq!(provenance["output_sha256"], sha256_hex(raw.as_bytes())); assert_eq!(provenance["plan_digest"], plan_digest); - assert_eq!(provenance["config_sha256"], sha256_hex(legacy_compatible_config().as_bytes())); + assert_eq!( + provenance["config_sha256"], + sha256_hex(legacy_compatible_config().as_bytes()) + ); } fn sha256_hex(bytes: &[u8]) -> String { From 5001434b2f437b1addb4a13d3a7296d7c5d8981e Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 21:24:09 +0200 Subject: [PATCH 05/33] test: cover legacy Matrix CLI digest --- tests/plan_cli.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/plan_cli.rs b/tests/plan_cli.rs index ffad5f6..12e9e8d 100644 --- a/tests/plan_cli.rs +++ b/tests/plan_cli.rs @@ -18,6 +18,7 @@ use commit_ci_preflight::config::config_schema_json; const CONFIG: &str = "tests/fixtures/config-v1-read-only.toml"; const MATRIX_CONFIG: &str = "tests/fixtures/config-v2-matrix.toml"; +const LEGACY_MATRIX_CONFIG: &str = "tests/fixtures/config-v2-legacy-compatible.toml"; const PINNED_SCHEMA: &str = include_str!("../schema/config-v1.schema.json"); #[test] @@ -93,6 +94,33 @@ fn v2_plan_exposes_reviewable_per_runtime_digests_without_execution() { } } +#[test] +fn legacy_matrix_profile_reproduces_the_pinned_historical_plan_digest() { + let expected: serde_json::Value = serde_json::from_str(include_str!( + "fixtures/matrix-v2-legacy-plan-044697.json" + )) + .expect("historical plan fixture"); + let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) + .args([ + "plan", + "--config", + LEGACY_MATRIX_CONFIG, + "--matrix-plan-profile", + "matrix-v2-legacy-v1", + "--json", + ]) + .output() + .expect("run legacy matrix plan command"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let actual: serde_json::Value = serde_json::from_slice(&output.stdout).expect("plan JSON"); + assert_eq!(actual["plan_digest"], expected["plan_digest"]); +} + #[test] fn missing_configuration_exits_with_usage_code_two() { let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) From 7d83b4fd84cc45bd267bd419240b43a0c570e30a Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 23:58:44 +0200 Subject: [PATCH 06/33] feat: derive historical Matrix V2 plan profile --- src/lib.rs | 1 + src/matrix.rs | 82 ++++++++++++++++- src/matrix_legacy.rs | 188 +++++++++++++++++++++++++++++++++++++++ tests/matrix_contract.rs | 83 ++++++++++++++++- 4 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 src/matrix_legacy.rs diff --git a/src/lib.rs b/src/lib.rs index a2ba8d6..6deded9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod config; pub mod durable_fs; pub mod github_actions; pub mod matrix; +mod matrix_legacy; pub mod process; pub mod receipt; pub mod resource; diff --git a/src/matrix.rs b/src/matrix.rs index e6cb341..24f4f22 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -33,6 +33,7 @@ use crate::config::{ ExecutionPlanV1, NormalizedCache, NormalizedEnvironment, NormalizedReceipt, NormalizedRuntime, ReceiptConfig, RuntimeConfig, RuntimeKind, validate_identifier, }; +use crate::matrix_legacy::{LegacyMatrixDigestBasisV1, project_legacy_basis}; use crate::process::{CancellationToken, SupervisorPort}; use crate::receipt::{ EvidenceStatus, ProducerEvidence, ReceiptEnvelopeV1, ReceiptError, ReceiptV1, @@ -148,6 +149,17 @@ impl MatrixCheckConfigV2 { pub struct MatrixPlanEnvelopeV2 { pub plan_digest: String, pub plan: MatrixPlanV2, + #[serde(skip)] + profile: MatrixPlanProfile, + #[serde(skip)] + legacy_basis: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MatrixPlanProfile { + #[default] + CurrentV2, + LegacyV1, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -186,6 +198,22 @@ impl MatrixConfigV2 { } pub fn into_plan(self) -> Result { + build_matrix_plan(self, MatrixPlanProfile::default()) + } +} + +pub fn build_matrix_plan( + config: MatrixConfigV2, + profile: MatrixPlanProfile, +) -> Result { + config.build_plan_with_profile(profile) +} + +impl MatrixConfigV2 { + fn build_plan_with_profile( + self, + profile: MatrixPlanProfile, + ) -> Result { if self.schema_version != MATRIX_CONFIG_SCHEMA_VERSION { return Err(MatrixError::UnsupportedSchemaVersion(self.schema_version)); } @@ -264,7 +292,7 @@ impl MatrixConfigV2 { checks: group.plan.checks, }); } - let plan = MatrixPlanV2 { + let mut plan = MatrixPlanV2 { schema_version: MATRIX_CONFIG_SCHEMA_VERSION.to_owned(), project: self.project, receipt: shared_receipt.expect("at least two runtimes"), @@ -272,12 +300,56 @@ impl MatrixConfigV2 { caches: shared_caches.expect("at least two runtimes"), runtimes: runtime_plans, }; - let plan_digest = canonical_digest(&plan).map_err(MatrixError::Receipt)?; - Ok(MatrixPlanEnvelopeV2 { plan_digest, plan }) + match profile { + MatrixPlanProfile::CurrentV2 => { + let plan_digest = canonical_digest(&plan).map_err(MatrixError::Receipt)?; + Ok(MatrixPlanEnvelopeV2 { + plan_digest, + plan, + profile, + legacy_basis: None, + }) + } + MatrixPlanProfile::LegacyV1 => { + let legacy_basis = project_legacy_basis(&plan)?; + for runtime in &mut plan.runtimes { + runtime.configuration_digest = + legacy_basis.runtime_digest(&runtime.id)?.to_owned(); + } + let plan_digest = legacy_basis.outer_digest()?; + Ok(MatrixPlanEnvelopeV2 { + plan_digest, + plan, + profile, + legacy_basis: Some(legacy_basis), + }) + } + } } } impl MatrixPlanEnvelopeV2 { + pub fn profile(&self) -> MatrixPlanProfile { + self.profile + } + + pub fn plan_digest(&self) -> &str { + &self.plan_digest + } + + pub fn runtime_configuration_digest(&self, id: &str) -> Result<&str, MatrixError> { + match &self.legacy_basis { + Some(basis) => basis.runtime_digest(id), + None => self + .plan + .runtimes + .iter() + .find(|runtime| runtime.id == id) + .map(|runtime| runtime.configuration_digest.as_str()) + .ok_or_else(|| MatrixError::UnknownRuntime(id.to_owned())), + } + } + pub fn canonical_bytes(&self) -> Result, MatrixError> { let expected = canonical_digest(&self.plan).map_err(MatrixError::Receipt)?; if expected != self.plan_digest { @@ -914,6 +986,7 @@ pub enum MatrixError { UnknownRuntime(String), RuntimeWithoutRequiredCheck(String), CrossRuntimeDependency { check: String, dependency: String }, + LegacyPlanNotRepresentable(&'static str), PlanDigestMismatch, ReceiptIdMismatch, InvalidReceipt, @@ -951,6 +1024,9 @@ impl fmt::Display for MatrixError { formatter, "matrix cross-runtime dependency is unsupported: {check} -> {dependency}" ), + Self::LegacyPlanNotRepresentable(field) => { + write!(formatter, "matrix legacy plan cannot represent: {field}") + } Self::PlanDigestMismatch => write!(formatter, "matrix plan digest mismatch"), Self::ReceiptIdMismatch => write!(formatter, "matrix receipt identifier mismatch"), Self::InvalidReceipt => { diff --git a/src/matrix_legacy.rs b/src/matrix_legacy.rs new file mode 100644 index 0000000..6da7683 --- /dev/null +++ b/src/matrix_legacy.rs @@ -0,0 +1,188 @@ +// Copyright 2026 Marco Porcellato +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::config::{NormalizedCache, NormalizedReceipt, NormalizedRuntime}; +use crate::matrix::{MatrixError, MatrixPlanV2}; +use crate::receipt::canonical_digest; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LegacyMatrixDigestBasisV1 { + plan: LegacyMatrixPlanV2, + runtime_digests: BTreeMap, +} + +impl LegacyMatrixDigestBasisV1 { + pub(crate) fn outer_digest(&self) -> Result { + canonical_digest(&self.plan).map_err(MatrixError::Receipt) + } + + pub(crate) fn runtime_digest(&self, id: &str) -> Result<&str, MatrixError> { + self.runtime_digests + .get(id) + .map(String::as_str) + .ok_or_else(|| MatrixError::UnknownRuntime(id.to_owned())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyExecutionPlanV1 { + schema_version: String, + project: String, + runtime: LegacyNormalizedRuntime, + receipt: NormalizedReceipt, + environment_allow: Vec, + caches: Vec, + checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyMatrixPlanV2 { + schema_version: String, + project: String, + receipt: NormalizedReceipt, + environment_allow: Vec, + caches: Vec, + runtimes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedRuntime { + kind: crate::config::RuntimeKind, + image: String, + cpu_count: u16, + memory_mib: u64, + pids_limit: u32, + network: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedCheck { + id: String, + required: bool, + argv: Vec, + working_directory: String, + timeout_seconds: u64, + depends_on: Vec, + artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyMatrixRuntimePlanV2 { + id: String, + configuration_digest: String, + runtime: LegacyNormalizedRuntime, + checks: Vec, +} + +pub(crate) fn project_legacy_basis( + plan: &MatrixPlanV2, +) -> Result { + for runtime in &plan.runtimes { + validate_runtime_representability(&runtime.runtime)?; + } + if !plan.environment.fixed.is_empty() { + return Err(MatrixError::LegacyPlanNotRepresentable("environment.fixed")); + } + if !plan.environment.runtime_internal.is_empty() { + return Err(MatrixError::LegacyPlanNotRepresentable( + "environment.runtime_internal", + )); + } + if !plan.environment.remote_secret_only.is_empty() { + return Err(MatrixError::LegacyPlanNotRepresentable( + "environment.remote_secret_only", + )); + } + let mut runtime_digests = BTreeMap::new(); + let mut runtimes = Vec::with_capacity(plan.runtimes.len()); + for runtime in &plan.runtimes { + let checks = runtime + .checks + .iter() + .map(|check| { + if !check.artifact_contracts.is_empty() { + return Err(MatrixError::LegacyPlanNotRepresentable( + "checks.artifact_contracts", + )); + } + Ok(LegacyNormalizedCheck { + id: check.id.clone(), + required: check.required, + argv: check.argv.clone(), + working_directory: check.working_directory.clone(), + timeout_seconds: check.timeout_seconds, + depends_on: check.depends_on.clone(), + artifacts: check.artifacts.clone(), + }) + }) + .collect::, MatrixError>>()?; + let legacy_runtime = legacy_runtime(&runtime.runtime); + let configuration_digest = canonical_digest(&LegacyExecutionPlanV1 { + schema_version: "1.0".to_owned(), + project: plan.project.clone(), + runtime: legacy_runtime.clone(), + receipt: plan.receipt.clone(), + environment_allow: plan.environment.inherit.clone(), + caches: plan.caches.clone(), + checks: checks.clone(), + }) + .map_err(MatrixError::Receipt)?; + runtime_digests.insert(runtime.id.clone(), configuration_digest.clone()); + runtimes.push(LegacyMatrixRuntimePlanV2 { + id: runtime.id.clone(), + configuration_digest, + runtime: legacy_runtime, + checks, + }); + } + + Ok(LegacyMatrixDigestBasisV1 { + plan: LegacyMatrixPlanV2 { + schema_version: plan.schema_version.clone(), + project: plan.project.clone(), + receipt: plan.receipt.clone(), + environment_allow: plan.environment.inherit.clone(), + caches: plan.caches.clone(), + runtimes, + }, + runtime_digests, + }) +} + +fn validate_runtime_representability(runtime: &NormalizedRuntime) -> Result<(), MatrixError> { + if runtime.pull_policy.is_some() { + return Err(MatrixError::LegacyPlanNotRepresentable( + "runtime.pull_policy", + )); + } + if runtime.swap_mode.is_some() { + return Err(MatrixError::LegacyPlanNotRepresentable("runtime.swap_mode")); + } + Ok(()) +} + +fn legacy_runtime(runtime: &NormalizedRuntime) -> LegacyNormalizedRuntime { + LegacyNormalizedRuntime { + kind: runtime.kind, + image: runtime.image.clone(), + cpu_count: runtime.cpu_count, + memory_mib: runtime.memory_mib, + pids_limit: runtime.pids_limit, + network: runtime.network, + } +} diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 5d155fc..3078e47 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -5,10 +5,10 @@ use commit_ci_preflight::matrix::{ MATRIX_CONFIG_SCHEMA_VERSION, MATRIX_POLICY_SCHEMA_VERSION, MATRIX_RECEIPT_SCHEMA_VERSION, - MatrixConfigV2, MatrixReceiptEnvelopeV2, MatrixReceiptV2, MatrixRequiredCheckV2, - MatrixRuntimePolicyV2, MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, - matrix_config_schema_json, matrix_policy_schema_json, matrix_receipt_schema_json, - verify_matrix_receipt_document, + MatrixConfigV2, MatrixError, MatrixPlanProfile, MatrixReceiptEnvelopeV2, MatrixReceiptV2, + MatrixRequiredCheckV2, MatrixRuntimePolicyV2, MatrixRuntimeReceiptV2, + MatrixVerificationPolicyV2, build_matrix_plan, matrix_config_schema_json, + matrix_policy_schema_json, matrix_receipt_schema_json, verify_matrix_receipt_document, }; use commit_ci_preflight::receipt::{ CheckEvidence, EvidenceStatus, PlatformEvidence, ProducerEvidence, ReceiptEnvelopeV1, @@ -271,6 +271,81 @@ timeout_seconds = 30 ); } +#[test] +fn legacy_profile_reproduces_historical_plan() { + let provenance: Value = serde_json::from_str(include_str!( + "fixtures/matrix-v2-legacy-plan-044697.provenance.json" + )) + .expect("provenance JSON"); + let config = MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"); + let envelope = build_matrix_plan(config, MatrixPlanProfile::LegacyV1).expect("legacy plan"); + + assert_eq!(MatrixPlanProfile::default(), MatrixPlanProfile::CurrentV2); + assert_eq!(envelope.profile(), MatrixPlanProfile::LegacyV1); + assert_eq!( + envelope.plan_digest(), + provenance["outer_digest"].as_str().expect("outer digest") + ); + for runtime in ["python311", "python312"] { + assert_eq!( + envelope + .runtime_configuration_digest(runtime) + .expect("known runtime"), + provenance["runtime_digests"][runtime] + .as_str() + .expect("runtime digest") + ); + } + assert!(matches!( + envelope.runtime_configuration_digest("unknown"), + Err(MatrixError::UnknownRuntime(id)) if id == "unknown" + )); +} + +#[test] +fn legacy_profile_is_canonical_across_runtime_and_check_declaration_order() { + let first = MatrixConfigV2::parse(legacy_compatible_config()).expect("parse first"); + let mut reordered = MatrixConfigV2::parse(legacy_compatible_config()).expect("parse second"); + reordered.runtimes.reverse(); + reordered.checks.reverse(); + + let first = build_matrix_plan(first, MatrixPlanProfile::LegacyV1).expect("first plan"); + let second = build_matrix_plan(reordered, MatrixPlanProfile::LegacyV1).expect("second plan"); + assert_eq!(first.plan_digest(), second.plan_digest()); + for runtime in ["python311", "python312"] { + assert_eq!( + first + .runtime_configuration_digest(runtime) + .expect("first runtime"), + second + .runtime_configuration_digest(runtime) + .expect("second runtime") + ); + } +} + +#[test] +fn production_sources_do_not_embed_adopter_expected_digests() { + let prohibited = [ + "25b35b942a6ff9b6237ebed7cefbdbc96b968bbe8954a38b606942f36b8df4b2", + "b3d8beef1542566d9d925bfee77d2244995dc74adcd879128ef65e82ed1d354b", + "d446c4ca0602c09eee61c796ad2972f58ab0eebe84a39f928fd90aac5bfb535c", + "13f4cb39b7e1a8ed31cae64502cc8e4d80d040230d3fb410a6afc3bad3b76178", + "eff5b7d55bb0220890dbfb050bb68a1e0fbba8f9a30a69e2f66085354fcc8562", + "7afb3e6dd435d9d5a317e4d9d85e80527431044312bbe299e9a70b6ba9e994c8", + ]; + for path in ["src/matrix.rs", "src/matrix_legacy.rs"] { + let source = std::fs::read_to_string(format!("{}/{}", env!("CARGO_MANIFEST_DIR"), path)) + .expect("production source"); + for digest in prohibited { + assert!( + !source.contains(digest), + "production source {path} embeds adopter digest {digest}" + ); + } + } +} + #[test] fn v2_matrix_configuration_rejects_single_runtime_environment_classes() { let input = format!( From ebad10aa6365cfe0eae94260b1a6129c54e3be73 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 00:19:47 +0200 Subject: [PATCH 07/33] fix: enforce Matrix V2 legacy profile binding --- src/matrix.rs | 45 ++++++++++++++-- src/matrix_legacy.rs | 45 ++++++++++------ tests/matrix_contract.rs | 110 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 174 insertions(+), 26 deletions(-) diff --git a/src/matrix.rs b/src/matrix.rs index 24f4f22..04ebcf7 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -333,14 +333,20 @@ impl MatrixPlanEnvelopeV2 { self.profile } - pub fn plan_digest(&self) -> &str { - &self.plan_digest + pub fn plan_digest(&self) -> Result<&str, MatrixError> { + self.validate_profile_binding()?; + Ok(&self.plan_digest) } pub fn runtime_configuration_digest(&self, id: &str) -> Result<&str, MatrixError> { - match &self.legacy_basis { - Some(basis) => basis.runtime_digest(id), - None => self + self.validate_profile_binding()?; + match self.profile { + MatrixPlanProfile::LegacyV1 => self + .legacy_basis + .as_ref() + .ok_or(MatrixError::PlanDigestMismatch)? + .runtime_digest(id), + MatrixPlanProfile::CurrentV2 => self .plan .runtimes .iter() @@ -350,6 +356,35 @@ impl MatrixPlanEnvelopeV2 { } } + fn validate_profile_binding(&self) -> Result<(), MatrixError> { + match self.profile { + MatrixPlanProfile::CurrentV2 => { + if self.legacy_basis.is_some() + || canonical_digest(&self.plan).map_err(MatrixError::Receipt)? + != self.plan_digest + { + return Err(MatrixError::PlanDigestMismatch); + } + } + MatrixPlanProfile::LegacyV1 => { + let basis = self + .legacy_basis + .as_ref() + .ok_or(MatrixError::PlanDigestMismatch)?; + let projected = project_legacy_basis(&self.plan)?; + if &projected != basis || projected.outer_digest()? != self.plan_digest { + return Err(MatrixError::PlanDigestMismatch); + } + for runtime in &self.plan.runtimes { + if projected.runtime_digest(&runtime.id)? != runtime.configuration_digest { + return Err(MatrixError::PlanDigestMismatch); + } + } + } + } + Ok(()) + } + pub fn canonical_bytes(&self) -> Result, MatrixError> { let expected = canonical_digest(&self.plan).map_err(MatrixError::Receipt)?; if expected != self.plan_digest { diff --git a/src/matrix_legacy.rs b/src/matrix_legacy.rs index 6da7683..fd183bf 100644 --- a/src/matrix_legacy.rs +++ b/src/matrix_legacy.rs @@ -92,25 +92,36 @@ struct LegacyMatrixRuntimePlanV2 { pub(crate) fn project_legacy_basis( plan: &MatrixPlanV2, ) -> Result { - for runtime in &plan.runtimes { + let MatrixPlanV2 { + schema_version, + project, + receipt, + environment, + caches, + runtimes, + } = plan; + // Matrix V2 has no storage field today. This intentionally exhaustive + // destructuring makes any future field addition fail compilation until its + // legacy representability classification, including storage, is explicit. + for runtime in runtimes { validate_runtime_representability(&runtime.runtime)?; } - if !plan.environment.fixed.is_empty() { + if !environment.fixed.is_empty() { return Err(MatrixError::LegacyPlanNotRepresentable("environment.fixed")); } - if !plan.environment.runtime_internal.is_empty() { + if !environment.runtime_internal.is_empty() { return Err(MatrixError::LegacyPlanNotRepresentable( "environment.runtime_internal", )); } - if !plan.environment.remote_secret_only.is_empty() { + if !environment.remote_secret_only.is_empty() { return Err(MatrixError::LegacyPlanNotRepresentable( "environment.remote_secret_only", )); } let mut runtime_digests = BTreeMap::new(); - let mut runtimes = Vec::with_capacity(plan.runtimes.len()); - for runtime in &plan.runtimes { + let mut legacy_runtimes = Vec::with_capacity(runtimes.len()); + for runtime in runtimes { let checks = runtime .checks .iter() @@ -134,16 +145,16 @@ pub(crate) fn project_legacy_basis( let legacy_runtime = legacy_runtime(&runtime.runtime); let configuration_digest = canonical_digest(&LegacyExecutionPlanV1 { schema_version: "1.0".to_owned(), - project: plan.project.clone(), + project: project.clone(), runtime: legacy_runtime.clone(), - receipt: plan.receipt.clone(), - environment_allow: plan.environment.inherit.clone(), - caches: plan.caches.clone(), + receipt: receipt.clone(), + environment_allow: environment.inherit.clone(), + caches: caches.clone(), checks: checks.clone(), }) .map_err(MatrixError::Receipt)?; runtime_digests.insert(runtime.id.clone(), configuration_digest.clone()); - runtimes.push(LegacyMatrixRuntimePlanV2 { + legacy_runtimes.push(LegacyMatrixRuntimePlanV2 { id: runtime.id.clone(), configuration_digest, runtime: legacy_runtime, @@ -153,12 +164,12 @@ pub(crate) fn project_legacy_basis( Ok(LegacyMatrixDigestBasisV1 { plan: LegacyMatrixPlanV2 { - schema_version: plan.schema_version.clone(), - project: plan.project.clone(), - receipt: plan.receipt.clone(), - environment_allow: plan.environment.inherit.clone(), - caches: plan.caches.clone(), - runtimes, + schema_version: schema_version.clone(), + project: project.clone(), + receipt: receipt.clone(), + environment_allow: environment.inherit.clone(), + caches: caches.clone(), + runtimes: legacy_runtimes, }, runtime_digests, }) diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 3078e47..a53f896 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -3,10 +3,14 @@ // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. +use commit_ci_preflight::config::{ + ArtifactKind, NormalizedArtifactContract, NormalizedFixedEnvironment, + NormalizedRuntimeInternalEnvironment, RuntimePullPolicy, RuntimeSwapMode, +}; use commit_ci_preflight::matrix::{ MATRIX_CONFIG_SCHEMA_VERSION, MATRIX_POLICY_SCHEMA_VERSION, MATRIX_RECEIPT_SCHEMA_VERSION, - MatrixConfigV2, MatrixError, MatrixPlanProfile, MatrixReceiptEnvelopeV2, MatrixReceiptV2, - MatrixRequiredCheckV2, MatrixRuntimePolicyV2, MatrixRuntimeReceiptV2, + MatrixConfigV2, MatrixError, MatrixPlanEnvelopeV2, MatrixPlanProfile, MatrixReceiptEnvelopeV2, + MatrixReceiptV2, MatrixRequiredCheckV2, MatrixRuntimePolicyV2, MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, build_matrix_plan, matrix_config_schema_json, matrix_policy_schema_json, matrix_receipt_schema_json, verify_matrix_receipt_document, }; @@ -283,7 +287,7 @@ fn legacy_profile_reproduces_historical_plan() { assert_eq!(MatrixPlanProfile::default(), MatrixPlanProfile::CurrentV2); assert_eq!(envelope.profile(), MatrixPlanProfile::LegacyV1); assert_eq!( - envelope.plan_digest(), + envelope.plan_digest().expect("legacy digest"), provenance["outer_digest"].as_str().expect("outer digest") ); for runtime in ["python311", "python312"] { @@ -311,7 +315,10 @@ fn legacy_profile_is_canonical_across_runtime_and_check_declaration_order() { let first = build_matrix_plan(first, MatrixPlanProfile::LegacyV1).expect("first plan"); let second = build_matrix_plan(reordered, MatrixPlanProfile::LegacyV1).expect("second plan"); - assert_eq!(first.plan_digest(), second.plan_digest()); + assert_eq!( + first.plan_digest().expect("first digest"), + second.plan_digest().expect("second digest") + ); for runtime in ["python311", "python312"] { assert_eq!( first @@ -324,6 +331,101 @@ fn legacy_profile_is_canonical_across_runtime_and_check_declaration_order() { } } +#[test] +fn legacy_profile_accessors_reject_mutated_public_plan() { + let mut envelope = build_matrix_plan( + MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"), + MatrixPlanProfile::LegacyV1, + ) + .expect("legacy plan"); + envelope.plan.project = "example/mutated-project".to_owned(); + + assert!(matches!( + envelope.plan_digest(), + Err(MatrixError::PlanDigestMismatch) + )); + assert!(matches!( + envelope.runtime_configuration_digest("python311"), + Err(MatrixError::PlanDigestMismatch) + )); +} + +#[test] +fn legacy_profile_rejects_each_non_representable_current_field() { + let cases: [(&str, fn(&mut MatrixPlanEnvelopeV2)); 6] = + [ + ("runtime.pull_policy", |envelope| { + envelope.plan.runtimes[0].runtime.pull_policy = Some(RuntimePullPolicy::Never); + }), + ("runtime.swap_mode", |envelope| { + envelope.plan.runtimes[0].runtime.swap_mode = Some(RuntimeSwapMode::Disabled); + }), + ("environment.fixed", |envelope| { + envelope + .plan + .environment + .fixed + .push(NormalizedFixedEnvironment { + name: "FIXED".to_owned(), + value_digest: DIGEST.to_owned(), + }); + }), + ("environment.runtime_internal", |envelope| { + envelope.plan.environment.runtime_internal.push( + NormalizedRuntimeInternalEnvironment { + name: "INTERNAL".to_owned(), + cache_id: "cargo".to_owned(), + container_target: "/cache".to_owned(), + }, + ); + }), + ("environment.remote_secret_only", |envelope| { + envelope + .plan + .environment + .remote_secret_only + .push("SECRET".to_owned()); + }), + ("checks.artifact_contracts", |envelope| { + envelope.plan.runtimes[0].checks[0].artifact_contracts.push( + NormalizedArtifactContract { + path: "artifact.txt".to_owned(), + kind: ArtifactKind::RegularFile, + max_bytes: 1, + max_entries: 1, + producer_check: "python311-version".to_owned(), + }, + ); + }), + ]; + + for (field, mutate) in cases { + let mut envelope = build_matrix_plan( + MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"), + MatrixPlanProfile::LegacyV1, + ) + .expect("legacy plan"); + mutate(&mut envelope); + assert!(matches!( + envelope.plan_digest(), + Err(MatrixError::LegacyPlanNotRepresentable(actual)) if actual == field + )); + } +} + +#[test] +fn default_matrix_plan_matches_explicit_current_profile() { + let config = MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"); + let default = config.clone().into_plan().expect("default plan"); + let current = build_matrix_plan(config, MatrixPlanProfile::CurrentV2).expect("current plan"); + + assert_eq!(default, current); + assert_eq!( + default.plan_digest().expect("default digest"), + current.plan_digest().expect("current digest") + ); +} + #[test] fn production_sources_do_not_embed_adopter_expected_digests() { let prohibited = [ From b8dd479004dc9508b1a708b356687d5d571f769d Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 00:44:02 +0200 Subject: [PATCH 08/33] feat: bind legacy Matrix digests to runtime envelopes --- src/matrix.rs | 22 +++++++++------ tests/matrix_contract.rs | 58 ++++++++++++++++++++++++++++++++++++++++ tests/runtime_cli.rs | 52 +++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/matrix.rs b/src/matrix.rs index 04ebcf7..25a3310 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -386,14 +386,12 @@ impl MatrixPlanEnvelopeV2 { } pub fn canonical_bytes(&self) -> Result, MatrixError> { - let expected = canonical_digest(&self.plan).map_err(MatrixError::Receipt)?; - if expected != self.plan_digest { - return Err(MatrixError::PlanDigestMismatch); - } + self.validate_profile_binding()?; canonical_json(self).map_err(MatrixError::Receipt) } pub fn runtime_envelopes(&self) -> Result, MatrixError> { + self.validate_profile_binding()?; let mut result = Vec::with_capacity(self.plan.runtimes.len()); for runtime in &self.plan.runtimes { let plan = ExecutionPlanV1 { @@ -406,10 +404,18 @@ impl MatrixPlanEnvelopeV2 { storage: None, checks: runtime.checks.clone(), }; - let plan_digest = canonical_digest(&plan).map_err(MatrixError::Receipt)?; - if plan_digest != runtime.configuration_digest { - return Err(MatrixError::PlanDigestMismatch); - } + let plan_digest = match self.profile { + MatrixPlanProfile::CurrentV2 => { + let digest = canonical_digest(&plan).map_err(MatrixError::Receipt)?; + if digest != runtime.configuration_digest { + return Err(MatrixError::PlanDigestMismatch); + } + digest + } + MatrixPlanProfile::LegacyV1 => { + self.runtime_configuration_digest(&runtime.id)?.to_owned() + } + }; result.push(( runtime.id.clone(), ExecutionPlanEnvelopeV1 { diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index a53f896..3b88cd2 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -350,6 +350,64 @@ fn legacy_profile_accessors_reject_mutated_public_plan() { )); } +#[test] +fn legacy_runtime_envelopes_recheck_projection() { + let legacy = build_matrix_plan( + MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"), + MatrixPlanProfile::LegacyV1, + ) + .expect("legacy plan"); + let current = build_matrix_plan( + MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"), + MatrixPlanProfile::CurrentV2, + ) + .expect("current plan"); + + let legacy_runtimes = legacy.runtime_envelopes().expect("legacy envelopes"); + let current_runtimes = current.runtime_envelopes().expect("current envelopes"); + for ((legacy_id, legacy_runtime), (current_id, current_runtime)) in + legacy_runtimes.iter().zip(current_runtimes.iter()) + { + assert_eq!(legacy_id, current_id); + assert_eq!( + legacy_runtime.plan_digest, + legacy + .runtime_configuration_digest(legacy_id) + .expect("legacy runtime digest") + ); + assert_ne!(legacy_runtime.plan_digest, current_runtime.plan_digest); + } + + let cases: [(&str, fn(&mut MatrixPlanEnvelopeV2)); 4] = [ + ("runtime", |envelope| { + envelope.plan.runtimes[0].runtime.image = "example.invalid/mutated@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(); + }), + ("check", |envelope| { + envelope.plan.runtimes[0].checks[0] + .argv + .push("--mutated".to_owned()); + }), + ("environment", |envelope| { + envelope.plan.environment.inherit.push("MUTATED".to_owned()); + }), + ("non-representable runtime field", |envelope| { + envelope.plan.runtimes[0].runtime.pull_policy = Some(RuntimePullPolicy::Never); + }), + ]; + + for (field, mutate) in cases { + let mut envelope = legacy.clone(); + mutate(&mut envelope); + assert!( + matches!( + envelope.runtime_envelopes(), + Err(MatrixError::PlanDigestMismatch | MatrixError::LegacyPlanNotRepresentable(_)) + ), + "runtime conversion must reject mutated {field}" + ); + } +} + #[test] fn legacy_profile_rejects_each_non_representable_current_field() { let cases: [(&str, fn(&mut MatrixPlanEnvelopeV2)); 6] = diff --git a/tests/runtime_cli.rs b/tests/runtime_cli.rs index efe516b..54266b1 100644 --- a/tests/runtime_cli.rs +++ b/tests/runtime_cli.rs @@ -16,6 +16,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use commit_ci_preflight::cache::{CacheKey, CacheRootSource, ResolvedCacheRoot}; +use commit_ci_preflight::matrix::{MatrixConfigV2, MatrixPlanProfile, build_matrix_plan}; use serde_json::Value; fn binary() -> &'static str { @@ -37,6 +39,56 @@ fn executable_fixture_root(prefix: &str) -> PathBuf { .join(format!("{prefix}-{}", std::process::id())) } +#[test] +fn legacy_profile_uses_distinct_plan_cache_identity() { + let legacy = build_matrix_plan( + MatrixConfigV2::parse(include_str!("fixtures/config-v2-legacy-compatible.toml")) + .expect("parse legacy fixture"), + MatrixPlanProfile::LegacyV1, + ) + .expect("legacy plan"); + let current = build_matrix_plan( + MatrixConfigV2::parse(include_str!("fixtures/config-v2-legacy-compatible.toml")) + .expect("parse current fixture"), + MatrixPlanProfile::CurrentV2, + ) + .expect("current plan"); + let legacy_runtime = legacy + .runtime_envelopes() + .expect("legacy envelopes") + .into_iter() + .find(|(id, _)| id == "python311") + .expect("legacy python311") + .1; + let current_runtime = current + .runtime_envelopes() + .expect("current envelopes") + .into_iter() + .find(|(id, _)| id == "python311") + .expect("current python311") + .1; + + let cache = ResolvedCacheRoot { + path: PathBuf::from("/owned-test-cache-root"), + source: CacheRootSource::Explicit, + }; + + let legacy_key = CacheKey::for_plan_cache(&legacy_runtime, &legacy_runtime.plan.caches[0]) + .expect("legacy cache key"); + let current_key = CacheKey::for_plan_cache(¤t_runtime, ¤t_runtime.plan.caches[0]) + .expect("current cache key"); + assert_ne!(legacy_runtime.plan_digest, current_runtime.plan_digest); + assert_ne!( + cache + .workspace_path(&legacy_runtime.plan_digest) + .expect("legacy workspace path"), + cache + .workspace_path(¤t_runtime.plan_digest) + .expect("current workspace path") + ); + assert_ne!(legacy_key.directory_name(), current_key.directory_name()); +} + #[test] fn dry_run_json_is_deterministic_and_never_executes_the_declared_argv() { let marker = std::env::temp_dir().join(format!("ccp-dry-run-marker-{}", std::process::id())); From d62ca2ab6c510d79f216ba12fab99a2e4472a60b Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 01:17:41 +0200 Subject: [PATCH 09/33] feat: expose Matrix V2 compatibility profile --- src/main.rs | 192 +++++++++++++++--- src/matrix.rs | 13 ++ src/matrix_legacy.rs | 4 + .../plan-v2-current-default.stdout.json | 1 + tests/plan_cli.rs | 72 +++++++ tests/runtime_cli.rs | 55 +++++ 6 files changed, 304 insertions(+), 33 deletions(-) create mode 100644 tests/fixtures/plan-v2-current-default.stdout.json diff --git a/src/main.rs b/src/main.rs index 7a80c4f..bdec044 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,8 @@ use commit_ci_preflight::github_actions::{ GithubActionsError, MigrationReadiness, analyze_workflow_file, }; use commit_ci_preflight::matrix::{ - MatrixConfigV2, MatrixError, MatrixPlanEnvelopeV2, MatrixRunRequestV2, execute_matrix_run_v2, + MatrixConfigV2, MatrixError, MatrixPlanEnvelopeV2, MatrixPlanProfile, MatrixPlanV2, + MatrixRunRequestV2, build_matrix_plan, execute_matrix_run_v2, }; use commit_ci_preflight::process::{ CancellationReason, CancellationToken, GenerationGuard, OutputMode, ProcessRequest, @@ -116,6 +117,8 @@ enum Command { /// Emit canonical machine-readable JSON. #[arg(long)] json: bool, + #[command(flatten)] + matrix_plan_profile: MatrixPlanProfileArgs, }, /// Probe the configured runtime without running project checks. Doctor { @@ -125,6 +128,8 @@ enum Command { /// Emit machine-readable JSON. #[arg(long)] json: bool, + #[command(flatten)] + matrix_plan_profile: MatrixPlanProfileArgs, }, /// Render runtime argv without spawning a process. DryRun { @@ -136,6 +141,8 @@ enum Command { /// Emit machine-readable JSON. #[arg(long)] json: bool, + #[command(flatten)] + matrix_plan_profile: MatrixPlanProfileArgs, }, /// Execute the validated checks locally and write a canonical receipt. Run { @@ -153,6 +160,8 @@ enum Command { /// Maximum time to wait for the host-wide heavy-command slot. #[arg(long, default_value_t = DEFAULT_QUEUE_TIMEOUT.as_secs())] admission_timeout_seconds: u64, + #[command(flatten)] + matrix_plan_profile: MatrixPlanProfileArgs, }, /// Independently verify receipt integrity and repository policy. Verify { @@ -303,6 +312,27 @@ struct GuardExecArgs { argv: Vec, } +#[derive(Debug, Args)] +struct MatrixPlanProfileArgs { + /// Compatibility digest profile for Matrix V2 configuration. + #[arg( + long, + default_value = "current-v2", + value_parser = parse_matrix_plan_profile + )] + matrix_plan_profile: MatrixPlanProfile, +} + +fn parse_matrix_plan_profile(value: &str) -> Result { + match value { + "current-v2" => Ok(MatrixPlanProfile::CurrentV2), + "matrix-v2-legacy-v1" => Ok(MatrixPlanProfile::LegacyV1), + _ => Err(format!( + "unknown matrix plan profile {value:?}; expected current-v2 or matrix-v2-legacy-v1" + )), + } +} + #[derive(Debug, Clone, Copy, ValueEnum)] enum ResourceExecutorArg { Native, @@ -450,21 +480,37 @@ fn main() { Some(Command::Admission { action }) => run_admission_command(action), Some(Command::Resource { action }) => run_resource_command(action), Some(Command::Guard { action }) => run_guard_command(action), - Some(Command::Plan { config, json }) => print_plan(&config, json), - Some(Command::Doctor { config, json }) => print_doctor(&config, json), + Some(Command::Plan { + config, + json, + matrix_plan_profile, + }) => print_plan(&config, matrix_plan_profile.matrix_plan_profile, json), + Some(Command::Doctor { + config, + json, + matrix_plan_profile, + }) => print_doctor(&config, matrix_plan_profile.matrix_plan_profile, json), Some(Command::DryRun { config, location, json, - }) => print_dry_run(&config, &location, json), + matrix_plan_profile, + }) => print_dry_run( + &config, + matrix_plan_profile.matrix_plan_profile, + &location, + json, + ), Some(Command::Run { config, location, generation, json, admission_timeout_seconds, + matrix_plan_profile, }) => print_run( &config, + matrix_plan_profile.matrix_plan_profile, &location, generation, admission_timeout_seconds, @@ -953,13 +999,23 @@ fn print_verify( fn print_run( path: &Path, + profile: MatrixPlanProfile, location: &CacheLocationArgs, generation: u64, admission_timeout_seconds: u64, json: bool, ) -> Result<(), CliError> { - if config_schema_version(path)?.as_deref() == Some("2.0") { - return print_matrix_run(path, location, generation, admission_timeout_seconds, json); + if profile == MatrixPlanProfile::LegacyV1 + || config_schema_version(path)?.as_deref() == Some("2.0") + { + return print_matrix_run( + path, + profile, + location, + generation, + admission_timeout_seconds, + json, + ); } let envelope = load_plan(path)?; if !envelope.plan.environment.remote_secret_only.is_empty() { @@ -1193,15 +1249,13 @@ fn config_schema_version(path: &Path) -> Result, CliError> { fn print_matrix_run( path: &Path, + profile: MatrixPlanProfile, location: &CacheLocationArgs, generation: u64, admission_timeout_seconds: u64, json: bool, ) -> Result<(), CliError> { - let envelope = MatrixConfigV2::load(path) - .map_err(CliError::Matrix)? - .into_plan() - .map_err(CliError::Matrix)?; + let envelope = load_matrix_plan(path, profile)?; let root = resolve_cache_root(location)?; let cache = ManagedCache::initialize(root).map_err(CliError::Cache)?; let journal = RunJournalStore::initialize(&cache.root().path).map_err(CliError::RunJournal)?; @@ -1326,6 +1380,9 @@ fn print_matrix_run( .map_err(CliError::Matrix)?; println!("{}", String::from_utf8(bytes).map_err(CliError::internal)?); } else { + if profile == MatrixPlanProfile::LegacyV1 { + println!("Matrix plan profile: matrix-v2-legacy-v1"); + } println!("Matrix receipt: {}", outcome.receipt.receipt_id); println!( "Runtimes: {}", @@ -1447,16 +1504,71 @@ fn load_plan(path: &Path) -> Result { .map_err(CliError::usage) } -fn print_plan(path: &Path, json: bool) -> Result<(), CliError> { - if config_schema_version(path)?.as_deref() == Some("2.0") { - let envelope = MatrixConfigV2::load(path) - .map_err(CliError::Matrix)? - .into_plan() - .map_err(CliError::Matrix)?; +fn load_matrix_plan( + path: &Path, + profile: MatrixPlanProfile, +) -> Result { + if config_schema_version(path)?.as_deref() != Some("2.0") { + return Err(CliError::usage(MatrixPlanProfileSchemaError)); + } + let config = MatrixConfigV2::load(path).map_err(CliError::Matrix)?; + build_matrix_plan(config, profile).map_err(CliError::Matrix) +} + +#[derive(Debug)] +struct MatrixPlanProfileSchemaError; + +impl fmt::Display for MatrixPlanProfileSchemaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("matrix plan profile requires schema version 2.0") + } +} + +impl std::error::Error for MatrixPlanProfileSchemaError {} + +#[derive(Debug, Serialize)] +struct LegacyMatrixPlanReportV1 { + matrix_plan_profile: &'static str, + plan_digest: String, + plan: MatrixPlanV2, + legacy_digest_basis: serde_json::Value, +} + +fn legacy_matrix_plan_report( + envelope: &MatrixPlanEnvelopeV2, +) -> Result { + let legacy_digest_basis = envelope + .legacy_digest_basis_value() + .map_err(CliError::Matrix)? + .ok_or_else(|| CliError::internal(MatrixPlanProfileSchemaError))?; + Ok(LegacyMatrixPlanReportV1 { + matrix_plan_profile: "matrix-v2-legacy-v1", + plan_digest: envelope.plan_digest().map_err(CliError::Matrix)?.to_owned(), + plan: envelope.plan.clone(), + legacy_digest_basis, + }) +} + +fn print_plan(path: &Path, profile: MatrixPlanProfile, json: bool) -> Result<(), CliError> { + if profile == MatrixPlanProfile::LegacyV1 + || config_schema_version(path)?.as_deref() == Some("2.0") + { + let envelope = load_matrix_plan(path, profile)?; if json { - let bytes = envelope.canonical_bytes().map_err(CliError::Matrix)?; - println!("{}", String::from_utf8(bytes).map_err(CliError::internal)?); + if profile == MatrixPlanProfile::LegacyV1 { + println!( + "{}", + serde_json::to_string(&legacy_matrix_plan_report(&envelope)?) + .map_err(CliError::internal)? + ); + } else { + let bytes = envelope.canonical_bytes().map_err(CliError::Matrix)?; + println!("{}", String::from_utf8(bytes).map_err(CliError::internal)?); + } } else { + if profile == MatrixPlanProfile::LegacyV1 { + println!("Matrix plan profile: matrix-v2-legacy-v1"); + } println!("Matrix plan: {}", envelope.plan_digest); println!("Project: {}", envelope.plan.project); for runtime in &envelope.plan.runtimes { @@ -1483,9 +1595,16 @@ fn print_plan(path: &Path, json: bool) -> Result<(), CliError> { Ok(()) } -fn print_dry_run(path: &Path, location: &CacheLocationArgs, json: bool) -> Result<(), CliError> { - if config_schema_version(path)?.as_deref() == Some("2.0") { - return print_matrix_dry_run(path, location, json); +fn print_dry_run( + path: &Path, + profile: MatrixPlanProfile, + location: &CacheLocationArgs, + json: bool, +) -> Result<(), CliError> { + if profile == MatrixPlanProfile::LegacyV1 + || config_schema_version(path)?.as_deref() == Some("2.0") + { + return print_matrix_dry_run(path, profile, location, json); } let envelope = load_plan(path)?; let cache = resolve_cache_root(location)?; @@ -1522,13 +1641,11 @@ struct MatrixRuntimeDryRunV2 { fn print_matrix_dry_run( path: &Path, + profile: MatrixPlanProfile, location: &CacheLocationArgs, json: bool, ) -> Result<(), CliError> { - let envelope = MatrixConfigV2::load(path) - .map_err(CliError::Matrix)? - .into_plan() - .map_err(CliError::Matrix)?; + let envelope = load_matrix_plan(path, profile)?; let cache = resolve_cache_root(location)?; let mut runtimes = Vec::with_capacity(envelope.plan.runtimes.len()); for (runtime_id, runtime_envelope) in envelope.runtime_envelopes().map_err(CliError::Matrix)? { @@ -1555,6 +1672,9 @@ fn print_matrix_dry_run( serde_json::to_string(&report).map_err(CliError::internal)? ); } else { + if profile == MatrixPlanProfile::LegacyV1 { + println!("Matrix plan profile: matrix-v2-legacy-v1"); + } println!("Matrix plan: {}", report.plan_digest); for runtime in &report.runtimes { println!("Runtime ID: {}", runtime.runtime_id); @@ -2256,9 +2376,11 @@ fn print_serializable_or_path( Ok(()) } -fn print_doctor(path: &Path, json: bool) -> Result<(), CliError> { - if config_schema_version(path)?.as_deref() == Some("2.0") { - return print_matrix_doctor(path, json); +fn print_doctor(path: &Path, profile: MatrixPlanProfile, json: bool) -> Result<(), CliError> { + if profile == MatrixPlanProfile::LegacyV1 + || config_schema_version(path)?.as_deref() == Some("2.0") + { + return print_matrix_doctor(path, profile, json); } let envelope = load_plan(path)?; let runtime = runtime_for(envelope.plan.runtime.kind).map_err(CliError::Runtime)?; @@ -2322,11 +2444,12 @@ fn collect_matrix_doctor_report( }) } -fn print_matrix_doctor(path: &Path, json: bool) -> Result<(), CliError> { - let envelope = MatrixConfigV2::load(path) - .map_err(CliError::Matrix)? - .into_plan() - .map_err(CliError::Matrix)?; +fn print_matrix_doctor( + path: &Path, + profile: MatrixPlanProfile, + json: bool, +) -> Result<(), CliError> { + let envelope = load_matrix_plan(path, profile)?; let supervisor = ProcessSupervisor::standard(); let cancellation = CancellationToken::default(); install_cancellation_handler(&cancellation)?; @@ -2350,6 +2473,9 @@ fn print_matrix_doctor(path: &Path, json: bool) -> Result<(), CliError> { serde_json::to_string(&report).map_err(CliError::internal)? ); } else { + if profile == MatrixPlanProfile::LegacyV1 { + println!("Matrix plan profile: matrix-v2-legacy-v1"); + } println!("Matrix plan: {}", report.plan_digest); for runtime in &report.runtimes { println!("Runtime ID: {}", runtime.runtime_id); diff --git a/src/matrix.rs b/src/matrix.rs index 25a3310..60ff209 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -356,6 +356,19 @@ impl MatrixPlanEnvelopeV2 { } } + pub fn legacy_digest_basis_value(&self) -> Result, MatrixError> { + self.validate_profile_binding()?; + match self.profile { + MatrixPlanProfile::CurrentV2 => Ok(None), + MatrixPlanProfile::LegacyV1 => self + .legacy_basis + .as_ref() + .ok_or(MatrixError::PlanDigestMismatch)? + .report_value() + .map(Some), + } + } + fn validate_profile_binding(&self) -> Result<(), MatrixError> { match self.profile { MatrixPlanProfile::CurrentV2 => { diff --git a/src/matrix_legacy.rs b/src/matrix_legacy.rs index fd183bf..c5b6fe5 100644 --- a/src/matrix_legacy.rs +++ b/src/matrix_legacy.rs @@ -37,6 +37,10 @@ impl LegacyMatrixDigestBasisV1 { .map(String::as_str) .ok_or_else(|| MatrixError::UnknownRuntime(id.to_owned())) } + + pub(crate) fn report_value(&self) -> Result { + serde_json::to_value(&self.plan).map_err(MatrixError::Json) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] diff --git a/tests/fixtures/plan-v2-current-default.stdout.json b/tests/fixtures/plan-v2-current-default.stdout.json new file mode 100644 index 0000000..474abf1 --- /dev/null +++ b/tests/fixtures/plan-v2-current-default.stdout.json @@ -0,0 +1 @@ +{"plan":{"caches":[],"environment":{"fixed":[],"inherit":[],"remote_secret_only":[],"runtime_internal":[]},"project":"example/project","receipt":{"freshness_seconds":300,"output":".ccp/receipt.json"},"runtimes":[{"checks":[{"argv":["python","-V"],"artifacts":[],"depends_on":[],"id":"compat-py311","required":true,"timeout_seconds":30,"working_directory":"."}],"configuration_digest":"sha256:29e542a68a29235ff98fb7afab5f093c8212b1748ab8296987336ca1ffa40ea3","id":"python311","runtime":{"cpu_count":1,"image":"example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"docker_compatible","memory_mib":256,"network":false,"pids_limit":64}},{"checks":[{"argv":["python","-V"],"artifacts":[],"depends_on":[],"id":"repository-check","required":true,"timeout_seconds":30,"working_directory":"."}],"configuration_digest":"sha256:505d34cc1722e82b3633df6e3c92534a3b0836d4083cd32354eb1acde28511ec","id":"python312","runtime":{"cpu_count":1,"image":"example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","kind":"docker_compatible","memory_mib":256,"network":false,"pids_limit":64}}],"schema_version":"2.0"},"plan_digest":"sha256:a190ff8f9f55d0f09ab312d9d5386c284e6abcdb8bde0764840641b638e6c385"} diff --git a/tests/plan_cli.rs b/tests/plan_cli.rs index 12e9e8d..56bc77a 100644 --- a/tests/plan_cli.rs +++ b/tests/plan_cli.rs @@ -15,11 +15,14 @@ use std::process::Command; use commit_ci_preflight::config::config_schema_json; +use commit_ci_preflight::receipt::canonical_digest; const CONFIG: &str = "tests/fixtures/config-v1-read-only.toml"; const MATRIX_CONFIG: &str = "tests/fixtures/config-v2-matrix.toml"; const LEGACY_MATRIX_CONFIG: &str = "tests/fixtures/config-v2-legacy-compatible.toml"; const PINNED_SCHEMA: &str = include_str!("../schema/config-v1.schema.json"); +const CURRENT_V2_PLAN_STDOUT: &[u8] = + include_bytes!("fixtures/plan-v2-current-default.stdout.json"); #[test] fn generated_configuration_schema_matches_pinned_bytes() { @@ -94,6 +97,57 @@ fn v2_plan_exposes_reviewable_per_runtime_digests_without_execution() { } } +#[test] +fn matrix_current_profile_and_omission_preserve_pinned_default_plan_bytes() { + for profile in [None, Some("current-v2")] { + let mut command = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")); + command.args(["plan", "--config", MATRIX_CONFIG, "--json"]); + if let Some(profile) = profile { + command.args(["--matrix-plan-profile", profile]); + } + let output = command.output().expect("run current matrix plan command"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, CURRENT_V2_PLAN_STDOUT); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("plan JSON"); + assert!(json.get("matrix_plan_profile").is_none()); + assert!(json.get("legacy_digest_basis").is_none()); + } +} + +#[test] +fn matrix_legacy_profile_discloses_a_reconstructible_digest_basis() { + let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) + .args([ + "plan", + "--config", + LEGACY_MATRIX_CONFIG, + "--matrix-plan-profile", + "matrix-v2-legacy-v1", + "--json", + ]) + .output() + .expect("run legacy matrix plan command"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("legacy plan JSON"); + assert_eq!(json["matrix_plan_profile"], "matrix-v2-legacy-v1"); + assert!(json["legacy_digest_basis"].is_object()); + assert_eq!( + canonical_digest(&json["legacy_digest_basis"]).expect("canonical legacy digest basis"), + json["plan_digest"].as_str().expect("legacy plan digest") + ); + assert_eq!(json["plan"]["schema_version"], "2.0"); +} + #[test] fn legacy_matrix_profile_reproduces_the_pinned_historical_plan_digest() { let expected: serde_json::Value = serde_json::from_str(include_str!( @@ -121,6 +175,24 @@ fn legacy_matrix_profile_reproduces_the_pinned_historical_plan_digest() { assert_eq!(actual["plan_digest"], expected["plan_digest"]); } +#[test] +fn matrix_plan_profile_rejects_unknown_values_with_usage_exit_code() { + let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) + .args([ + "plan", + "--config", + MATRIX_CONFIG, + "--matrix-plan-profile", + "unknown-profile", + ]) + .output() + .expect("run unknown matrix profile command"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("unknown-profile")); +} + #[test] fn missing_configuration_exits_with_usage_code_two() { let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) diff --git a/tests/runtime_cli.rs b/tests/runtime_cli.rs index 54266b1..f8d89ed 100644 --- a/tests/runtime_cli.rs +++ b/tests/runtime_cli.rs @@ -195,6 +195,61 @@ timeout_seconds = 1 fs::remove_dir_all(root).expect("remove owned fixture root"); } +#[test] +fn matrix_legacy_profile_rejects_v1_schemas_before_runtime_cache_or_admission() { + let root = executable_fixture_root("ccp-legacy-profile-v1-schema"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("owned fixture root"); + let v1_fixture = fs::read_to_string(fixture()).expect("v1 fixture"); + + for schema_version in ["1.0", "1.1", "1.2", "1.3"] { + let config_path = root.join(format!("config-{schema_version}.toml")); + fs::write( + &config_path, + v1_fixture.replace( + "schema_version = \"1.0\"", + &format!("schema_version = \"{schema_version}\""), + ), + ) + .expect("legacy-incompatible fixture"); + + for command_name in ["plan", "doctor", "dry-run", "run"] { + let cache_dir = root.join(format!("cache-{command_name}-{schema_version}")); + let mut command = Command::new(binary()); + command + .arg(command_name) + .arg("--config") + .arg(&config_path) + .args(["--matrix-plan-profile", "matrix-v2-legacy-v1"]); + if matches!(command_name, "dry-run" | "run") { + command + .arg("--repository") + .arg(&root) + .arg("--cache-dir") + .arg(&cache_dir); + } + let output = command.output().expect("legacy profile command"); + + assert_eq!( + output.status.code(), + Some(2), + "{command_name} with schema {schema_version}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("matrix plan profile requires schema version 2.0") + ); + assert!( + !cache_dir.exists(), + "{command_name} with schema {schema_version} initialized a cache" + ); + } + } + fs::remove_dir_all(root).expect("remove owned fixture root"); +} + #[test] fn dry_run_human_output_states_that_argv_is_not_a_shell_and_was_not_run() { let output = Command::new(binary()) From 58c6219bd71cb9308a3c842dbde36cacb1cb7523 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 01:42:27 +0200 Subject: [PATCH 10/33] feat: disclose Matrix compatibility receipt provenance --- src/main.rs | 1 + src/matrix.rs | 51 ++++++++++++++++++++++++++++++++++++--- src/run.rs | 11 ++++++++- tests/matrix_contract.rs | 34 ++++++++++++++++++++++++++ tests/receipt_contract.rs | 10 ++++++++ 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index bdec044..7ee0bda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1180,6 +1180,7 @@ fn print_run( envelope: &envelope, repository: &location.repository, cache: &cache, + producer_version: env!("CARGO_PKG_VERSION"), generation, source_snapshot: Some(&source_snapshot), }, diff --git a/src/matrix.rs b/src/matrix.rs index 60ff209..63f0d72 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -162,6 +162,15 @@ pub enum MatrixPlanProfile { LegacyV1, } +impl MatrixPlanProfile { + pub const fn producer_version(self) -> &'static str { + match self { + Self::CurrentV2 => env!("CARGO_PKG_VERSION"), + Self::LegacyV1 => concat!(env!("CARGO_PKG_VERSION"), "+matrix-v2-legacy-v1"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MatrixPlanV2 { pub schema_version: String, @@ -469,10 +478,23 @@ pub fn execute_matrix_run_v2( clock: &dyn Clock, barrier: &mut dyn CompletionBarrier, ) -> Result { + request.envelope.validate_profile_binding()?; + let expected_producer = ProducerEvidence { + name: env!("CARGO_PKG_NAME").to_owned(), + version: request.envelope.profile().producer_version().to_owned(), + }; let started_at_utc = clock.now_utc().map_err(MatrixError::Run)?; let mut runtime_receipts = Vec::new(); let mut checks = Vec::new(); for (runtime_id, runtime_envelope) in request.envelope.runtime_envelopes()? { + request.envelope.validate_profile_binding()?; + let expected_configuration_digest = request + .envelope + .runtime_configuration_digest(&runtime_id)? + .to_owned(); + if runtime_envelope.plan_digest != expected_configuration_digest { + return Err(MatrixError::PlanDigestMismatch); + } let runtime = runtime_for(runtime_envelope.plan.runtime.kind).map_err(MatrixError::Runtime)?; let mut inner_barrier = NoopCompletionBarrier; @@ -482,6 +504,7 @@ pub fn execute_matrix_run_v2( envelope: &runtime_envelope, repository: request.repository, cache: request.cache, + producer_version: request.envelope.profile().producer_version(), generation: request.generation, source_snapshot: None, }, @@ -493,6 +516,11 @@ pub fn execute_matrix_run_v2( &mut lifecycle, ) .map_err(MatrixError::Run)?; + if receipt.receipt.producer != expected_producer + || receipt.receipt.configuration_digest != expected_configuration_digest + { + return Err(MatrixError::InvalidReceipt); + } checks.extend(receipt.receipt.checks.iter().cloned()); runtime_receipts.push(MatrixRuntimeReceiptV2 { runtime_id, @@ -503,7 +531,22 @@ pub fn execute_matrix_run_v2( let first = runtime_receipts .first() .ok_or(MatrixError::InvalidReceipt)?; - let producer = first.receipt.receipt.producer.clone(); + request.envelope.validate_profile_binding()?; + let configuration_digest = request.envelope.plan_digest()?.to_owned(); + if first.receipt.receipt.producer != expected_producer { + return Err(MatrixError::InvalidReceipt); + } + for runtime in &runtime_receipts { + let expected_configuration_digest = request + .envelope + .runtime_configuration_digest(&runtime.runtime_id)?; + if runtime.receipt.receipt.producer != first.receipt.receipt.producer + || runtime.receipt.receipt.producer != expected_producer + || runtime.receipt.receipt.configuration_digest != expected_configuration_digest + { + return Err(MatrixError::InvalidReceipt); + } + } let repository_evidence = first.receipt.receipt.repository.clone(); let redaction_policy_version = first.receipt.receipt.redaction_policy_version.clone(); let statuses: Vec<_> = checks @@ -517,14 +560,14 @@ pub fn execute_matrix_run_v2( schema_version: MATRIX_RECEIPT_SCHEMA_VERSION, project: &request.envelope.plan.project, commit: &repository_evidence.commit_sha, - configuration_digest: &request.envelope.plan_digest, + configuration_digest: &configuration_digest, generation: request.generation, started_at_utc: &started_at_utc, }) .map_err(MatrixError::Receipt)?; let receipt = MatrixReceiptEnvelopeV2::seal(MatrixReceiptV2 { schema_version: MATRIX_RECEIPT_SCHEMA_VERSION.to_owned(), - producer, + producer: expected_producer, repository: repository_evidence, run: RunEvidence { run_id, @@ -532,7 +575,7 @@ pub fn execute_matrix_run_v2( started_at_utc, finished_at_utc, }, - configuration_digest: request.envelope.plan_digest.clone(), + configuration_digest, runtime_receipts, overall_status, incomplete_reason: (overall_status == EvidenceStatus::Pending) diff --git a/src/run.rs b/src/run.rs index 507fdcf..0adc1a8 100644 --- a/src/run.rs +++ b/src/run.rs @@ -56,6 +56,7 @@ pub struct RunRequest<'a> { pub envelope: &'a ExecutionPlanEnvelopeV1, pub repository: &'a Path, pub cache: &'a ManagedCache, + pub producer_version: &'a str, pub generation: u64, pub source_snapshot: Option<&'a SourceSnapshot>, } @@ -665,7 +666,7 @@ fn execute_local_receipt_and_artifacts_with_barrier_and_lifecycle_and_storage_pr schema_version: crate::receipt::RECEIPT_SCHEMA_VERSION.to_owned(), producer: ProducerEvidence { name: env!("CARGO_PKG_NAME").to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), + version: request.producer_version.to_owned(), }, repository: RepositoryEvidence { repository: request.envelope.plan.project.clone(), @@ -1650,6 +1651,7 @@ depends_on = ["first"] envelope: &self.envelope, repository: &self.repository, cache: &self.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: None, }, @@ -1670,6 +1672,7 @@ depends_on = ["first"] envelope: &self.envelope, repository: &self.repository, cache: &self.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: None, }, @@ -1762,6 +1765,7 @@ depends_on = ["first"] envelope: &fixture.envelope, repository: &fixture.repository, cache: &fixture.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: Some(&snapshot), }, @@ -1853,6 +1857,7 @@ depends_on = ["first"] envelope: &fixture.envelope, repository: &fixture.repository, cache: &fixture.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: Some(&snapshot), }, @@ -1920,6 +1925,7 @@ depends_on = ["first"] envelope: &fixture.envelope, repository: &fixture.repository, cache: &fixture.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: Some(&snapshot), }, @@ -1953,6 +1959,7 @@ depends_on = ["first"] envelope: &fixture.envelope, repository: &fixture.repository, cache: &fixture.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: None, }, @@ -2123,6 +2130,7 @@ depends_on = ["first"] envelope: &fixture.envelope, repository: &fixture.repository, cache: &fixture.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: None, }, @@ -2173,6 +2181,7 @@ depends_on = ["first"] envelope: &fixture.envelope, repository: &fixture.repository, cache: &fixture.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: None, }, diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 3b88cd2..59c19ea 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -306,6 +306,40 @@ fn legacy_profile_reproduces_historical_plan() { )); } +#[test] +fn legacy_receipt_provenance_is_uniform() { + let legacy_version = MatrixPlanProfile::LegacyV1.producer_version(); + assert_eq!(legacy_version, "0.1.0+matrix-v2-legacy-v1"); + + let mut legacy = receipt().receipt; + legacy.producer.version = legacy_version.to_owned(); + for runtime in &mut legacy.runtime_receipts { + runtime.receipt.receipt.producer.version = legacy_version.to_owned(); + let inner = runtime.receipt.receipt.clone(); + runtime.receipt = ReceiptEnvelopeV1::seal(inner).expect("reseal legacy inner receipt"); + } + let legacy = MatrixReceiptEnvelopeV2::seal(legacy).expect("seal legacy matrix receipt"); + + assert_eq!(legacy.receipt.producer.version, legacy_version); + assert!( + legacy.receipt.runtime_receipts.iter().all(|runtime| runtime + .receipt + .receipt + .producer + .version + == legacy_version) + ); + + let mut mixed = legacy.receipt; + mixed.runtime_receipts[1].receipt.receipt.producer.version = "0.1.0".to_owned(); + let inner = mixed.runtime_receipts[1].receipt.receipt.clone(); + mixed.runtime_receipts[1].receipt = ReceiptEnvelopeV1::seal(inner).expect("reseal mixed inner"); + assert!(matches!( + MatrixReceiptEnvelopeV2::seal(mixed), + Err(MatrixError::InvalidReceipt) + )); +} + #[test] fn legacy_profile_is_canonical_across_runtime_and_check_declaration_order() { let first = MatrixConfigV2::parse(legacy_compatible_config()).expect("parse first"); diff --git a/tests/receipt_contract.rs b/tests/receipt_contract.rs index d75f747..341ee11 100644 --- a/tests/receipt_contract.rs +++ b/tests/receipt_contract.rs @@ -33,6 +33,16 @@ fn pinned_pass_fixture_round_trips_byte_for_byte() { ); } +#[test] +fn explicit_producer_version_is_sealed() { + let envelope: ReceiptEnvelopeV1 = + serde_json::from_slice(PASS_FIXTURE).expect("pinned fixture parses"); + + assert_eq!(envelope.receipt.producer.name, "commit-ci-preflight"); + assert_eq!(envelope.receipt.producer.version, "0.1.0"); + envelope.verify().expect("producer version remains sealed"); +} + #[test] fn generated_schema_matches_pinned_contract_byte_for_byte() { assert_eq!( From 4a3b1509bc236733d380650a5e1b3ae552fdd32e Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 02:11:11 +0200 Subject: [PATCH 11/33] test: cover Matrix receipt provenance execution paths --- src/matrix.rs | 297 ++++++++++++++++++++++++++++++++++++++++++++++---- src/run.rs | 31 ++++++ 2 files changed, 304 insertions(+), 24 deletions(-) diff --git a/src/matrix.rs b/src/matrix.rs index 63f0d72..6f3c40a 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -479,10 +479,6 @@ pub fn execute_matrix_run_v2( barrier: &mut dyn CompletionBarrier, ) -> Result { request.envelope.validate_profile_binding()?; - let expected_producer = ProducerEvidence { - name: env!("CARGO_PKG_NAME").to_owned(), - version: request.envelope.profile().producer_version().to_owned(), - }; let started_at_utc = clock.now_utc().map_err(MatrixError::Run)?; let mut runtime_receipts = Vec::new(); let mut checks = Vec::new(); @@ -516,11 +512,6 @@ pub fn execute_matrix_run_v2( &mut lifecycle, ) .map_err(MatrixError::Run)?; - if receipt.receipt.producer != expected_producer - || receipt.receipt.configuration_digest != expected_configuration_digest - { - return Err(MatrixError::InvalidReceipt); - } checks.extend(receipt.receipt.checks.iter().cloned()); runtime_receipts.push(MatrixRuntimeReceiptV2 { runtime_id, @@ -533,20 +524,6 @@ pub fn execute_matrix_run_v2( .ok_or(MatrixError::InvalidReceipt)?; request.envelope.validate_profile_binding()?; let configuration_digest = request.envelope.plan_digest()?.to_owned(); - if first.receipt.receipt.producer != expected_producer { - return Err(MatrixError::InvalidReceipt); - } - for runtime in &runtime_receipts { - let expected_configuration_digest = request - .envelope - .runtime_configuration_digest(&runtime.runtime_id)?; - if runtime.receipt.receipt.producer != first.receipt.receipt.producer - || runtime.receipt.receipt.producer != expected_producer - || runtime.receipt.receipt.configuration_digest != expected_configuration_digest - { - return Err(MatrixError::InvalidReceipt); - } - } let repository_evidence = first.receipt.receipt.repository.clone(); let redaction_policy_version = first.receipt.receipt.redaction_policy_version.clone(); let statuses: Vec<_> = checks @@ -565,9 +542,10 @@ pub fn execute_matrix_run_v2( started_at_utc: &started_at_utc, }) .map_err(MatrixError::Receipt)?; + let producer = validate_matrix_receipts_for_seal(request.envelope, &runtime_receipts)?; let receipt = MatrixReceiptEnvelopeV2::seal(MatrixReceiptV2 { schema_version: MATRIX_RECEIPT_SCHEMA_VERSION.to_owned(), - producer: expected_producer, + producer, repository: repository_evidence, run: RunEvidence { run_id, @@ -595,6 +573,34 @@ pub fn execute_matrix_run_v2( }) } +fn validate_matrix_receipts_for_seal( + envelope: &MatrixPlanEnvelopeV2, + runtime_receipts: &[MatrixRuntimeReceiptV2], +) -> Result { + envelope.validate_profile_binding()?; + let expected_producer = ProducerEvidence { + name: env!("CARGO_PKG_NAME").to_owned(), + version: envelope.profile().producer_version().to_owned(), + }; + let first = runtime_receipts + .first() + .ok_or(MatrixError::InvalidReceipt)?; + if first.receipt.receipt.producer != expected_producer { + return Err(MatrixError::InvalidReceipt); + } + for runtime in runtime_receipts { + let expected_configuration_digest = + envelope.runtime_configuration_digest(&runtime.runtime_id)?; + if runtime.receipt.receipt.producer != first.receipt.receipt.producer + || runtime.receipt.receipt.producer != expected_producer + || runtime.receipt.receipt.configuration_digest != expected_configuration_digest + { + return Err(MatrixError::InvalidReceipt); + } + } + Ok(expected_producer) +} + fn derive_status(required: &[EvidenceStatus]) -> EvidenceStatus { if required.contains(&EvidenceStatus::Fail) { EvidenceStatus::Fail @@ -1065,6 +1071,249 @@ fn equal( } } +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::cache::{CacheRootOptions, PlatformFamily, ResolvedCacheRoot}; + use crate::process::{GenerationGuard, ProcessError, ProcessRequest, ProcessResult}; + use crate::receipt::{CheckEvidence, PlatformEvidence}; + use crate::run::SystemClock; + + const IMAGE_311: &str = "example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const IMAGE_312: &str = "example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const OUTPUT_DIGEST: &str = + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + fn envelope(profile: MatrixPlanProfile) -> MatrixPlanEnvelopeV2 { + build_matrix_plan( + MatrixConfigV2::parse(&format!( + r#" +schema_version = "2.0" +project = "owner/repository" + +[receipt] +output = ".ccp/receipt.json" + +[[checks]] +id = "python311-check" +runtime_id = "python311" +required = true +argv = ["python", "-V"] +working_directory = "." +timeout_seconds = 60 + +[[checks]] +id = "python312-check" +runtime_id = "python312" +required = true +argv = ["python", "-V"] +working_directory = "." +timeout_seconds = 60 + +[[runtimes]] +id = "python311" +kind = "docker_compatible" +image = "{IMAGE_311}" +cpu_count = 1 +memory_mib = 128 +pids_limit = 16 + +[[runtimes]] +id = "python312" +kind = "docker_compatible" +image = "{IMAGE_312}" +cpu_count = 1 +memory_mib = 128 +pids_limit = 16 +"# + )) + .expect("matrix config"), + profile, + ) + .expect("matrix plan") + } + + fn synthetic_runtime_receipts(envelope: &MatrixPlanEnvelopeV2) -> Vec { + let producer = ProducerEvidence { + name: env!("CARGO_PKG_NAME").to_owned(), + version: envelope.profile().producer_version().to_owned(), + }; + envelope + .plan + .runtimes + .iter() + .map(|runtime| { + let image_digest = runtime + .runtime + .image + .rsplit_once('@') + .expect("pinned image") + .1 + .to_owned(); + let receipt = ReceiptEnvelopeV1::seal(ReceiptV1 { + schema_version: crate::receipt::RECEIPT_SCHEMA_VERSION.to_owned(), + producer: producer.clone(), + repository: RepositoryEvidence { + repository: envelope.plan.project.clone(), + commit_sha: "a".repeat(40), + dirty: false, + }, + run: RunEvidence { + run_id: format!("run-{}", runtime.id), + generation: 7, + started_at_utc: "2026-08-25T01:00:00Z".to_owned(), + finished_at_utc: "2026-08-25T01:00:01Z".to_owned(), + }, + platform: PlatformEvidence { + host_os: "macos".to_owned(), + host_arch: "aarch64".to_owned(), + runtime_kind: "docker_compatible".to_owned(), + runtime_version: "test".to_owned(), + image_reference: runtime.runtime.image.clone(), + image_digest, + }, + configuration_digest: envelope + .runtime_configuration_digest(&runtime.id) + .expect("runtime digest") + .to_owned(), + checks: vec![CheckEvidence { + id: format!("{}-check", runtime.id), + required: true, + argv: vec!["python".to_owned(), "-V".to_owned()], + working_directory: ".".to_owned(), + status: EvidenceStatus::Pass, + exit_code: Some(0), + duration_ms: 1, + timed_out: false, + cancelled: false, + output_digest: Some(OUTPUT_DIGEST.to_owned()), + incomplete_reason: None, + }], + overall_status: EvidenceStatus::Pass, + incomplete_reason: None, + redaction_policy_version: "ccp-redaction-v1".to_owned(), + }) + .expect("seal inner receipt"); + MatrixRuntimeReceiptV2 { + runtime_id: runtime.id.clone(), + receipt, + } + }) + .collect() + } + + fn reseal(runtime: &mut MatrixRuntimeReceiptV2) { + let inner = runtime.receipt.receipt.clone(); + runtime.receipt = ReceiptEnvelopeV1::seal(inner).expect("reseal inner receipt"); + } + + #[test] + fn preseal_receipt_validation_accepts_current_and_legacy_profile_provenance() { + for profile in [MatrixPlanProfile::CurrentV2, MatrixPlanProfile::LegacyV1] { + let envelope = envelope(profile); + let receipts = synthetic_runtime_receipts(&envelope); + + let producer = validate_matrix_receipts_for_seal(&envelope, &receipts) + .expect("profile-bound inner receipts"); + assert_eq!(producer.version, profile.producer_version()); + } + } + + #[test] + fn preseal_receipt_validation_rejects_mutated_producer_and_runtime_digest() { + let envelope = envelope(MatrixPlanProfile::LegacyV1); + + let mut mixed_producer = synthetic_runtime_receipts(&envelope); + mixed_producer[1].receipt.receipt.producer.version = env!("CARGO_PKG_VERSION").to_owned(); + reseal(&mut mixed_producer[1]); + assert!(matches!( + validate_matrix_receipts_for_seal(&envelope, &mixed_producer), + Err(MatrixError::InvalidReceipt) + )); + + let mut changed_digest = synthetic_runtime_receipts(&envelope); + changed_digest[1].receipt.receipt.configuration_digest = OUTPUT_DIGEST.to_owned(); + reseal(&mut changed_digest[1]); + assert!(matches!( + validate_matrix_receipts_for_seal(&envelope, &changed_digest), + Err(MatrixError::InvalidReceipt) + )); + } + + #[derive(Default)] + struct CountingSupervisor { + calls: AtomicUsize, + } + + impl SupervisorPort for CountingSupervisor { + fn execute( + &self, + _request: &ProcessRequest, + _cancellation: &CancellationToken, + _generation: &GenerationGuard, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + panic!("tampered Matrix plan must fail before supervisor execution") + } + } + + #[test] + fn executor_rejects_tampered_legacy_plan_before_supervisor_execution() { + let root = std::env::current_dir() + .expect("current directory") + .parent() + .expect("repository parent") + .join(format!( + ".ccp-matrix-provenance-test-{}", + std::process::id() + )); + if root.exists() { + fs::remove_dir_all(&root).expect("clean fixture root"); + } + let repository = root.join("repository"); + fs::create_dir_all(&repository).expect("repository root"); + let cache = ManagedCache::initialize( + ResolvedCacheRoot::resolve( + &repository, + &CacheRootOptions { + explicit: Some(root.join("cache")), + environment: None, + home: None, + xdg_cache_home: None, + local_app_data: None, + platform: PlatformFamily::Unix, + }, + ) + .expect("cache root"), + ) + .expect("cache"); + let mut envelope = envelope(MatrixPlanProfile::LegacyV1); + envelope.plan.project = "owner/tampered".to_owned(); + let supervisor = CountingSupervisor::default(); + let mut barrier = NoopCompletionBarrier; + + let result = execute_matrix_run_v2( + &MatrixRunRequestV2 { + envelope: &envelope, + repository: &repository, + cache: &cache, + generation: 7, + }, + &supervisor, + &CancellationToken::default(), + &SystemClock, + &mut barrier, + ); + + assert!(matches!(result, Err(MatrixError::PlanDigestMismatch))); + assert_eq!(supervisor.calls.load(Ordering::SeqCst), 0); + drop(cache); + fs::remove_dir_all(root).expect("remove fixture root"); + } +} + #[derive(Debug)] pub enum MatrixError { Io(std::io::Error), diff --git a/src/run.rs b/src/run.rs index 0adc1a8..1e86837 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1733,6 +1733,37 @@ depends_on = ["first"] ); } + #[test] + fn local_receipt_seals_the_producer_version_from_its_request() { + for (label, producer_version) in [ + ("package-producer", env!("CARGO_PKG_VERSION")), + ("compatibility-producer", "0.1.0+matrix-v2-legacy-v1"), + ] { + let fixture = RunFixture::new(label); + let outcome = execute_local_run( + &RunRequest { + envelope: &fixture.envelope, + repository: &fixture.repository, + cache: &fixture.cache, + producer_version, + generation: 7, + source_snapshot: None, + }, + &DockerCompatibleRuntime, + &FakeSupervisor::new(ExecutionMode::Pass), + &CancellationToken::default(), + &FixedClock::new(), + ) + .expect("run"); + + assert_eq!(outcome.receipt.receipt.producer.version, producer_version); + let decoded: ReceiptEnvelopeV1 = + serde_json::from_slice(&fs::read(&outcome.receipt_path).expect("receipt bytes")) + .expect("receipt JSON"); + assert_eq!(decoded.receipt.producer.version, producer_version); + } + } + #[test] fn snapshot_run_publishes_v2_receipt_bound_to_manifest() { let fixture = RunFixture::new("snapshot-v2"); From 730a1bd5f7232aaa73ed3b7ab1e8819834cb30fa Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 02:41:42 +0200 Subject: [PATCH 12/33] test: prove historical Matrix verifier compatibility --- .../fixtures/policy-v2-legacy-compatible.toml | 32 ++ tests/matrix_contract.rs | 19 + tests/verification_contract.rs | 471 +++++++++++++++++- tests/verify_cli.rs | 25 + 4 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/policy-v2-legacy-compatible.toml diff --git a/tests/fixtures/policy-v2-legacy-compatible.toml b/tests/fixtures/policy-v2-legacy-compatible.toml new file mode 100644 index 0000000..f0b30bf --- /dev/null +++ b/tests/fixtures/policy-v2-legacy-compatible.toml @@ -0,0 +1,32 @@ +schema_version = "2.0" +project = "example/legacy-matrix" +configuration_digest = "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13" +max_age_seconds = 300 + +[[required_checks]] +id = "python311-version" +runtime_id = "python311" + +[[required_checks]] +id = "python312-version" +runtime_id = "python312" + +[[runtimes]] +id = "python311" +configuration_digest = "sha256:755f77f6815b1ed7b4415b3312c48a6528e2d752270775916efa6c10f1ffe192" +image_reference = "example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[runtimes.platforms]] +host_os = "macos" +host_arch = "aarch64" +runtime_kind = "docker_compatible" + +[[runtimes]] +id = "python312" +configuration_digest = "sha256:be2eb7d200946e9f1dc84cebd8c0cca8739e424163f91c86063bbe4caed936f9" +image_reference = "example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[runtimes.platforms]] +host_os = "macos" +host_arch = "aarch64" +runtime_kind = "docker_compatible" diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 59c19ea..7447957 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -31,6 +31,7 @@ const DIGEST: &str = "sha256:ccccccccccccccccccccccccccccccccccccccccccccccccccc const CONFIG_SCHEMA: &str = include_str!("../schema/config-v2.schema.json"); const RECEIPT_SCHEMA: &str = include_str!("../schema/receipt-v2.schema.json"); const POLICY_SCHEMA: &str = include_str!("../schema/policy-v2.schema.json"); +const LEGACY_COMPATIBLE_POLICY: &str = include_str!("fixtures/policy-v2-legacy-compatible.toml"); fn runtime_receipt(id: &str, image: &str, check_id: &str) -> ReceiptEnvelopeV1 { let digest = image.rsplit_once('@').expect("pinned image").1.to_owned(); @@ -306,6 +307,24 @@ fn legacy_profile_reproduces_historical_plan() { )); } +#[test] +fn legacy_compatible_policy_fixture_binds_historical_profile_digests() { + let provenance: Value = serde_json::from_str(include_str!( + "fixtures/matrix-v2-legacy-plan-044697.provenance.json" + )) + .expect("provenance JSON"); + let policy = MatrixVerificationPolicyV2::parse(LEGACY_COMPATIBLE_POLICY).expect("policy"); + + assert_eq!(policy.project, "example/legacy-matrix"); + assert_eq!(policy.configuration_digest, provenance["outer_digest"]); + for runtime in policy.runtimes { + assert_eq!( + runtime.configuration_digest, + provenance["runtime_digests"][&runtime.id] + ); + } +} + #[test] fn legacy_receipt_provenance_is_uniform() { let legacy_version = MatrixPlanProfile::LegacyV1.producer_version(); diff --git a/tests/verification_contract.rs b/tests/verification_contract.rs index 15cc74e..6dcd0ca 100644 --- a/tests/verification_contract.rs +++ b/tests/verification_contract.rs @@ -12,9 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::path::Path; - -use commit_ci_preflight::receipt::{EvidenceStatus, ReceiptEnvelopeV1, ReceiptEnvelopeV2}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use commit_ci_preflight::matrix::{ + MatrixReceiptEnvelopeV2, MatrixReceiptV2, MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, + verify_matrix_receipt_document, +}; +use commit_ci_preflight::receipt::{ + CheckEvidence, EvidenceStatus, PlatformEvidence, ProducerEvidence, ReceiptEnvelopeV1, + ReceiptEnvelopeV2, ReceiptV1, RepositoryEvidence, RunEvidence, +}; use commit_ci_preflight::verify::{ PolicyError, VerificationDecision, VerificationError, VerificationPolicyDocument, VerificationPolicyV1, VerificationPolicyV1_1, VerificationStatus, @@ -36,6 +47,21 @@ const VERIFY_SOURCE: &str = include_str!("../src/verify.rs"); const TRUSTED_PLAN_POLICY: &str = "tests/fixtures/policy-v1_1-trusted-plan.toml"; const ALTERED_TRUSTED_PLAN_POLICY: &str = "tests/fixtures/policy-v1_1-trusted-plan-altered.toml"; const ROOT_POLICY: &str = ".commit-ci-policy.toml"; +const LEGACY_MATRIX_POLICY: &str = include_str!("fixtures/policy-v2-legacy-compatible.toml"); +const LEGACY_MATRIX_PROVENANCE: &str = + include_str!("fixtures/matrix-v2-legacy-plan-044697.provenance.json"); +const LEGACY_MATRIX_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const LEGACY_MATRIX_EVALUATED_AT: &str = "2026-08-16T10:01:00Z"; +const LEGACY_MATRIX_PRODUCER: &str = "0.1.0+matrix-v2-legacy-v1"; +const LEGACY_MATRIX_OUTER_DIGEST: &str = + "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13"; +const LEGACY_MATRIX_PY311_DIGEST: &str = + "sha256:755f77f6815b1ed7b4415b3312c48a6528e2d752270775916efa6c10f1ffe192"; +const LEGACY_MATRIX_PY312_DIGEST: &str = + "sha256:be2eb7d200946e9f1dc84cebd8c0cca8739e424163f91c86063bbe4caed936f9"; +const LEGACY_MATRIX_PY311_IMAGE: &str = "example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const LEGACY_MATRIX_PY312_IMAGE: &str = "example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); fn policy() -> VerificationPolicyV1 { VerificationPolicyV1::parse(POLICY).expect("policy") @@ -79,6 +105,202 @@ fn collect_leaf_pointers(value: &serde_json::Value, path: &str, pointers: &mut V } } +fn legacy_matrix_policy() -> MatrixVerificationPolicyV2 { + MatrixVerificationPolicyV2::parse(LEGACY_MATRIX_POLICY).expect("legacy Matrix policy") +} + +fn legacy_runtime_receipt( + runtime_id: &str, + image_reference: &str, + configuration_digest: &str, + check_id: &str, +) -> ReceiptEnvelopeV1 { + let image_digest = image_reference + .rsplit_once('@') + .expect("pinned image") + .1 + .to_owned(); + ReceiptEnvelopeV1::seal(ReceiptV1 { + schema_version: "1.0".to_owned(), + producer: ProducerEvidence { + name: "commit-ci-preflight".to_owned(), + version: LEGACY_MATRIX_PRODUCER.to_owned(), + }, + repository: RepositoryEvidence { + repository: "example/legacy-matrix".to_owned(), + commit_sha: LEGACY_MATRIX_COMMIT.to_owned(), + dirty: false, + }, + run: RunEvidence { + run_id: format!("legacy-{runtime_id}"), + generation: 1, + started_at_utc: "2026-08-16T10:00:00Z".to_owned(), + finished_at_utc: "2026-08-16T10:00:01Z".to_owned(), + }, + platform: PlatformEvidence { + host_os: "macos".to_owned(), + host_arch: "aarch64".to_owned(), + runtime_kind: "docker_compatible".to_owned(), + runtime_version: "test".to_owned(), + image_reference: image_reference.to_owned(), + image_digest, + }, + configuration_digest: configuration_digest.to_owned(), + checks: vec![CheckEvidence { + id: check_id.to_owned(), + required: true, + argv: vec!["python".to_owned(), "-V".to_owned()], + working_directory: ".".to_owned(), + status: EvidenceStatus::Pass, + exit_code: Some(0), + duration_ms: 1, + timed_out: false, + cancelled: false, + output_digest: Some(configuration_digest.to_owned()), + incomplete_reason: None, + }], + overall_status: EvidenceStatus::Pass, + incomplete_reason: None, + redaction_policy_version: "1.0".to_owned(), + }) + .expect("seal legacy runtime receipt") +} + +fn legacy_matrix_receipt() -> MatrixReceiptEnvelopeV2 { + MatrixReceiptEnvelopeV2::seal(MatrixReceiptV2 { + schema_version: "2.0".to_owned(), + producer: ProducerEvidence { + name: "commit-ci-preflight".to_owned(), + version: LEGACY_MATRIX_PRODUCER.to_owned(), + }, + repository: RepositoryEvidence { + repository: "example/legacy-matrix".to_owned(), + commit_sha: LEGACY_MATRIX_COMMIT.to_owned(), + dirty: false, + }, + run: RunEvidence { + run_id: "legacy-matrix".to_owned(), + generation: 1, + started_at_utc: "2026-08-16T10:00:00Z".to_owned(), + finished_at_utc: "2026-08-16T10:00:02Z".to_owned(), + }, + configuration_digest: LEGACY_MATRIX_OUTER_DIGEST.to_owned(), + runtime_receipts: vec![ + MatrixRuntimeReceiptV2 { + runtime_id: "python311".to_owned(), + receipt: legacy_runtime_receipt( + "python311", + LEGACY_MATRIX_PY311_IMAGE, + LEGACY_MATRIX_PY311_DIGEST, + "python311-version", + ), + }, + MatrixRuntimeReceiptV2 { + runtime_id: "python312".to_owned(), + receipt: legacy_runtime_receipt( + "python312", + LEGACY_MATRIX_PY312_IMAGE, + LEGACY_MATRIX_PY312_DIGEST, + "python312-version", + ), + }, + ], + overall_status: EvidenceStatus::Pass, + incomplete_reason: None, + redaction_policy_version: "1.0".to_owned(), + }) + .expect("seal legacy Matrix receipt") +} + +fn matrix_report( + bytes: &[u8], + policy: &MatrixVerificationPolicyV2, + commit: &str, +) -> commit_ci_preflight::verify::VerificationReportV1 { + verify_matrix_receipt_document(bytes, policy, commit, LEGACY_MATRIX_EVALUATED_AT) + .expect("verify Matrix receipt") +} + +fn assert_matrix_failure( + report: &commit_ci_preflight::verify::VerificationReportV1, + finding_code: &str, +) { + assert_eq!(report.decision, VerificationDecision::Fail); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == finding_code), + "missing {finding_code}: {:?}", + report.findings + ); +} + +fn historical_verifier() -> PathBuf { + let path = env::var_os("CCP_HISTORICAL_VERIFIER_044697") + .map(PathBuf::from) + .expect("set CCP_HISTORICAL_VERIFIER_044697 to the reviewed 044697 verifier binary"); + let expected = serde_json::from_str::(LEGACY_MATRIX_PROVENANCE) + .expect("provenance JSON")["binary_sha256"] + .as_str() + .expect("provenance binary SHA-256") + .to_owned(); + let actual = { + use sha2::{Digest, Sha256}; + Sha256::digest(fs::read(&path).expect("read historical verifier")) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + }; + assert_eq!( + actual, expected, + "refusing to invoke CCP_HISTORICAL_VERIFIER_044697 because it is not the provenance-pinned historical verifier" + ); + path +} + +fn with_historical_fixture(operation: impl FnOnce(&Path) -> T) -> T { + let mut directory = env::temp_dir(); + directory.push(format!( + "commit-ci-preflight-historical-044697-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&directory).expect("create historical fixture directory"); + let result = operation(&directory); + fs::remove_dir_all(&directory).expect("remove historical fixture directory"); + result +} + +fn write_matrix_document(directory: &Path, name: &str, bytes: &[u8]) -> PathBuf { + let path = directory.join(name); + fs::write(&path, bytes).expect("write matrix test document"); + path +} + +fn run_historical_verifier( + verifier: &Path, + receipt: &Path, + policy: &Path, + expected_commit: &str, +) -> std::process::Output { + Command::new(verifier) + .args([ + "verify", + "--receipt", + receipt.to_str().expect("UTF-8 receipt path"), + "--policy", + policy.to_str().expect("UTF-8 policy path"), + "--expected-commit", + expected_commit, + "--evaluated-at-utc", + LEGACY_MATRIX_EVALUATED_AT, + "--json", + ]) + .output() + .expect("invoke provenance-pinned historical verifier") +} + fn mutate_leaf(value: &mut serde_json::Value) { match value { serde_json::Value::Null => *value = serde_json::Value::Bool(true), @@ -238,6 +460,249 @@ fn trusted_plan_policy_parser_rejects_unsafe_config_path_and_overlapping_produce )); } +#[test] +fn current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations() { + let policy = legacy_matrix_policy(); + let original = legacy_matrix_receipt(); + let original_bytes = original.canonical_bytes().expect("legacy receipt bytes"); + let valid = matrix_report(&original_bytes, &policy, LEGACY_MATRIX_COMMIT); + assert_eq!(valid.decision, VerificationDecision::Pass); + assert!(valid.findings.is_empty()); + + let mut producer = serde_json::to_value(&original).expect("legacy receipt JSON"); + producer["receipt"]["producer"]["version"] = serde_json::Value::String("0.1.0".to_owned()); + let producer = matrix_report( + &serde_json::to_vec(&producer).expect("producer mutation"), + &policy, + LEGACY_MATRIX_COMMIT, + ); + assert_matrix_failure(&producer, "receipt.semantic_or_digest_invalid"); + + let wrong_commit = matrix_report(&original_bytes, &policy, &"b".repeat(40)); + assert_matrix_failure(&wrong_commit, "policy.commit"); + + let mut outer = original.clone().receipt; + outer.configuration_digest = format!("sha256:{}", "d".repeat(64)); + let outer = MatrixReceiptEnvelopeV2::seal(outer).expect("reseal outer digest mutation"); + let outer = matrix_report( + &outer.canonical_bytes().expect("outer mutation bytes"), + &policy, + LEGACY_MATRIX_COMMIT, + ); + assert_matrix_failure(&outer, "policy.configuration"); + + let mut runtime = original.clone().receipt; + runtime.runtime_receipts[0] + .receipt + .receipt + .configuration_digest = format!("sha256:{}", "d".repeat(64)); + let inner = runtime.runtime_receipts[0].receipt.receipt.clone(); + runtime.runtime_receipts[0].receipt = ReceiptEnvelopeV1::seal(inner).expect("reseal runtime"); + let runtime = MatrixReceiptEnvelopeV2::seal(runtime).expect("reseal runtime mutation"); + let runtime = matrix_report( + &runtime.canonical_bytes().expect("runtime mutation bytes"), + &policy, + LEGACY_MATRIX_COMMIT, + ); + assert_matrix_failure(&runtime, "policy.runtime_configuration"); + + let mut check_binding = policy.clone(); + check_binding.required_checks[0].runtime_id = "python312".to_owned(); + check_binding.required_checks[1].runtime_id = "python311".to_owned(); + let check_binding = matrix_report(&original_bytes, &check_binding, LEGACY_MATRIX_COMMIT); + assert_matrix_failure(&check_binding, "policy.check_runtime"); + + let mut runtime_binding = original.clone().receipt; + runtime_binding.runtime_receipts[0].runtime_id = "python312".to_owned(); + runtime_binding.runtime_receipts[1].runtime_id = "python311".to_owned(); + let runtime_binding = + MatrixReceiptEnvelopeV2::seal(runtime_binding).expect("reseal runtime binding mutation"); + let runtime_binding = matrix_report( + &runtime_binding + .canonical_bytes() + .expect("runtime binding mutation bytes"), + &policy, + LEGACY_MATRIX_COMMIT, + ); + assert_matrix_failure(&runtime_binding, "policy.runtime_image"); + + let mut altered_byte = original_bytes; + let index = altered_byte + .windows(b"legacy-matrix".len()) + .position(|window| window == b"legacy-matrix") + .expect("legacy project in canonical receipt"); + altered_byte[index] = b'x'; + let altered_byte = matrix_report(&altered_byte, &policy, LEGACY_MATRIX_COMMIT); + assert_matrix_failure(&altered_byte, "receipt.semantic_or_digest_invalid"); +} + +#[test] +#[ignore = "set CCP_HISTORICAL_VERIFIER_044697 to the provenance-pinned 044697 binary, then run with --ignored"] +fn historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations() { + let verifier = historical_verifier(); + with_historical_fixture(|directory| { + let original = legacy_matrix_receipt(); + let policy = + write_matrix_document(directory, "policy.toml", LEGACY_MATRIX_POLICY.as_bytes()); + let receipt = write_matrix_document( + directory, + "receipt.json", + &original.canonical_bytes().expect("legacy receipt bytes"), + ); + let valid = run_historical_verifier(&verifier, &receipt, &policy, LEGACY_MATRIX_COMMIT); + assert!( + valid.status.success(), + "historical verifier stderr: {}", + String::from_utf8_lossy(&valid.stderr) + ); + let valid_report: serde_json::Value = + serde_json::from_slice(&valid.stdout).expect("historical valid report"); + assert_eq!(valid_report["decision"], "PASS"); + + let mut producer = serde_json::to_value(&original).expect("legacy receipt JSON"); + producer["receipt"]["producer"]["version"] = serde_json::Value::String("0.1.0".to_owned()); + let producer = write_matrix_document( + directory, + "producer.json", + &serde_json::to_vec(&producer).expect("producer mutation"), + ); + let producer = run_historical_verifier(&verifier, &producer, &policy, LEGACY_MATRIX_COMMIT); + assert_eq!(producer.status.code(), Some(3)); + let producer: serde_json::Value = + serde_json::from_slice(&producer.stdout).expect("producer report"); + assert_eq!( + producer["findings"][0]["code"], + "receipt.semantic_or_digest_invalid" + ); + + let wrong_commit = run_historical_verifier(&verifier, &receipt, &policy, &"b".repeat(40)); + assert_eq!(wrong_commit.status.code(), Some(3)); + let wrong_commit: serde_json::Value = + serde_json::from_slice(&wrong_commit.stdout).expect("commit report"); + assert!( + wrong_commit["findings"] + .as_array() + .expect("commit findings") + .iter() + .any(|finding| finding["code"] == "policy.commit") + ); + + let mut outer = original.clone().receipt; + outer.configuration_digest = format!("sha256:{}", "d".repeat(64)); + let outer = MatrixReceiptEnvelopeV2::seal(outer).expect("reseal outer digest mutation"); + let outer = write_matrix_document( + directory, + "outer.json", + &outer.canonical_bytes().expect("outer mutation bytes"), + ); + let outer = run_historical_verifier(&verifier, &outer, &policy, LEGACY_MATRIX_COMMIT); + assert_eq!(outer.status.code(), Some(3)); + let outer: serde_json::Value = serde_json::from_slice(&outer.stdout).expect("outer report"); + assert!( + outer["findings"] + .as_array() + .expect("outer findings") + .iter() + .any(|finding| finding["code"] == "policy.configuration") + ); + + let mut runtime = original.clone().receipt; + runtime.runtime_receipts[0] + .receipt + .receipt + .configuration_digest = format!("sha256:{}", "d".repeat(64)); + let inner = runtime.runtime_receipts[0].receipt.receipt.clone(); + runtime.runtime_receipts[0].receipt = + ReceiptEnvelopeV1::seal(inner).expect("reseal runtime"); + let runtime = MatrixReceiptEnvelopeV2::seal(runtime).expect("reseal runtime mutation"); + let runtime = write_matrix_document( + directory, + "runtime.json", + &runtime.canonical_bytes().expect("runtime mutation bytes"), + ); + let runtime = run_historical_verifier(&verifier, &runtime, &policy, LEGACY_MATRIX_COMMIT); + assert_eq!(runtime.status.code(), Some(3)); + let runtime: serde_json::Value = + serde_json::from_slice(&runtime.stdout).expect("runtime report"); + assert!( + runtime["findings"] + .as_array() + .expect("runtime findings") + .iter() + .any(|finding| finding["code"] == "policy.runtime_configuration") + ); + + let policy_with_wrong_check_binding = LEGACY_MATRIX_POLICY + .replace( + "id = \"python311-version\"\nruntime_id = \"python311\"", + "id = \"python311-version\"\nruntime_id = \"python312\"", + ) + .replace( + "id = \"python312-version\"\nruntime_id = \"python312\"", + "id = \"python312-version\"\nruntime_id = \"python311\"", + ); + let check_binding = write_matrix_document( + directory, + "check-binding.toml", + policy_with_wrong_check_binding.as_bytes(), + ); + let check_binding = + run_historical_verifier(&verifier, &receipt, &check_binding, LEGACY_MATRIX_COMMIT); + assert_eq!(check_binding.status.code(), Some(3)); + let check_binding: serde_json::Value = + serde_json::from_slice(&check_binding.stdout).expect("check binding report"); + assert!( + check_binding["findings"] + .as_array() + .expect("check binding findings") + .iter() + .any(|finding| finding["code"] == "policy.check_runtime") + ); + + let mut runtime_binding = original.clone().receipt; + runtime_binding.runtime_receipts[0].runtime_id = "python312".to_owned(); + runtime_binding.runtime_receipts[1].runtime_id = "python311".to_owned(); + let runtime_binding = MatrixReceiptEnvelopeV2::seal(runtime_binding) + .expect("reseal runtime binding mutation"); + let runtime_binding = write_matrix_document( + directory, + "runtime-binding.json", + &runtime_binding + .canonical_bytes() + .expect("runtime binding mutation bytes"), + ); + let runtime_binding = + run_historical_verifier(&verifier, &runtime_binding, &policy, LEGACY_MATRIX_COMMIT); + assert_eq!(runtime_binding.status.code(), Some(3)); + let runtime_binding: serde_json::Value = + serde_json::from_slice(&runtime_binding.stdout).expect("runtime binding report"); + assert!( + runtime_binding["findings"] + .as_array() + .expect("runtime binding findings") + .iter() + .any(|finding| finding["code"] == "policy.runtime_image") + ); + + let mut altered_byte = original.canonical_bytes().expect("legacy receipt bytes"); + let index = altered_byte + .windows(b"legacy-matrix".len()) + .position(|window| window == b"legacy-matrix") + .expect("legacy project in canonical receipt"); + altered_byte[index] = b'x'; + let altered_byte = write_matrix_document(directory, "byte.json", &altered_byte); + let altered_byte = + run_historical_verifier(&verifier, &altered_byte, &policy, LEGACY_MATRIX_COMMIT); + assert_eq!(altered_byte.status.code(), Some(3)); + let altered_byte: serde_json::Value = + serde_json::from_slice(&altered_byte.stdout).expect("byte report"); + assert_eq!( + altered_byte["findings"][0]["code"], + "receipt.semantic_or_digest_invalid" + ); + }); +} + #[test] fn editing_every_receipt_leaf_breaks_integrity() { let original: serde_json::Value = serde_json::from_slice(RECEIPT).expect("JSON"); diff --git a/tests/verify_cli.rs b/tests/verify_cli.rs index cc05df1..4940cd1 100644 --- a/tests/verify_cli.rs +++ b/tests/verify_cli.rs @@ -19,6 +19,7 @@ const POLICY: &str = "tests/fixtures/policy-v1.toml"; const RECEIPT_V2: &str = "tests/fixtures/receipt-v2-pass.json"; const TRUSTED_PLAN_POLICY: &str = "tests/fixtures/policy-v1_1-trusted-plan.toml"; const INVALID_TRUSTED_PLAN_POLICY: &str = "tests/fixtures/policy-v1_1-missing-config.toml"; +const LEGACY_MATRIX_POLICY: &str = "tests/fixtures/policy-v2-legacy-compatible.toml"; const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const EVALUATED_AT: &str = "2026-08-08T12:30:00Z"; @@ -156,3 +157,27 @@ fn missing_receipt_does_not_bypass_trusted_policy_configuration_validation() { assert!(output.stdout.is_empty()); assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read trusted configuration")); } + +#[test] +fn verify_cli_accepts_the_legacy_matrix_policy_before_receipt_evaluation() { + let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) + .args([ + "verify", + "--receipt", + "tests/fixtures/does-not-exist.json", + "--policy", + LEGACY_MATRIX_POLICY, + "--expected-commit", + COMMIT, + "--evaluated-at-utc", + EVALUATED_AT, + "--json", + ]) + .output() + .expect("legacy Matrix policy CLI"); + assert_eq!(output.status.code(), Some(3)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("report JSON"); + assert_eq!(report["integrity_status"], "FAIL"); + assert_eq!(report["policy_status"], "NOT_RUN"); + assert_eq!(report["findings"][0]["code"], "receipt.read_failed"); +} From 238eb36b906b1e7a1da3d5e4f392753f870341c4 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 03:06:03 +0200 Subject: [PATCH 13/33] test: retain qualified historical verifier evidence --- ...historical-verifier-044697.provenance.json | 19 +++++++ tests/matrix_contract.rs | 50 ++++++++++++++++++- tests/verification_contract.rs | 31 +++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/historical-verifier-044697.provenance.json diff --git a/tests/fixtures/historical-verifier-044697.provenance.json b/tests/fixtures/historical-verifier-044697.provenance.json new file mode 100644 index 0000000..c5b7638 --- /dev/null +++ b/tests/fixtures/historical-verifier-044697.provenance.json @@ -0,0 +1,19 @@ +{ + "commit": "044697dee9a0d678d30a4847d62ddf9b4970505b", + "tree": "5220164edf17831ce0c42dae1c14300ed1045015", + "build_argv": ["cargo", "build", "--locked", "--offline"], + "rustc_version": "rustc 1.96.0 (ac68faa20 2026-05-25)", + "cargo_version": "cargo 1.96.0 (30a34c682 2026-05-25)", + "retained_binary_path": "/private/tmp/commit-ci-preflight-044697-5321ff4d291ec24db6a7a5919bc08fc00a9d63767b630a3469fc39318c400277", + "binary_sha256": "5321ff4d291ec24db6a7a5919bc08fc00a9d63767b630a3469fc39318c400277", + "binary_sha256_status": "retained_historical_verifier", + "config_sha256": "cb8c3abf7231ff8b25cfab561ba0080f0cc70952f7e63f9ee2832ecbcf572a67", + "plan_command_argv": ["/private/tmp/commit-ci-preflight-044697-5321ff4d291ec24db6a7a5919bc08fc00a9d63767b630a3469fc39318c400277", "plan", "--config", "tests/fixtures/config-v2-legacy-compatible.toml", "--json"], + "output_sha256": "224cc7b31c1541d4270960980412058f23e4ed396d63c79a4454c3c1bd25653d", + "plan_digest": "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13", + "outer_digest": "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13", + "runtime_digests": { + "python311": "sha256:755f77f6815b1ed7b4415b3312c48a6528e2d752270775916efa6c10f1ffe192", + "python312": "sha256:be2eb7d200946e9f1dc84cebd8c0cca8739e424163f91c86063bbe4caed936f9" + } +} diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 7447957..085fdf6 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -313,15 +313,63 @@ fn legacy_compatible_policy_fixture_binds_historical_profile_digests() { "fixtures/matrix-v2-legacy-plan-044697.provenance.json" )) .expect("provenance JSON"); + let historical_plan: Value = + serde_json::from_str(include_str!("fixtures/matrix-v2-legacy-plan-044697.json")) + .expect("historical plan JSON"); let policy = MatrixVerificationPolicyV2::parse(LEGACY_COMPATIBLE_POLICY).expect("policy"); + let planned_runtimes = historical_plan["plan"]["runtimes"] + .as_array() + .expect("historical runtimes"); assert_eq!(policy.project, "example/legacy-matrix"); assert_eq!(policy.configuration_digest, provenance["outer_digest"]); - for runtime in policy.runtimes { + let policy_check_bindings: Vec<_> = policy + .required_checks + .iter() + .map(|check| (check.id.as_str(), check.runtime_id.as_str())) + .collect(); + let planned_check_bindings: Vec<_> = planned_runtimes + .iter() + .flat_map(|runtime| { + let runtime_id = runtime["id"].as_str().expect("historical runtime ID"); + runtime["checks"] + .as_array() + .expect("historical runtime checks") + .iter() + .filter(|check| check["required"] == true) + .map(move |check| { + ( + check["id"].as_str().expect("historical check ID"), + runtime_id, + ) + }) + }) + .collect(); + assert_eq!(policy_check_bindings, planned_check_bindings); + + for runtime in &policy.runtimes { assert_eq!( runtime.configuration_digest, provenance["runtime_digests"][&runtime.id] ); + let planned_runtime = planned_runtimes + .iter() + .find(|candidate| candidate["id"] == runtime.id) + .expect("planned runtime matching policy runtime"); + assert_eq!( + runtime.image_reference, + planned_runtime["runtime"]["image"] + .as_str() + .expect("historical runtime image") + ); + assert_eq!( + planned_runtime["runtime"]["kind"], "docker_compatible", + "historical runtime kind" + ); + assert_eq!(runtime.platforms.len(), 1, "fixed host platform tuple"); + assert_eq!(runtime.platforms[0].host_os, "macos"); + assert_eq!(runtime.platforms[0].host_arch, "aarch64"); + assert_eq!(runtime.platforms[0].runtime_kind, "docker_compatible"); } } diff --git a/tests/verification_contract.rs b/tests/verification_contract.rs index 6dcd0ca..366cf07 100644 --- a/tests/verification_contract.rs +++ b/tests/verification_contract.rs @@ -50,6 +50,8 @@ const ROOT_POLICY: &str = ".commit-ci-policy.toml"; const LEGACY_MATRIX_POLICY: &str = include_str!("fixtures/policy-v2-legacy-compatible.toml"); const LEGACY_MATRIX_PROVENANCE: &str = include_str!("fixtures/matrix-v2-legacy-plan-044697.provenance.json"); +const HISTORICAL_VERIFIER_PROVENANCE: &str = + include_str!("fixtures/historical-verifier-044697.provenance.json"); const LEGACY_MATRIX_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const LEGACY_MATRIX_EVALUATED_AT: &str = "2026-08-16T10:01:00Z"; const LEGACY_MATRIX_PRODUCER: &str = "0.1.0+matrix-v2-legacy-v1"; @@ -109,6 +111,33 @@ fn legacy_matrix_policy() -> MatrixVerificationPolicyV2 { MatrixVerificationPolicyV2::parse(LEGACY_MATRIX_POLICY).expect("legacy Matrix policy") } +#[test] +fn retained_historical_verifier_provenance_is_separate_from_generator_provenance() { + let generator: serde_json::Value = + serde_json::from_str(LEGACY_MATRIX_PROVENANCE).expect("generator provenance JSON"); + let verifier: serde_json::Value = + serde_json::from_str(HISTORICAL_VERIFIER_PROVENANCE).expect("verifier provenance JSON"); + + assert_eq!(generator["commit"], verifier["commit"]); + assert_eq!(generator["tree"], verifier["tree"]); + assert_eq!( + generator["binary_sha256_status"], + "observed_at_fixture_generation; temporary_binary_not_retained" + ); + assert_eq!( + verifier["binary_sha256_status"], + "retained_historical_verifier" + ); + assert_eq!( + verifier["build_argv"], + serde_json::json!(["cargo", "build", "--locked", "--offline"]) + ); + assert_eq!(verifier["plan_command_argv"][1], "plan"); + assert_eq!(verifier["output_sha256"], generator["output_sha256"]); + assert_eq!(verifier["outer_digest"], generator["outer_digest"]); + assert_eq!(verifier["runtime_digests"], generator["runtime_digests"]); +} + fn legacy_runtime_receipt( runtime_id: &str, image_reference: &str, @@ -240,7 +269,7 @@ fn historical_verifier() -> PathBuf { let path = env::var_os("CCP_HISTORICAL_VERIFIER_044697") .map(PathBuf::from) .expect("set CCP_HISTORICAL_VERIFIER_044697 to the reviewed 044697 verifier binary"); - let expected = serde_json::from_str::(LEGACY_MATRIX_PROVENANCE) + let expected = serde_json::from_str::(HISTORICAL_VERIFIER_PROVENANCE) .expect("provenance JSON")["binary_sha256"] .as_str() .expect("provenance binary SHA-256") From 68345a7efcd0c6b3e8175025c9c9b3ba92f91b3a Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 03:26:08 +0200 Subject: [PATCH 14/33] test: enforce pre-admission compatibility failures --- tests/matrix_contract.rs | 4 ++ tests/plan_cli.rs | 21 +++++++++ tests/runtime_cli.rs | 97 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 085fdf6..cfd82a6 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -449,6 +449,10 @@ fn legacy_profile_accessors_reject_mutated_public_plan() { envelope.runtime_configuration_digest("python311"), Err(MatrixError::PlanDigestMismatch) )); + assert!(matches!( + envelope.canonical_bytes(), + Err(MatrixError::PlanDigestMismatch) + )); } #[test] diff --git a/tests/plan_cli.rs b/tests/plan_cli.rs index 56bc77a..5ae6acb 100644 --- a/tests/plan_cli.rs +++ b/tests/plan_cli.rs @@ -193,6 +193,27 @@ fn matrix_plan_profile_rejects_unknown_values_with_usage_exit_code() { assert!(String::from_utf8_lossy(&output.stderr).contains("unknown-profile")); } +#[test] +fn legacy_matrix_profile_rejects_single_runtime_configuration_with_usage_exit_code() { + let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) + .args([ + "plan", + "--config", + CONFIG, + "--matrix-plan-profile", + "matrix-v2-legacy-v1", + ]) + .output() + .expect("run single-runtime legacy matrix plan command"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("matrix plan profile requires schema version 2.0") + ); +} + #[test] fn missing_configuration_exits_with_usage_code_two() { let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) diff --git a/tests/runtime_cli.rs b/tests/runtime_cli.rs index f8d89ed..8dbe513 100644 --- a/tests/runtime_cli.rs +++ b/tests/runtime_cli.rs @@ -39,6 +39,33 @@ fn executable_fixture_root(prefix: &str) -> PathBuf { .join(format!("{prefix}-{}", std::process::id())) } +fn tree_bytes(root: &Path) -> Vec<(PathBuf, Vec)> { + fn visit(root: &Path, path: &Path, output: &mut Vec<(PathBuf, Vec)>) { + let mut entries = fs::read_dir(path) + .expect("read fixture tree") + .collect::, _>>() + .expect("fixture tree entries"); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + visit(root, &path, output); + } else { + output.push(( + path.strip_prefix(root) + .expect("relative fixture path") + .to_path_buf(), + fs::read(&path).expect("fixture file bytes"), + )); + } + } + } + + let mut output = Vec::new(); + visit(root, root, &mut output); + output +} + #[test] fn legacy_profile_uses_distinct_plan_cache_identity() { let legacy = build_matrix_plan( @@ -250,6 +277,76 @@ fn matrix_legacy_profile_rejects_v1_schemas_before_runtime_cache_or_admission() fs::remove_dir_all(root).expect("remove owned fixture root"); } +#[cfg(unix)] +#[test] +fn legacy_profile_rejection_precedes_shared_state() { + use std::os::unix::fs::PermissionsExt; + + let root = executable_fixture_root("ccp-legacy-profile-pre-admission"); + let _ = fs::remove_dir_all(&root); + let source = root.join("source"); + let bin = root.join("bin"); + fs::create_dir_all(&source).expect("source fixture root"); + fs::create_dir_all(&bin).expect("fake runtime directory"); + + let config_path = source.join("single-runtime.toml"); + fs::copy(fixture(), &config_path).expect("copy single-runtime fixture"); + let source_before = tree_bytes(&source); + let cache_dir = root.join("cache"); + let admission_home = root.join("admission-home"); + let receipt = source.join(".ccp/receipt.json"); + let marker = root.join("runtime-marker"); + let docker = bin.join("docker"); + fs::write( + &docker, + format!("#!/bin/sh\nprintf runtime > '{}'\n", marker.display()), + ) + .expect("fake docker"); + let mut permissions = fs::metadata(&docker) + .expect("fake docker metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&docker, permissions).expect("fake docker executable"); + + let output = Command::new(binary()) + .args(["run", "--config"]) + .arg(&config_path) + .args([ + "--matrix-plan-profile", + "matrix-v2-legacy-v1", + "--repository", + ]) + .arg(&source) + .args(["--cache-dir"]) + .arg(&cache_dir) + .env("HOME", &admission_home) + .env("PATH", &bin) + .output() + .expect("legacy misuse run"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("matrix plan profile requires schema version 2.0") + ); + assert!(!cache_dir.exists(), "run initialized the cache root"); + assert!( + !cache_dir.join("run-journal-v1").exists(), + "run initialized the journal" + ); + assert!(!admission_home.exists(), "run initialized admission state"); + assert!(!receipt.exists(), "run wrote a receipt"); + assert!(!marker.exists(), "run constructed the Docker runtime"); + assert_eq!( + tree_bytes(&source), + source_before, + "run mutated the source tree" + ); + + fs::remove_dir_all(root).expect("remove owned fixture root"); +} + #[test] fn dry_run_human_output_states_that_argv_is_not_a_shell_and_was_not_run() { let output = Command::new(binary()) From 370d18e8ff4ebf348d5a8c5dc00b1843b441bb93 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 03:35:38 +0200 Subject: [PATCH 15/33] test: expand legacy profile mutation guards --- tests/runtime_cli.rs | 338 ++++++++++++++++++++++++++++--------------- 1 file changed, 222 insertions(+), 116 deletions(-) diff --git a/tests/runtime_cli.rs b/tests/runtime_cli.rs index 8dbe513..8c57343 100644 --- a/tests/runtime_cli.rs +++ b/tests/runtime_cli.rs @@ -66,6 +66,152 @@ fn tree_bytes(root: &Path) -> Vec<(PathBuf, Vec)> { output } +const RUNTIME_SENTINEL: &str = "ccp-legacy-profile-runtime-sentinel"; + +struct IsolatedLegacyRunFixture { + root: PathBuf, + source: PathBuf, + cache_dir: PathBuf, + home: PathBuf, + xdg_cache_home: PathBuf, + local_app_data: PathBuf, + bin: PathBuf, + marker: PathBuf, + source_before: Vec<(PathBuf, Vec)>, +} + +impl IsolatedLegacyRunFixture { + fn new(prefix: &str, config: &str) -> Self { + let root = executable_fixture_root(prefix); + let _ = fs::remove_dir_all(&root); + let source = root.join("source"); + let bin = root.join("bin"); + fs::create_dir_all(&source).expect("source fixture root"); + fs::create_dir_all(&bin).expect("fake runtime directory"); + fs::write(source.join("config.toml"), config).expect("write config fixture"); + + let marker = root.join("runtime-marker"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let docker = bin.join("docker"); + fs::write( + &docker, + format!( + "#!/bin/sh\nprintf runtime > '{}'\nprintf '%s\\n' '{}' >&2\nexit 97\n", + marker.display(), + RUNTIME_SENTINEL, + ), + ) + .expect("fake docker"); + let mut permissions = fs::metadata(&docker) + .expect("fake docker metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&docker, permissions).expect("fake docker executable"); + } + #[cfg(windows)] + fs::write( + bin.join("docker.cmd"), + format!("@echo {RUNTIME_SENTINEL} 1>&2\r\n@exit /b 97\r\n"), + ) + .expect("fake docker command"); + + let source_before = tree_bytes(&source); + Self { + cache_dir: root.join("cache"), + home: root.join("home"), + xdg_cache_home: root.join("xdg-cache"), + local_app_data: root.join("local-app-data"), + root, + source, + bin, + marker, + source_before, + } + } + + fn run(&self) -> std::process::Output { + Command::new(binary()) + .args(["run", "--config"]) + .arg(self.source.join("config.toml")) + .args([ + "--matrix-plan-profile", + "matrix-v2-legacy-v1", + "--repository", + ]) + .arg(&self.source) + .args(["--cache-dir"]) + .arg(&self.cache_dir) + .env("HOME", &self.home) + .env("XDG_CACHE_HOME", &self.xdg_cache_home) + .env("LOCALAPPDATA", &self.local_app_data) + .env("PATH", &self.bin) + .output() + .expect("legacy misuse run") + } + + fn assert_rejected_before_shared_state(&self, output: std::process::Output, field: &str) { + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(field), + "stderr did not name {field}: {stderr}" + ); + assert!( + !stderr.contains(RUNTIME_SENTINEL), + "runtime sentinel reached stderr: {stderr}" + ); + assert!(!self.cache_dir.exists(), "run initialized the cache root"); + assert!( + !self.cache_dir.join("run-journal-v1").exists(), + "run initialized the journal" + ); + for admission_root in [ + self.home + .join("Library") + .join("Caches") + .join("commit-ci-preflight-admission"), + self.xdg_cache_home.join("commit-ci-preflight-admission"), + self.local_app_data.join("commit-ci-preflight-admission"), + ] { + assert!( + !admission_root.exists(), + "run initialized admission state at {}", + admission_root.display() + ); + } + assert!(!self.home.exists(), "run created HOME state"); + assert!(!self.xdg_cache_home.exists(), "run created XDG cache state"); + assert!( + !self.local_app_data.exists(), + "run created local app-data state" + ); + assert!( + !self.source.join(".ccp/receipt.json").exists(), + "run wrote a receipt" + ); + assert!(!self.marker.exists(), "run constructed the Docker runtime"); + assert_eq!( + tree_bytes(&self.source), + self.source_before, + "run mutated the source tree" + ); + } + + fn cleanup(self) { + fs::remove_dir_all(self.root).expect("remove owned fixture root"); + } +} + +fn assert_legacy_run_rejected_before_shared_state(prefix: &str, config: &str, field: &str) { + let fixture = IsolatedLegacyRunFixture::new(prefix, config); + fixture.assert_rejected_before_shared_state(fixture.run(), field); + fixture.cleanup(); +} + #[test] fn legacy_profile_uses_distinct_plan_cache_identity() { let legacy = build_matrix_plan( @@ -223,128 +369,88 @@ timeout_seconds = 1 } #[test] -fn matrix_legacy_profile_rejects_v1_schemas_before_runtime_cache_or_admission() { - let root = executable_fixture_root("ccp-legacy-profile-v1-schema"); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("owned fixture root"); - let v1_fixture = fs::read_to_string(fixture()).expect("v1 fixture"); - +fn legacy_profile_rejection_precedes_shared_state() { + let single_runtime = fs::read_to_string(fixture()).expect("v1 fixture"); for schema_version in ["1.0", "1.1", "1.2", "1.3"] { - let config_path = root.join(format!("config-{schema_version}.toml")); - fs::write( - &config_path, - v1_fixture.replace( - "schema_version = \"1.0\"", - &format!("schema_version = \"{schema_version}\""), - ), - ) - .expect("legacy-incompatible fixture"); - - for command_name in ["plan", "doctor", "dry-run", "run"] { - let cache_dir = root.join(format!("cache-{command_name}-{schema_version}")); - let mut command = Command::new(binary()); - command - .arg(command_name) - .arg("--config") - .arg(&config_path) - .args(["--matrix-plan-profile", "matrix-v2-legacy-v1"]); - if matches!(command_name, "dry-run" | "run") { - command - .arg("--repository") - .arg(&root) - .arg("--cache-dir") - .arg(&cache_dir); - } - let output = command.output().expect("legacy profile command"); - - assert_eq!( - output.status.code(), - Some(2), - "{command_name} with schema {schema_version}: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(output.stdout.is_empty()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("matrix plan profile requires schema version 2.0") - ); - assert!( - !cache_dir.exists(), - "{command_name} with schema {schema_version} initialized a cache" - ); - } + let config = single_runtime.replace( + "schema_version = \"1.0\"", + &format!("schema_version = \"{schema_version}\""), + ); + assert_legacy_run_rejected_before_shared_state( + &format!("ccp-legacy-profile-v1-{schema_version}"), + &config, + "matrix plan profile requires schema version 2.0", + ); } - fs::remove_dir_all(root).expect("remove owned fixture root"); } -#[cfg(unix)] #[test] -fn legacy_profile_rejection_precedes_shared_state() { - use std::os::unix::fs::PermissionsExt; - - let root = executable_fixture_root("ccp-legacy-profile-pre-admission"); - let _ = fs::remove_dir_all(&root); - let source = root.join("source"); - let bin = root.join("bin"); - fs::create_dir_all(&source).expect("source fixture root"); - fs::create_dir_all(&bin).expect("fake runtime directory"); - - let config_path = source.join("single-runtime.toml"); - fs::copy(fixture(), &config_path).expect("copy single-runtime fixture"); - let source_before = tree_bytes(&source); - let cache_dir = root.join("cache"); - let admission_home = root.join("admission-home"); - let receipt = source.join(".ccp/receipt.json"); - let marker = root.join("runtime-marker"); - let docker = bin.join("docker"); - fs::write( - &docker, - format!("#!/bin/sh\nprintf runtime > '{}'\n", marker.display()), - ) - .expect("fake docker"); - let mut permissions = fs::metadata(&docker) - .expect("fake docker metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&docker, permissions).expect("fake docker executable"); - - let output = Command::new(binary()) - .args(["run", "--config"]) - .arg(&config_path) - .args([ - "--matrix-plan-profile", - "matrix-v2-legacy-v1", - "--repository", - ]) - .arg(&source) - .args(["--cache-dir"]) - .arg(&cache_dir) - .env("HOME", &admission_home) - .env("PATH", &bin) - .output() - .expect("legacy misuse run"); - - assert_eq!(output.status.code(), Some(2)); - assert!(output.stdout.is_empty()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("matrix plan profile requires schema version 2.0") - ); - assert!(!cache_dir.exists(), "run initialized the cache root"); - assert!( - !cache_dir.join("run-journal-v1").exists(), - "run initialized the journal" - ); - assert!(!admission_home.exists(), "run initialized admission state"); - assert!(!receipt.exists(), "run wrote a receipt"); - assert!(!marker.exists(), "run constructed the Docker runtime"); - assert_eq!( - tree_bytes(&source), - source_before, - "run mutated the source tree" - ); +fn legacy_profile_rejects_current_only_matrix_syntax_before_shared_state() { + let matrix = include_str!("fixtures/config-v2-legacy-compatible.toml"); + let cases = [ + ( + "runtime-pull-policy", + matrix.replacen( + "network = false", + "network = false\npull_policy = \"never\"", + 1, + ), + "pull_policy", + ), + ( + "runtime-swap-mode", + matrix.replacen( + "network = false", + "network = false\nswap_mode = \"disabled\"", + 1, + ), + "swap_mode", + ), + ( + "environment-fixed", + matrix.replacen( + "allow = [\"SOURCE_DATE_EPOCH\"]", + "allow = [\"SOURCE_DATE_EPOCH\"]\nfixed = { FIXED = \"value\" }", + 1, + ), + "fixed", + ), + ( + "environment-runtime-internal", + matrix.replacen( + "allow = [\"SOURCE_DATE_EPOCH\"]", + "allow = [\"SOURCE_DATE_EPOCH\"]\nruntime_internal = []", + 1, + ), + "runtime_internal", + ), + ( + "environment-remote-secret-only", + matrix.replacen( + "allow = [\"SOURCE_DATE_EPOCH\"]", + "allow = [\"SOURCE_DATE_EPOCH\"]\nremote_secret_only = []", + 1, + ), + "remote_secret_only", + ), + ( + "check-artifact-contracts", + matrix.replacen( + "timeout_seconds = 30", + "timeout_seconds = 30\nartifact_contracts = []", + 1, + ), + "artifact_contracts", + ), + ]; - fs::remove_dir_all(root).expect("remove owned fixture root"); + for (name, config, field) in cases { + assert_legacy_run_rejected_before_shared_state( + &format!("ccp-legacy-profile-{name}"), + &config, + field, + ); + } } #[test] From ba8be6ca3b53a142b61c76e2e3621a383f4553ed Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 03:43:54 +0200 Subject: [PATCH 16/33] test: mark Windows legacy runtime fake --- tests/runtime_cli.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/runtime_cli.rs b/tests/runtime_cli.rs index 8c57343..8dbc843 100644 --- a/tests/runtime_cli.rs +++ b/tests/runtime_cli.rs @@ -114,7 +114,10 @@ impl IsolatedLegacyRunFixture { #[cfg(windows)] fs::write( bin.join("docker.cmd"), - format!("@echo {RUNTIME_SENTINEL} 1>&2\r\n@exit /b 97\r\n"), + format!( + "@echo runtime> \"{}\"\r\n@echo {RUNTIME_SENTINEL} 1>&2\r\n@exit /b 97\r\n", + marker.display() + ), ) .expect("fake docker command"); From 3838400c28e2553d3da9972048ae92d112eeaf62 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 03:48:04 +0200 Subject: [PATCH 17/33] docs: document Matrix V2 compatibility profile --- CHANGELOG.md | 5 ++++ docs/ADOPTION_GUIDE.md | 8 +++++ docs/CACHE_AND_WORKSPACE.md | 6 ++++ docs/CONFIGURATION.md | 17 +++++++++++ docs/GITHUB_GATE.md | 8 +++++ docs/INVARIANT_EVIDENCE_MATRIX.md | 15 ++++++++++ docs/LOCAL_RUN.md | 11 +++++++ docs/MULTI_RUNTIME_RECEIPTS.md | 8 +++++ docs/RECEIPT_SPEC.md | 7 +++++ docs/TESTING_AND_FAULT_INJECTION.md | 7 +++++ docs/TROUBLESHOOTING.md | 8 +++++ task-8-report.md | 22 ++++++++++++++ tests/repository_hygiene_contract.rs | 44 ++++++++++++++++++++++++++++ 13 files changed, 166 insertions(+) create mode 100644 task-8-report.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 99956fd..3cd61a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -282,3 +282,8 @@ Semantic Versioning after its first public release. [Unreleased]: https://github.com/MarcoPorcellato/commit-ci-preflight/compare/v0.1.0-rc.1...HEAD [0.1.0-rc.1]: https://github.com/MarcoPorcellato/commit-ci-preflight/releases/tag/v0.1.0-rc.1 +# Unreleased + +- Documented the Matrix-only `matrix-v2-legacy-v1` compatibility profile, + command parity, cache separation, historical-verifier boundary, evidence + matrix, and rollback target `current-v2`. diff --git a/docs/ADOPTION_GUIDE.md b/docs/ADOPTION_GUIDE.md index dee13c1..63024f3 100644 --- a/docs/ADOPTION_GUIDE.md +++ b/docs/ADOPTION_GUIDE.md @@ -329,3 +329,11 @@ remote jobs. Preserve receipts and configurations for incident analysis. See - [ ] GitHub status is observed on the latest PR head. - [ ] Review, secrets, deployments, and uncovered native gates remain remote. - [ ] Rollback path is documented and tested. +# Matrix V2 legacy adoption boundary + +Adopters using the Matrix-only `matrix-v2-legacy-v1` profile must preserve the +producer suffix and command parity (`plan`, `doctor`, `dry-run`, `run`). The +operator copies reviewed digests into Matrix policy v2, never from a completed +receipt. Legacy and current cache namespaces are separate; `verify` has no +profile flag. Require acceptance by an old trusted historical verifier before +policy migration. This guidance does not infer policy or general trust. diff --git a/docs/CACHE_AND_WORKSPACE.md b/docs/CACHE_AND_WORKSPACE.md index bf62051..efb3a55 100644 --- a/docs/CACHE_AND_WORKSPACE.md +++ b/docs/CACHE_AND_WORKSPACE.md @@ -204,3 +204,9 @@ introduced. The declaration is cooperative and non-attesting: undeclared paths are not pinned, and manual or non-cooperative replacement is unsupported. Pinning does not initialize, repair, delete, quarantine, publish, or attest cache contents. +# Compatibility cache boundary + +The Matrix-only `matrix-v2-legacy-v1` profile has a separate legacy cache +namespace from current-v2. Preserve command parity for `plan`, `doctor`, +`dry-run`, and `run`; `verify` has no profile flag. Cache identity and the +producer suffix are evidence fields, not policy inference or general trust. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cb5694b..7f56230 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -222,3 +222,20 @@ and artifact contracts. It uses the same CCP canonical JSON v1 profile described in `docs/RECEIPT_SPEC.md`. The digest is integrity evidence, not a signature or identity attestation. +# Matrix V2 compatibility profile + +The `matrix-v2-legacy-v1` profile is Matrix-only compatibility for the legacy +producer suffix. It does not widen the schema or infer policy. Review the plan +and copy its digests into Matrix policy v2; never copy them from a completed +receipt. Use the same command parity sequence for each selected source: + +```console +commit-ci-preflight plan --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight doctor --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight dry-run --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight run --matrix-plan-profile matrix-v2-legacy-v1 --generation N --json +``` + +Legacy and current cache identities are separate. `verify` has no profile flag. +An old trusted historical verifier must accept the exact receipt before policy +migration; this is evidence acceptance, not a general trust claim. diff --git a/docs/GITHUB_GATE.md b/docs/GITHUB_GATE.md index 8b7bdbd..33c79f2 100644 --- a/docs/GITHUB_GATE.md +++ b/docs/GITHUB_GATE.md @@ -170,3 +170,11 @@ The only cold remote compilation is the small trusted verifier. Project tests, Docker checks, and local dependency caches remain off GitHub. A future signed release can replace this bootstrap build with a pinned verifier download after release publication is separately authorized and qualified. +# Matrix V2 compatibility gate + +The `matrix-v2-legacy-v1` profile is Matrix-only. Preserve command parity across +`plan`, `doctor`, `dry-run`, and `run`; `verify` has no profile flag. Review +digests before copying them into Matrix policy v2, never from a completed +receipt. Legacy/current cache namespaces are separate, and an old trusted +historical verifier must accept the exact receipt before migration. This is not +policy inference or a general trust claim. diff --git a/docs/INVARIANT_EVIDENCE_MATRIX.md b/docs/INVARIANT_EVIDENCE_MATRIX.md index 5575917..1f5f9d7 100644 --- a/docs/INVARIANT_EVIDENCE_MATRIX.md +++ b/docs/INVARIANT_EVIDENCE_MATRIX.md @@ -39,3 +39,18 @@ The implemented source boundary is explicit: This closes T2 in deterministic source evidence. Native platform, crash/power-loss and release qualification remain separate gates. +# Task 8 documentation evidence matrix + +| Invariant | Focused evidence | +|---|---| +| Projection reproducibility | projection contract test | +| Representability rejection | representability test | +| Command parity | profile CLI contract test | +| Cache separation | cache identity test | +| Producer uniformity | producer suffix test | +| Historical verifier acceptance | compatibility verifier test | +| Mutation rejection | mutation-boundary test | +| Zero pre-admission mutation | admission journal test | + +All entries are Matrix-only evidence for `matrix-v2-legacy-v1`; they do not infer +policy or establish general trust. Rollback target is `current-v2`. diff --git a/docs/LOCAL_RUN.md b/docs/LOCAL_RUN.md index 5dd7bc8..7f5fb0a 100644 --- a/docs/LOCAL_RUN.md +++ b/docs/LOCAL_RUN.md @@ -213,3 +213,14 @@ release. This declaration is cooperative and non-attesting: undeclared paths are not pinned, and manual or non-cooperative replacement is unsupported. The flags do not initialize, repair, delete, quarantine, publish, or attest cache contents. Legacy `guard exec` without these flags remains unchanged. +# Matrix V2 legacy operator sequence + +For Matrix-only compatibility, use the exact `matrix-v2-legacy-v1` profile and +preserve command parity: `plan`, `doctor`, `dry-run`, then `run` with generation +`N`. The operator reviews and copies digests into Matrix policy v2, never from a +completed receipt. The producer suffix is part of the reviewed evidence. + +`verify` has no profile flag. Legacy and current cache namespace identities are +separate, and an old trusted historical verifier must accept the exact receipt +before policy migration. These statements document boundaries and do not infer +policy or general trust. diff --git a/docs/MULTI_RUNTIME_RECEIPTS.md b/docs/MULTI_RUNTIME_RECEIPTS.md index 8ff0c3e..9943090 100644 --- a/docs/MULTI_RUNTIME_RECEIPTS.md +++ b/docs/MULTI_RUNTIME_RECEIPTS.md @@ -164,3 +164,11 @@ project code. Regenerate all pinned contracts with `cargo run --locked --example generate_contract`. The v1 schema/fixture tests require historical v1 output to remain byte-for-byte stable. +# Matrix-only legacy compatibility + +The `matrix-v2-legacy-v1` profile is Matrix-only and retains the producer suffix +as reviewed evidence. Run `plan`, `doctor`, `dry-run`, and `run` with the profile +for command parity; `verify` has no profile flag. Copy reviewed digests into +Matrix policy v2, never from a completed receipt. Legacy and current cache +namespaces remain separate. Historical verifier acceptance is required before +migration and is not policy inference or a general trust statement. diff --git a/docs/RECEIPT_SPEC.md b/docs/RECEIPT_SPEC.md index c61bc23..1d5134d 100644 --- a/docs/RECEIPT_SPEC.md +++ b/docs/RECEIPT_SPEC.md @@ -202,3 +202,10 @@ cargo test --test receipt_contract Regeneration is accepted only when the schema/fixture diff is intentionally reviewed. Tests require generated and pinned bytes to remain identical. +# Compatibility-profile boundary + +Receipts produced under `matrix-v2-legacy-v1` are Matrix-only compatibility +evidence and retain the producer suffix. The profile applies to `plan`, +`doctor`, `dry-run`, and `run`; `verify` has no profile flag. A historical +verifier must accept the exact receipt before Matrix policy v2 migration. This +does not widen the schema, infer policy, or establish general trust. diff --git a/docs/TESTING_AND_FAULT_INJECTION.md b/docs/TESTING_AND_FAULT_INJECTION.md index 32169d6..91f0cf3 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -147,3 +147,10 @@ trusted configuration validation. 6. Record platform-native evidence separately from deterministic tests. 7. Keep fault fixtures free of secrets, personal data, network access, and the operator's real cache or repository state. +# Matrix V2 compatibility test matrix + +Focused tests cover projection reproducibility, representability rejection, +command parity, cache namespace separation, producer uniformity, historical +verifier acceptance, mutation rejection, and zero pre-admission mutation for +the Matrix-only `matrix-v2-legacy-v1` profile. `verify` has no profile flag. +Tests should not infer policy or general trust; rollback target is `current-v2`. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e97d802..d91b5b0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -228,3 +228,11 @@ Include: Keep private source, raw logs, credentials, tokens, environment values, and personal paths out of public issues. Security findings belong in the private channel described by [SECURITY.md](../SECURITY.md). +# Matrix V2 legacy troubleshooting + +For Matrix-only compatibility, confirm the exact `matrix-v2-legacy-v1` profile +and the command parity sequence (`plan`, `doctor`, `dry-run`, `run`). Legacy and +current cache namespaces must not be mixed. `verify` has no profile flag. Before +policy migration, an old trusted historical verifier must accept the exact +receipt. Review digests into Matrix policy v2 only from the reviewed plan, never +from a completed receipt; do not infer policy or general trust. diff --git a/task-8-report.md b/task-8-report.md new file mode 100644 index 0000000..3368378 --- /dev/null +++ b/task-8-report.md @@ -0,0 +1,22 @@ +# Task 8 report + +## Scope + +Documentation-only Matrix V2 compatibility contract for `matrix-v2-legacy-v1`. +The repository hygiene test scans the public operator documents and rejects +selected production digest constants. No production source was changed. + +## Evidence + +- RED: `rtk cargo test --test repository_hygiene_contract matrix_legacy_profile_is_documented_without_production_digest_constants -- --exact --nocapture` + initially hit the existing target lock permission; rerun with + `CARGO_TARGET_DIR=/tmp/ccp-task8-target` failed as expected on missing + `matrix-v2-legacy-v1` documentation. +- GREEN: `rtk env CARGO_TARGET_DIR=/tmp/ccp-task8-target cargo test --test repository_hygiene_contract -- --nocapture` — 7 passed. +- GREEN: `rtk env CARGO_TARGET_DIR=/tmp/ccp-task8-target cargo test --test release_hardening_contract` — 7 passed. + +## Boundaries + +No network, CCP, Docker, install, push, PR, or publication was performed. +The persistent target directory was not modified; the isolated target was used +only because the repository target lock was not permitted by the environment. diff --git a/tests/repository_hygiene_contract.rs b/tests/repository_hygiene_contract.rs index f7483ad..c9a4158 100644 --- a/tests/repository_hygiene_contract.rs +++ b/tests/repository_hygiene_contract.rs @@ -24,6 +24,50 @@ const PR_TEMPLATE: &str = include_str!("../.github/PULL_REQUEST_TEMPLATE.md"); const ROADMAP: &str = include_str!("../ROADMAP.md"); const SOCIAL_PREVIEW: &str = include_str!("../docs/assets/social-preview.svg"); +#[test] +fn matrix_legacy_profile_is_documented_without_production_digest_constants() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let docs = [ + "docs/CONFIGURATION.md", + "docs/LOCAL_RUN.md", + "docs/MULTI_RUNTIME_RECEIPTS.md", + "docs/RECEIPT_SPEC.md", + "docs/GITHUB_GATE.md", + "docs/ADOPTION_GUIDE.md", + "docs/CACHE_AND_WORKSPACE.md", + "docs/TROUBLESHOOTING.md", + "docs/INVARIANT_EVIDENCE_MATRIX.md", + "docs/TESTING_AND_FAULT_INJECTION.md", + ]; + let corpus = docs + .iter() + .map(|path| fs::read_to_string(root.join(path)).expect("read public documentation")) + .collect::>() + .join("\n"); + for required in [ + "matrix-v2-legacy-v1", + "Matrix-only", + "producer suffix", + "command parity", + "cache namespace", + "policy inference", + "historical verifier", + "current-v2", + ] { + assert!(corpus.to_lowercase().contains(&required.to_lowercase()), "missing documentation contract: {required}"); + } + let source = fs::read_dir(root.join("src")).expect("read src"); + let source_text = source + .filter_map(Result::ok) + .filter(|entry| entry.path().extension().and_then(|v| v.to_str()) == Some("rs")) + .map(|entry| fs::read_to_string(entry.path()).expect("read source")) + .collect::>() + .join("\n"); + for forbidden in ["latent-adopter", "golden digest", "golden_digest"] { + assert!(!source_text.to_lowercase().contains(forbidden), "forbidden production digest constant: {forbidden}"); + } +} + #[test] fn issue_template_yaml_is_present_and_safe() { assert_yaml_mapping("issue template config", ISSUE_CONFIG, true); From 18bf8e4a5e8c1760108882db9c0583a3868f48a0 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 04:06:08 +0200 Subject: [PATCH 18/33] docs: tighten Matrix V2 compatibility contract --- CHANGELOG.md | 5 -- docs/ADOPTION_GUIDE.md | 2 +- docs/CACHE_AND_WORKSPACE.md | 2 +- docs/CONFIGURATION.md | 2 +- docs/GITHUB_GATE.md | 3 +- docs/INVARIANT_EVIDENCE_MATRIX.md | 2 +- docs/LOCAL_RUN.md | 9 ++- docs/MULTI_RUNTIME_RECEIPTS.md | 9 ++- docs/RECEIPT_SPEC.md | 6 +- docs/TESTING_AND_FAULT_INJECTION.md | 2 +- docs/TROUBLESHOOTING.md | 2 +- task-8-report.md | 22 ------ tests/repository_hygiene_contract.rs | 102 +++++++++++++++++---------- 13 files changed, 95 insertions(+), 73 deletions(-) delete mode 100644 task-8-report.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd61a1..99956fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -282,8 +282,3 @@ Semantic Versioning after its first public release. [Unreleased]: https://github.com/MarcoPorcellato/commit-ci-preflight/compare/v0.1.0-rc.1...HEAD [0.1.0-rc.1]: https://github.com/MarcoPorcellato/commit-ci-preflight/releases/tag/v0.1.0-rc.1 -# Unreleased - -- Documented the Matrix-only `matrix-v2-legacy-v1` compatibility profile, - command parity, cache separation, historical-verifier boundary, evidence - matrix, and rollback target `current-v2`. diff --git a/docs/ADOPTION_GUIDE.md b/docs/ADOPTION_GUIDE.md index 63024f3..fd04ed7 100644 --- a/docs/ADOPTION_GUIDE.md +++ b/docs/ADOPTION_GUIDE.md @@ -329,7 +329,7 @@ remote jobs. Preserve receipts and configurations for incident analysis. See - [ ] GitHub status is observed on the latest PR head. - [ ] Review, secrets, deployments, and uncovered native gates remain remote. - [ ] Rollback path is documented and tested. -# Matrix V2 legacy adoption boundary +## Matrix V2 legacy adoption boundary Adopters using the Matrix-only `matrix-v2-legacy-v1` profile must preserve the producer suffix and command parity (`plan`, `doctor`, `dry-run`, `run`). The diff --git a/docs/CACHE_AND_WORKSPACE.md b/docs/CACHE_AND_WORKSPACE.md index efb3a55..45f9d9c 100644 --- a/docs/CACHE_AND_WORKSPACE.md +++ b/docs/CACHE_AND_WORKSPACE.md @@ -204,7 +204,7 @@ introduced. The declaration is cooperative and non-attesting: undeclared paths are not pinned, and manual or non-cooperative replacement is unsupported. Pinning does not initialize, repair, delete, quarantine, publish, or attest cache contents. -# Compatibility cache boundary +## Compatibility cache boundary The Matrix-only `matrix-v2-legacy-v1` profile has a separate legacy cache namespace from current-v2. Preserve command parity for `plan`, `doctor`, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7f56230..d3e1160 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -222,7 +222,7 @@ and artifact contracts. It uses the same CCP canonical JSON v1 profile described in `docs/RECEIPT_SPEC.md`. The digest is integrity evidence, not a signature or identity attestation. -# Matrix V2 compatibility profile +## Matrix V2 compatibility profile The `matrix-v2-legacy-v1` profile is Matrix-only compatibility for the legacy producer suffix. It does not widen the schema or infer policy. Review the plan diff --git a/docs/GITHUB_GATE.md b/docs/GITHUB_GATE.md index 33c79f2..752f7ea 100644 --- a/docs/GITHUB_GATE.md +++ b/docs/GITHUB_GATE.md @@ -170,7 +170,7 @@ The only cold remote compilation is the small trusted verifier. Project tests, Docker checks, and local dependency caches remain off GitHub. A future signed release can replace this bootstrap build with a pinned verifier download after release publication is separately authorized and qualified. -# Matrix V2 compatibility gate +## Matrix V2 compatibility gate The `matrix-v2-legacy-v1` profile is Matrix-only. Preserve command parity across `plan`, `doctor`, `dry-run`, and `run`; `verify` has no profile flag. Review @@ -178,3 +178,4 @@ digests before copying them into Matrix policy v2, never from a completed receipt. Legacy/current cache namespaces are separate, and an old trusted historical verifier must accept the exact receipt before migration. This is not policy inference or a general trust claim. +Rollback target is `current-v2`. diff --git a/docs/INVARIANT_EVIDENCE_MATRIX.md b/docs/INVARIANT_EVIDENCE_MATRIX.md index 1f5f9d7..fafd652 100644 --- a/docs/INVARIANT_EVIDENCE_MATRIX.md +++ b/docs/INVARIANT_EVIDENCE_MATRIX.md @@ -39,7 +39,7 @@ The implemented source boundary is explicit: This closes T2 in deterministic source evidence. Native platform, crash/power-loss and release qualification remain separate gates. -# Task 8 documentation evidence matrix +## Task 8 documentation evidence matrix | Invariant | Focused evidence | |---|---| diff --git a/docs/LOCAL_RUN.md b/docs/LOCAL_RUN.md index 7f5fb0a..2b57803 100644 --- a/docs/LOCAL_RUN.md +++ b/docs/LOCAL_RUN.md @@ -213,7 +213,14 @@ release. This declaration is cooperative and non-attesting: undeclared paths are not pinned, and manual or non-cooperative replacement is unsupported. The flags do not initialize, repair, delete, quarantine, publish, or attest cache contents. Legacy `guard exec` without these flags remains unchanged. -# Matrix V2 legacy operator sequence +## Matrix V2 legacy operator sequence + +```console +commit-ci-preflight plan --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight doctor --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight dry-run --matrix-plan-profile matrix-v2-legacy-v1 --json +commit-ci-preflight run --matrix-plan-profile matrix-v2-legacy-v1 --generation N --json +``` For Matrix-only compatibility, use the exact `matrix-v2-legacy-v1` profile and preserve command parity: `plan`, `doctor`, `dry-run`, then `run` with generation diff --git a/docs/MULTI_RUNTIME_RECEIPTS.md b/docs/MULTI_RUNTIME_RECEIPTS.md index 9943090..1e05df7 100644 --- a/docs/MULTI_RUNTIME_RECEIPTS.md +++ b/docs/MULTI_RUNTIME_RECEIPTS.md @@ -164,7 +164,14 @@ project code. Regenerate all pinned contracts with `cargo run --locked --example generate_contract`. The v1 schema/fixture tests require historical v1 output to remain byte-for-byte stable. -# Matrix-only legacy compatibility +## Matrix-only legacy compatibility + +The historical verifier external test is `tests/verification_contract.rs::historical_matrix_verifier` +and requires `CCP_HISTORICAL_VERIFIER_044697`, provenance-pinned to commit +`044697dee9a0d678d30a4847d62ddf9b4970505b`. + +This is the outer-v2 / inner-v1 boundary: outer Matrix schema 2.0 and inner +runtime schema 1.0, produced with version `0.1.0+matrix-v2-legacy-v1`. The `matrix-v2-legacy-v1` profile is Matrix-only and retains the producer suffix as reviewed evidence. Run `plan`, `doctor`, `dry-run`, and `run` with the profile diff --git a/docs/RECEIPT_SPEC.md b/docs/RECEIPT_SPEC.md index 1d5134d..ffad927 100644 --- a/docs/RECEIPT_SPEC.md +++ b/docs/RECEIPT_SPEC.md @@ -202,7 +202,11 @@ cargo test --test receipt_contract Regeneration is accepted only when the schema/fixture diff is intentionally reviewed. Tests require generated and pinned bytes to remain identical. -# Compatibility-profile boundary +## Compatibility-profile boundary + +The producer version is `0.1.0+matrix-v2-legacy-v1`. The receipt boundary is +outer Matrix schema 2.0 with inner runtime schema 1.0. Reviewed digests are +never from a completed receipt. Receipts produced under `matrix-v2-legacy-v1` are Matrix-only compatibility evidence and retain the producer suffix. The profile applies to `plan`, diff --git a/docs/TESTING_AND_FAULT_INJECTION.md b/docs/TESTING_AND_FAULT_INJECTION.md index 91f0cf3..911e6cc 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -147,7 +147,7 @@ trusted configuration validation. 6. Record platform-native evidence separately from deterministic tests. 7. Keep fault fixtures free of secrets, personal data, network access, and the operator's real cache or repository state. -# Matrix V2 compatibility test matrix +## Matrix V2 compatibility test matrix Focused tests cover projection reproducibility, representability rejection, command parity, cache namespace separation, producer uniformity, historical diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d91b5b0..856ce76 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -228,7 +228,7 @@ Include: Keep private source, raw logs, credentials, tokens, environment values, and personal paths out of public issues. Security findings belong in the private channel described by [SECURITY.md](../SECURITY.md). -# Matrix V2 legacy troubleshooting +## Matrix V2 legacy troubleshooting For Matrix-only compatibility, confirm the exact `matrix-v2-legacy-v1` profile and the command parity sequence (`plan`, `doctor`, `dry-run`, `run`). Legacy and diff --git a/task-8-report.md b/task-8-report.md deleted file mode 100644 index 3368378..0000000 --- a/task-8-report.md +++ /dev/null @@ -1,22 +0,0 @@ -# Task 8 report - -## Scope - -Documentation-only Matrix V2 compatibility contract for `matrix-v2-legacy-v1`. -The repository hygiene test scans the public operator documents and rejects -selected production digest constants. No production source was changed. - -## Evidence - -- RED: `rtk cargo test --test repository_hygiene_contract matrix_legacy_profile_is_documented_without_production_digest_constants -- --exact --nocapture` - initially hit the existing target lock permission; rerun with - `CARGO_TARGET_DIR=/tmp/ccp-task8-target` failed as expected on missing - `matrix-v2-legacy-v1` documentation. -- GREEN: `rtk env CARGO_TARGET_DIR=/tmp/ccp-task8-target cargo test --test repository_hygiene_contract -- --nocapture` — 7 passed. -- GREEN: `rtk env CARGO_TARGET_DIR=/tmp/ccp-task8-target cargo test --test release_hardening_contract` — 7 passed. - -## Boundaries - -No network, CCP, Docker, install, push, PR, or publication was performed. -The persistent target directory was not modified; the isolated target was used -only because the repository target lock was not permitted by the environment. diff --git a/tests/repository_hygiene_contract.rs b/tests/repository_hygiene_contract.rs index c9a4158..cae4a86 100644 --- a/tests/repository_hygiene_contract.rs +++ b/tests/repository_hygiene_contract.rs @@ -27,44 +27,74 @@ const SOCIAL_PREVIEW: &str = include_str!("../docs/assets/social-preview.svg"); #[test] fn matrix_legacy_profile_is_documented_without_production_digest_constants() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let docs = [ - "docs/CONFIGURATION.md", - "docs/LOCAL_RUN.md", - "docs/MULTI_RUNTIME_RECEIPTS.md", - "docs/RECEIPT_SPEC.md", - "docs/GITHUB_GATE.md", - "docs/ADOPTION_GUIDE.md", - "docs/CACHE_AND_WORKSPACE.md", - "docs/TROUBLESHOOTING.md", - "docs/INVARIANT_EVIDENCE_MATRIX.md", - "docs/TESTING_AND_FAULT_INJECTION.md", - ]; - let corpus = docs - .iter() - .map(|path| fs::read_to_string(root.join(path)).expect("read public documentation")) - .collect::>() - .join("\n"); - for required in [ - "matrix-v2-legacy-v1", - "Matrix-only", - "producer suffix", - "command parity", - "cache namespace", - "policy inference", - "historical verifier", - "current-v2", + for path in ["docs/CONFIGURATION.md", "docs/LOCAL_RUN.md"] { + let text = fs::read_to_string(root.join(path)).expect("read operator docs"); + for command in [ + "plan --matrix-plan-profile matrix-v2-legacy-v1 --json", + "doctor --matrix-plan-profile matrix-v2-legacy-v1 --json", + "dry-run --matrix-plan-profile matrix-v2-legacy-v1 --json", + "run --matrix-plan-profile matrix-v2-legacy-v1 --generation N --json", + ] { + assert!(text.contains(command), "{path} missing {command}"); + } + } + for (path, snippets) in [ + ( + "docs/RECEIPT_SPEC.md", + &[ + "0.1.0+matrix-v2-legacy-v1", + "outer Matrix schema 2.0", + "inner runtime schema 1.0", + "never from a completed receipt", + ] as &[&str], + ), + ( + "docs/MULTI_RUNTIME_RECEIPTS.md", + &[ + "outer-v2", + "inner-v1", + "producer suffix", + "historical verifier", + ], + ), + ( + "docs/GITHUB_GATE.md", + &["append-once", "`verify` has no profile flag", "current-v2"], + ), ] { - assert!(corpus.to_lowercase().contains(&required.to_lowercase()), "missing documentation contract: {required}"); + let text = fs::read_to_string(root.join(path)).expect("read contract docs"); + for snippet in snippets { + assert!(text.contains(snippet), "{path} missing {snippet}"); + } } - let source = fs::read_dir(root.join("src")).expect("read src"); - let source_text = source - .filter_map(Result::ok) - .filter(|entry| entry.path().extension().and_then(|v| v.to_str()) == Some("rs")) - .map(|entry| fs::read_to_string(entry.path()).expect("read source")) - .collect::>() - .join("\n"); - for forbidden in ["latent-adopter", "golden digest", "golden_digest"] { - assert!(!source_text.to_lowercase().contains(forbidden), "forbidden production digest constant: {forbidden}"); + let mut sources = Vec::new(); + collect_rust_sources(&root.join("src"), &mut sources); + for path in sources { + let text = fs::read_to_string(path).expect("read source"); + for digest in [ + "25b35b942a6ff9b6237ebed7cefbdbc96b968bbe8954a38b606942f36b8df4b2", + "b3d8beef1542566d9d925bfee77d2244995dc74adcd879128ef65e82ed1d354b", + "d446c4ca0602c09eee61c796ad2972f58ab0eebe84a39f928fd90aac5bfb535c", + "13f4cb39b7e1a8ed31cae64502cc8e4d80d040230d3fb410a6afc3bad3b76178", + "eff5b7d55bb0220890dbfb050bb68a1e0fbba8f9a30a69e2f66085354fcc8562", + "7afb3e6dd435d9d5a317e4d9d85e80527431044312bbe299e9a70b6ba9e994c8", + ] { + assert!( + !text.contains(digest), + "production source embeds adopter digest {digest}" + ); + } + } +} + +fn collect_rust_sources(dir: &Path, output: &mut Vec) { + for entry in fs::read_dir(dir).expect("read src").flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rust_sources(&path, output); + } else if path.extension().and_then(|v| v.to_str()) == Some("rs") { + output.push(path); + } } } From ef2eaadc9aee1ad3440d88105a83123fffd6ee9b Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 04:15:50 +0200 Subject: [PATCH 19/33] docs: complete Matrix V2 evidence contract --- CHANGELOG.md | 2 ++ docs/ADOPTION_GUIDE.md | 2 ++ docs/CACHE_AND_WORKSPACE.md | 2 ++ docs/INVARIANT_EVIDENCE_MATRIX.md | 16 +++++++------- docs/TESTING_AND_FAULT_INJECTION.md | 22 ++++++++++++++----- docs/TROUBLESHOOTING.md | 2 ++ tests/repository_hygiene_contract.rs | 33 ++++++++++++++++++++++++++++ 7 files changed, 66 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99956fd..06e6638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Semantic Versioning after its first public release. exactly-once admission release, and fail-closed release-error precedence. Deterministic coverage includes watchdog, journal, and cache-pin ordering; native guard-exec qualification remains separate and NOT_RUN. +- Documented the Matrix-only `matrix-v2-legacy-v1` compatibility profile; + default operation remains `current-v2`. - Matrix v2 `doctor` and `dry-run` now inspect every declared runtime using lexically ordered, digest-labeled reports. Doctor performs one bounded runtime probe per entry; dry-run renders each explicit workspace and command diff --git a/docs/ADOPTION_GUIDE.md b/docs/ADOPTION_GUIDE.md index fd04ed7..eb54cdf 100644 --- a/docs/ADOPTION_GUIDE.md +++ b/docs/ADOPTION_GUIDE.md @@ -331,6 +331,8 @@ remote jobs. Preserve receipts and configurations for incident analysis. See - [ ] Rollback path is documented and tested. ## Matrix V2 legacy adoption boundary +This boundary does not perform policy inference; it records Matrix-only evidence. + Adopters using the Matrix-only `matrix-v2-legacy-v1` profile must preserve the producer suffix and command parity (`plan`, `doctor`, `dry-run`, `run`). The operator copies reviewed digests into Matrix policy v2, never from a completed diff --git a/docs/CACHE_AND_WORKSPACE.md b/docs/CACHE_AND_WORKSPACE.md index 45f9d9c..9beaa6e 100644 --- a/docs/CACHE_AND_WORKSPACE.md +++ b/docs/CACHE_AND_WORKSPACE.md @@ -206,6 +206,8 @@ Pinning does not initialize, repair, delete, quarantine, publish, or attest cache contents. ## Compatibility cache boundary +This Matrix-only boundary does not perform policy inference. + The Matrix-only `matrix-v2-legacy-v1` profile has a separate legacy cache namespace from current-v2. Preserve command parity for `plan`, `doctor`, `dry-run`, and `run`; `verify` has no profile flag. Cache identity and the diff --git a/docs/INVARIANT_EVIDENCE_MATRIX.md b/docs/INVARIANT_EVIDENCE_MATRIX.md index fafd652..b1d3a7e 100644 --- a/docs/INVARIANT_EVIDENCE_MATRIX.md +++ b/docs/INVARIANT_EVIDENCE_MATRIX.md @@ -43,14 +43,14 @@ crash/power-loss and release qualification remain separate gates. | Invariant | Focused evidence | |---|---| -| Projection reproducibility | projection contract test | -| Representability rejection | representability test | -| Command parity | profile CLI contract test | -| Cache separation | cache identity test | -| Producer uniformity | producer suffix test | -| Historical verifier acceptance | compatibility verifier test | -| Mutation rejection | mutation-boundary test | -| Zero pre-admission mutation | admission journal test | +| Projection reproducibility | `tests/matrix_contract.rs::legacy_profile_projection_matches_historical_fixture` | +| Representability rejection | `tests/matrix_contract.rs::legacy_profile_rejects_each_non_representable_current_field` | +| Command parity | `tests/runtime_cli.rs::legacy_profile_uses_distinct_plan_cache_identity` | +| Cache separation | `tests/runtime_cli.rs::legacy_profile_uses_distinct_plan_cache_identity` | +| Producer uniformity | `tests/receipt_contract.rs::explicit_producer_version_is_sealed` | +| Historical verifier acceptance | `tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations` (`#[ignore]`, `--ignored`, external verifier required) | +| Mutation rejection | `tests/verification_contract.rs::current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations` | +| Zero pre-admission mutation | `tests/runtime_cli.rs::remote_secret_only_run_fails_before_cache_or_admission_setup` | All entries are Matrix-only evidence for `matrix-v2-legacy-v1`; they do not infer policy or establish general trust. Rollback target is `current-v2`. diff --git a/docs/TESTING_AND_FAULT_INJECTION.md b/docs/TESTING_AND_FAULT_INJECTION.md index 911e6cc..66a0764 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -149,8 +149,20 @@ trusted configuration validation. operator's real cache or repository state. ## Matrix V2 compatibility test matrix -Focused tests cover projection reproducibility, representability rejection, -command parity, cache namespace separation, producer uniformity, historical -verifier acceptance, mutation rejection, and zero pre-admission mutation for -the Matrix-only `matrix-v2-legacy-v1` profile. `verify` has no profile flag. -Tests should not infer policy or general trust; rollback target is `current-v2`. +Focused tests cover the Matrix-only `matrix-v2-legacy-v1` profile: projection +`tests/matrix_contract.rs::legacy_profile_projection_matches_historical_fixture`, +representability `tests/matrix_contract.rs::legacy_profile_rejects_each_non_representable_current_field`, +command parity and cache identity +`tests/runtime_cli.rs::legacy_profile_uses_distinct_plan_cache_identity`, +producer uniformity `tests/receipt_contract.rs::explicit_producer_version_is_sealed`, +mutation rejection +`tests/verification_contract.rs::current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations`, +and zero pre-admission mutation +`tests/runtime_cli.rs::remote_secret_only_run_fails_before_cache_or_admission_setup`. +The historical verifier is +`tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations`, +marked `#[ignore]` and run with `--ignored` only when +`CCP_HISTORICAL_VERIFIER_044697` points to the provenance-pinned SHA +`044697dee9a0d678d30a4847d62ddf9b4970505b`; the ordinary suite does not prove +historical acceptance. `verify` has no profile flag. Tests do not infer policy +or general trust; rollback target is `current-v2`. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 856ce76..8bc86d4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -230,6 +230,8 @@ personal paths out of public issues. Security findings belong in the private channel described by [SECURITY.md](../SECURITY.md). ## Matrix V2 legacy troubleshooting +Troubleshooting is Matrix-only and does not perform policy inference. + For Matrix-only compatibility, confirm the exact `matrix-v2-legacy-v1` profile and the command parity sequence (`plan`, `doctor`, `dry-run`, `run`). Legacy and current cache namespaces must not be mixed. `verify` has no profile flag. Before diff --git a/tests/repository_hygiene_contract.rs b/tests/repository_hygiene_contract.rs index cae4a86..17ebbf8 100644 --- a/tests/repository_hygiene_contract.rs +++ b/tests/repository_hygiene_contract.rs @@ -67,6 +67,39 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { assert!(text.contains(snippet), "{path} missing {snippet}"); } } + for (path, snippets) in [ + ( + "docs/ADOPTION_GUIDE.md", + &["Matrix-only", "policy inference", "cache namespaces"] as &[&str], + ), + ( + "docs/CACHE_AND_WORKSPACE.md", + &["Matrix-only", "separate legacy cache", "policy inference"], + ), + ( + "docs/TROUBLESHOOTING.md", + &["Matrix-only", "policy migration", "policy inference"], + ), + ( + "docs/INVARIANT_EVIDENCE_MATRIX.md", + &[ + "tests/matrix_contract.rs::legacy_profile_projection_matches_historical_fixture", + "current-v2", + ], + ), + ( + "docs/TESTING_AND_FAULT_INJECTION.md", + &[ + "CCP_HISTORICAL_VERIFIER_044697", + "ordinary suite does not prove", + ], + ), + ] { + let text = fs::read_to_string(root.join(path)).expect("read boundary docs"); + for snippet in snippets { + assert!(text.contains(snippet), "{path} missing {snippet}"); + } + } let mut sources = Vec::new(); collect_rust_sources(&root.join("src"), &mut sources); for path in sources { From 496fb07e7c2c4a079bd920bc3a8788c137cfbef2 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 04:26:43 +0200 Subject: [PATCH 20/33] docs: align Matrix evidence references --- docs/INVARIANT_EVIDENCE_MATRIX.md | 10 +++--- docs/TESTING_AND_FAULT_INJECTION.md | 4 +-- tests/plan_cli.rs | 43 ++++++++++++++++---------- tests/repository_hygiene_contract.rs | 46 +++++++++++++++++++++++++++- 4 files changed, 79 insertions(+), 24 deletions(-) diff --git a/docs/INVARIANT_EVIDENCE_MATRIX.md b/docs/INVARIANT_EVIDENCE_MATRIX.md index b1d3a7e..b21c038 100644 --- a/docs/INVARIANT_EVIDENCE_MATRIX.md +++ b/docs/INVARIANT_EVIDENCE_MATRIX.md @@ -43,14 +43,14 @@ crash/power-loss and release qualification remain separate gates. | Invariant | Focused evidence | |---|---| -| Projection reproducibility | `tests/matrix_contract.rs::legacy_profile_projection_matches_historical_fixture` | +| Projection reproducibility | `tests/matrix_contract.rs::legacy_profile_reproduces_historical_plan` | | Representability rejection | `tests/matrix_contract.rs::legacy_profile_rejects_each_non_representable_current_field` | -| Command parity | `tests/runtime_cli.rs::legacy_profile_uses_distinct_plan_cache_identity` | +| Command parity | `tests/plan_cli.rs::matrix_plan_profile_flag_is_exposed_only_by_configuration_commands` | | Cache separation | `tests/runtime_cli.rs::legacy_profile_uses_distinct_plan_cache_identity` | -| Producer uniformity | `tests/receipt_contract.rs::explicit_producer_version_is_sealed` | +| Producer uniformity | `tests/matrix_contract.rs::legacy_receipt_provenance_is_uniform` | | Historical verifier acceptance | `tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations` (`#[ignore]`, `--ignored`, external verifier required) | -| Mutation rejection | `tests/verification_contract.rs::current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations` | -| Zero pre-admission mutation | `tests/runtime_cli.rs::remote_secret_only_run_fails_before_cache_or_admission_setup` | +| Mutation rejection | `tests/verification_contract.rs::current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations` and historical verifier test | +| Zero pre-admission mutation | `tests/runtime_cli.rs::legacy_profile_rejection_precedes_shared_state` and `tests/runtime_cli.rs::legacy_profile_rejects_current_only_matrix_syntax_before_shared_state` | All entries are Matrix-only evidence for `matrix-v2-legacy-v1`; they do not infer policy or establish general trust. Rollback target is `current-v2`. diff --git a/docs/TESTING_AND_FAULT_INJECTION.md b/docs/TESTING_AND_FAULT_INJECTION.md index 66a0764..07abf93 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -150,11 +150,11 @@ trusted configuration validation. ## Matrix V2 compatibility test matrix Focused tests cover the Matrix-only `matrix-v2-legacy-v1` profile: projection -`tests/matrix_contract.rs::legacy_profile_projection_matches_historical_fixture`, +`tests/matrix_contract.rs::legacy_profile_reproduces_historical_plan`, representability `tests/matrix_contract.rs::legacy_profile_rejects_each_non_representable_current_field`, command parity and cache identity `tests/runtime_cli.rs::legacy_profile_uses_distinct_plan_cache_identity`, -producer uniformity `tests/receipt_contract.rs::explicit_producer_version_is_sealed`, +producer uniformity `tests/matrix_contract.rs::legacy_receipt_provenance_is_uniform`, mutation rejection `tests/verification_contract.rs::current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations`, and zero pre-admission mutation diff --git a/tests/plan_cli.rs b/tests/plan_cli.rs index 5ae6acb..29ca0d3 100644 --- a/tests/plan_cli.rs +++ b/tests/plan_cli.rs @@ -24,6 +24,23 @@ const PINNED_SCHEMA: &str = include_str!("../schema/config-v1.schema.json"); const CURRENT_V2_PLAN_STDOUT: &[u8] = include_bytes!("fixtures/plan-v2-current-default.stdout.json"); +#[test] +fn matrix_plan_profile_flag_is_exposed_only_by_configuration_commands() { + let binary = env!("CARGO_BIN_EXE_commit-ci-preflight"); + for command in ["plan", "doctor", "dry-run", "run"] { + let output = Command::new(binary) + .args([command, "--help"]) + .output() + .expect("help"); + assert!(String::from_utf8_lossy(&output.stdout).contains("--matrix-plan-profile")); + } + let output = Command::new(binary) + .args(["verify", "--help"]) + .output() + .expect("help"); + assert!(!String::from_utf8_lossy(&output.stdout).contains("--matrix-plan-profile")); +} + #[test] fn generated_configuration_schema_matches_pinned_bytes() { assert_eq!(config_schema_json().expect("config schema"), PINNED_SCHEMA); @@ -48,12 +65,10 @@ fn plan_json_is_read_only_and_machine_readable() { json["plan"]["checks"][0]["argv"][0], "this-command-does-not-exist-and-must-not-run" ); - assert!( - json["plan_digest"] - .as_str() - .expect("plan digest") - .starts_with("sha256:") - ); + assert!(json["plan_digest"] + .as_str() + .expect("plan digest") + .starts_with("sha256:")); } #[test] @@ -88,12 +103,10 @@ fn v2_plan_exposes_reviewable_per_runtime_digests_without_execution() { 2 ); for runtime in json["plan"]["runtimes"].as_array().expect("runtimes") { - assert!( - runtime["configuration_digest"] - .as_str() - .expect("configuration digest") - .starts_with("sha256:") - ); + assert!(runtime["configuration_digest"] + .as_str() + .expect("configuration digest") + .starts_with("sha256:")); } } @@ -208,10 +221,8 @@ fn legacy_matrix_profile_rejects_single_runtime_configuration_with_usage_exit_co assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("matrix plan profile requires schema version 2.0") - ); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("matrix plan profile requires schema version 2.0")); } #[test] diff --git a/tests/repository_hygiene_contract.rs b/tests/repository_hygiene_contract.rs index 17ebbf8..29af3bc 100644 --- a/tests/repository_hygiene_contract.rs +++ b/tests/repository_hygiene_contract.rs @@ -67,6 +67,50 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { assert!(text.contains(snippet), "{path} missing {snippet}"); } } + for (path, name) in [ + ( + "tests/matrix_contract.rs", + "legacy_profile_reproduces_historical_plan", + ), + ( + "tests/matrix_contract.rs", + "legacy_profile_rejects_each_non_representable_current_field", + ), + ( + "tests/matrix_contract.rs", + "legacy_receipt_provenance_is_uniform", + ), + ( + "tests/plan_cli.rs", + "matrix_plan_profile_flag_is_exposed_only_by_configuration_commands", + ), + ( + "tests/runtime_cli.rs", + "legacy_profile_uses_distinct_plan_cache_identity", + ), + ( + "tests/runtime_cli.rs", + "legacy_profile_rejection_precedes_shared_state", + ), + ( + "tests/runtime_cli.rs", + "legacy_profile_rejects_current_only_matrix_syntax_before_shared_state", + ), + ( + "tests/verification_contract.rs", + "current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations", + ), + ( + "tests/verification_contract.rs", + "historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations", + ), + ] { + let source = fs::read_to_string(root.join(path)).expect("read referenced test"); + assert!( + source.contains(&format!("fn {name}")), + "missing referenced test {path}::{name}" + ); + } for (path, snippets) in [ ( "docs/ADOPTION_GUIDE.md", @@ -83,7 +127,7 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { ( "docs/INVARIANT_EVIDENCE_MATRIX.md", &[ - "tests/matrix_contract.rs::legacy_profile_projection_matches_historical_fixture", + "tests/matrix_contract.rs::legacy_profile_reproduces_historical_plan", "current-v2", ], ), From 9283a18b7fef1a9d3c4edca52c3caa1c4bd072ec Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 04:40:45 +0200 Subject: [PATCH 21/33] test: validate matrix evidence references --- docs/MULTI_RUNTIME_RECEIPTS.md | 6 +- docs/TESTING_AND_FAULT_INJECTION.md | 7 +- tests/repository_hygiene_contract.rs | 123 +++++++++++++++++---------- 3 files changed, 85 insertions(+), 51 deletions(-) diff --git a/docs/MULTI_RUNTIME_RECEIPTS.md b/docs/MULTI_RUNTIME_RECEIPTS.md index 1e05df7..7a0de12 100644 --- a/docs/MULTI_RUNTIME_RECEIPTS.md +++ b/docs/MULTI_RUNTIME_RECEIPTS.md @@ -166,8 +166,10 @@ generate_contract`. The v1 schema/fixture tests require historical v1 output to remain byte-for-byte stable. ## Matrix-only legacy compatibility -The historical verifier external test is `tests/verification_contract.rs::historical_matrix_verifier` -and requires `CCP_HISTORICAL_VERIFIER_044697`, provenance-pinned to commit +The historical verifier external test is +`tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations` +and is marked `#[ignore]`; run it with `--ignored` only when +`CCP_HISTORICAL_VERIFIER_044697` points to the provenance-pinned commit `044697dee9a0d678d30a4847d62ddf9b4970505b`. This is the outer-v2 / inner-v1 boundary: outer Matrix schema 2.0 and inner diff --git a/docs/TESTING_AND_FAULT_INJECTION.md b/docs/TESTING_AND_FAULT_INJECTION.md index 07abf93..c4cbdf5 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -152,13 +152,14 @@ trusted configuration validation. Focused tests cover the Matrix-only `matrix-v2-legacy-v1` profile: projection `tests/matrix_contract.rs::legacy_profile_reproduces_historical_plan`, representability `tests/matrix_contract.rs::legacy_profile_rejects_each_non_representable_current_field`, -command parity and cache identity -`tests/runtime_cli.rs::legacy_profile_uses_distinct_plan_cache_identity`, +command parity +`tests/plan_cli.rs::matrix_plan_profile_flag_is_exposed_only_by_configuration_commands`, producer uniformity `tests/matrix_contract.rs::legacy_receipt_provenance_is_uniform`, mutation rejection `tests/verification_contract.rs::current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations`, and zero pre-admission mutation -`tests/runtime_cli.rs::remote_secret_only_run_fails_before_cache_or_admission_setup`. +`tests/runtime_cli.rs::legacy_profile_rejection_precedes_shared_state` and +`tests/runtime_cli.rs::legacy_profile_rejects_current_only_matrix_syntax_before_shared_state`. The historical verifier is `tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations`, marked `#[ignore]` and run with `--ignored` only when diff --git a/tests/repository_hygiene_contract.rs b/tests/repository_hygiene_contract.rs index 29af3bc..3f855d1 100644 --- a/tests/repository_hygiene_contract.rs +++ b/tests/repository_hygiene_contract.rs @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{fs, path::Path}; +use std::{ + fs, + path::{Component, Path}, +}; use saphyr::{LoadableYamlNode, MappingOwned, YamlOwned}; @@ -55,6 +58,11 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { "inner-v1", "producer suffix", "historical verifier", + "tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations", + "#[ignore]", + "--ignored", + "CCP_HISTORICAL_VERIFIER_044697", + "provenance-pinned", ], ), ( @@ -67,50 +75,6 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { assert!(text.contains(snippet), "{path} missing {snippet}"); } } - for (path, name) in [ - ( - "tests/matrix_contract.rs", - "legacy_profile_reproduces_historical_plan", - ), - ( - "tests/matrix_contract.rs", - "legacy_profile_rejects_each_non_representable_current_field", - ), - ( - "tests/matrix_contract.rs", - "legacy_receipt_provenance_is_uniform", - ), - ( - "tests/plan_cli.rs", - "matrix_plan_profile_flag_is_exposed_only_by_configuration_commands", - ), - ( - "tests/runtime_cli.rs", - "legacy_profile_uses_distinct_plan_cache_identity", - ), - ( - "tests/runtime_cli.rs", - "legacy_profile_rejection_precedes_shared_state", - ), - ( - "tests/runtime_cli.rs", - "legacy_profile_rejects_current_only_matrix_syntax_before_shared_state", - ), - ( - "tests/verification_contract.rs", - "current_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations", - ), - ( - "tests/verification_contract.rs", - "historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations", - ), - ] { - let source = fs::read_to_string(root.join(path)).expect("read referenced test"); - assert!( - source.contains(&format!("fn {name}")), - "missing referenced test {path}::{name}" - ); - } for (path, snippets) in [ ( "docs/ADOPTION_GUIDE.md", @@ -127,7 +91,7 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { ( "docs/INVARIANT_EVIDENCE_MATRIX.md", &[ - "tests/matrix_contract.rs::legacy_profile_reproduces_historical_plan", + "tests/matrix_contract.rs::legacy_profile_reproduces_historical_plan", "current-v2", ], ), @@ -136,6 +100,9 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { &[ "CCP_HISTORICAL_VERIFIER_044697", "ordinary suite does not prove", + "tests/plan_cli.rs::matrix_plan_profile_flag_is_exposed_only_by_configuration_commands", + "tests/runtime_cli.rs::legacy_profile_rejection_precedes_shared_state", + "tests/runtime_cli.rs::legacy_profile_rejects_current_only_matrix_syntax_before_shared_state", ], ), ] { @@ -144,6 +111,13 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { assert!(text.contains(snippet), "{path} missing {snippet}"); } } + assert_documented_test_references_are_valid( + root, + [ + "docs/INVARIANT_EVIDENCE_MATRIX.md", + "docs/TESTING_AND_FAULT_INJECTION.md", + ], + ); let mut sources = Vec::new(); collect_rust_sources(&root.join("src"), &mut sources); for path in sources { @@ -164,6 +138,63 @@ fn matrix_legacy_profile_is_documented_without_production_digest_constants() { } } +fn assert_documented_test_references_are_valid<'a>( + root: &Path, + documents: impl IntoIterator, +) { + for document in documents { + let text = fs::read_to_string(root.join(document)).expect("read evidence document"); + for reference in backticked_test_references(&text) { + assert_documented_test_reference_is_safe_and_defined(root, document, reference); + } + } +} + +fn backticked_test_references(document: &str) -> impl Iterator { + document + .split('`') + .enumerate() + .filter_map(|(index, reference)| { + (index % 2 == 1 && reference.starts_with("tests/") && reference.contains(".rs::")) + .then_some(reference) + }) +} + +fn assert_documented_test_reference_is_safe_and_defined( + root: &Path, + document: &str, + reference: &str, +) { + let (relative_path, name) = reference + .split_once("::") + .unwrap_or_else(|| panic!("{document} has invalid test reference {reference}")); + assert!( + !name.is_empty() + && name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()), + "{document} has unsafe test function name {name}" + ); + + let path = Path::new(relative_path); + let components: Vec<_> = path.components().collect(); + assert!( + matches!( + components.as_slice(), + [Component::Normal(directory), Component::Normal(file)] + if *directory == "tests" && file.to_string_lossy().ends_with(".rs") + ), + "{document} has unsafe or non-tests path {relative_path}" + ); + + let source = fs::read_to_string(root.join(path)) + .unwrap_or_else(|_| panic!("{document} references missing test source {relative_path}")); + assert!( + source.contains(&format!("fn {name}(")), + "{document} references missing test {reference}" + ); +} + fn collect_rust_sources(dir: &Path, output: &mut Vec) { for entry in fs::read_dir(dir).expect("read src").flatten() { let path = entry.path(); From a4bbc761789662710d3d099d0d9be78fcee5b1e8 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 04:43:31 +0200 Subject: [PATCH 22/33] style: format plan CLI tests --- tests/plan_cli.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/plan_cli.rs b/tests/plan_cli.rs index 29ca0d3..f0b383b 100644 --- a/tests/plan_cli.rs +++ b/tests/plan_cli.rs @@ -65,10 +65,12 @@ fn plan_json_is_read_only_and_machine_readable() { json["plan"]["checks"][0]["argv"][0], "this-command-does-not-exist-and-must-not-run" ); - assert!(json["plan_digest"] - .as_str() - .expect("plan digest") - .starts_with("sha256:")); + assert!( + json["plan_digest"] + .as_str() + .expect("plan digest") + .starts_with("sha256:") + ); } #[test] @@ -103,10 +105,12 @@ fn v2_plan_exposes_reviewable_per_runtime_digests_without_execution() { 2 ); for runtime in json["plan"]["runtimes"].as_array().expect("runtimes") { - assert!(runtime["configuration_digest"] - .as_str() - .expect("configuration digest") - .starts_with("sha256:")); + assert!( + runtime["configuration_digest"] + .as_str() + .expect("configuration digest") + .starts_with("sha256:") + ); } } @@ -221,8 +225,10 @@ fn legacy_matrix_profile_rejects_single_runtime_configuration_with_usage_exit_co assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); - assert!(String::from_utf8_lossy(&output.stderr) - .contains("matrix plan profile requires schema version 2.0")); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("matrix plan profile requires schema version 2.0") + ); } #[test] From b04a97ee8190491f15ec9a67bab684f990e2a45a Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 04:59:31 +0200 Subject: [PATCH 23/33] test: satisfy strict matrix contract Clippy gate --- tests/matrix_contract.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index cfd82a6..1a3eeae 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -32,6 +32,7 @@ const CONFIG_SCHEMA: &str = include_str!("../schema/config-v2.schema.json"); const RECEIPT_SCHEMA: &str = include_str!("../schema/receipt-v2.schema.json"); const POLICY_SCHEMA: &str = include_str!("../schema/policy-v2.schema.json"); const LEGACY_COMPATIBLE_POLICY: &str = include_str!("fixtures/policy-v2-legacy-compatible.toml"); +type MatrixEnvelopeMutator = fn(&mut MatrixPlanEnvelopeV2); fn runtime_receipt(id: &str, image: &str, check_id: &str) -> ReceiptEnvelopeV1 { let digest = image.rsplit_once('@').expect("pinned image").1.to_owned(); @@ -483,7 +484,7 @@ fn legacy_runtime_envelopes_recheck_projection() { assert_ne!(legacy_runtime.plan_digest, current_runtime.plan_digest); } - let cases: [(&str, fn(&mut MatrixPlanEnvelopeV2)); 4] = [ + let cases: [(&str, MatrixEnvelopeMutator); 4] = [ ("runtime", |envelope| { envelope.plan.runtimes[0].runtime.image = "example.invalid/mutated@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(); }), @@ -515,7 +516,7 @@ fn legacy_runtime_envelopes_recheck_projection() { #[test] fn legacy_profile_rejects_each_non_representable_current_field() { - let cases: [(&str, fn(&mut MatrixPlanEnvelopeV2)); 6] = + let cases: [(&str, MatrixEnvelopeMutator); 6] = [ ("runtime.pull_policy", |envelope| { envelope.plan.runtimes[0].runtime.pull_policy = Some(RuntimePullPolicy::Never); From 73439faf38804cd8f9c26437e7c4673b04fa4619 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 05:47:32 +0200 Subject: [PATCH 24/33] fix: close Matrix legacy final review findings --- docs/MULTI_RUNTIME_RECEIPTS.md | 4 +- docs/TESTING_AND_FAULT_INJECTION.md | 9 +- ...25-matrix-v2-legacy-plan-profile-design.md | 12 +- src/main.rs | 124 ++++++- src/matrix.rs | 313 +++++++++++++++++- src/matrix_legacy.rs | 214 ++++++++---- tests/matrix_contract.rs | 73 ++++ tests/verification_contract.rs | 146 ++++---- 8 files changed, 755 insertions(+), 140 deletions(-) diff --git a/docs/MULTI_RUNTIME_RECEIPTS.md b/docs/MULTI_RUNTIME_RECEIPTS.md index 7a0de12..138817f 100644 --- a/docs/MULTI_RUNTIME_RECEIPTS.md +++ b/docs/MULTI_RUNTIME_RECEIPTS.md @@ -169,7 +169,9 @@ to remain byte-for-byte stable. The historical verifier external test is `tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations` and is marked `#[ignore]`; run it with `--ignored` only when -`CCP_HISTORICAL_VERIFIER_044697` points to the provenance-pinned commit +`CCP_HISTORICAL_VERIFIER_044697` points to the retained binary with SHA-256 +`5321ff4d291ec24db6a7a5919bc08fc00a9d63767b630a3469fc39318c400277`, +built from provenance-pinned commit `044697dee9a0d678d30a4847d62ddf9b4970505b`. This is the outer-v2 / inner-v1 boundary: outer Matrix schema 2.0 and inner diff --git a/docs/TESTING_AND_FAULT_INJECTION.md b/docs/TESTING_AND_FAULT_INJECTION.md index c4cbdf5..173dc0c 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -163,7 +163,8 @@ and zero pre-admission mutation The historical verifier is `tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations`, marked `#[ignore]` and run with `--ignored` only when -`CCP_HISTORICAL_VERIFIER_044697` points to the provenance-pinned SHA -`044697dee9a0d678d30a4847d62ddf9b4970505b`; the ordinary suite does not prove -historical acceptance. `verify` has no profile flag. Tests do not infer policy -or general trust; rollback target is `current-v2`. +`CCP_HISTORICAL_VERIFIER_044697` points to the retained verifier binary with +SHA-256 `5321ff4d291ec24db6a7a5919bc08fc00a9d63767b630a3469fc39318c400277`, +The ordinary suite does not prove historical acceptance. The retained binary +was built from commit `044697dee9a0d678d30a4847d62ddf9b4970505b`. `verify` has no profile flag. +Tests do not infer policy or general trust; rollback target is `current-v2`. diff --git a/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md b/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md index f91581a..c884625 100644 --- a/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md +++ b/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md @@ -298,10 +298,20 @@ fixture provenance: - source commit: `044697dee9a0d678d30a4847d62ddf9b4970505b`; - source tree: `5220164edf17831ce0c42dae1c14300ed1045015`; -- isolated binary SHA-256: +- preliminary isolated-reproduction binary SHA-256: `71d64cdbb1bb509bb459aebd6c53e06d819150de42be4fe3715c35bd73426af7`; - exact plan command and raw canonical output. +Three separately built binaries from that exact commit/tree have distinct +evidence roles and are not interchangeable. The `71d64c...` build was the +preliminary isolated reproduction and was not the checked-in fixture generator. +Task 1 generated the checked-in fixture with the non-retained `6372674cff4b7f8bef6272403f1493cbc88228f031759d588aa532589cde0106` +build recorded in `matrix-v2-legacy-plan-044697.provenance.json`. The later +retained verifier used for exact replay is +`5321ff4d291ec24db6a7a5919bc08fc00a9d63767b630a3469fc39318c400277`, +recorded in `historical-verifier-044697.provenance.json`. Reproducible plan +bytes across these builds do not make their executable hashes identical. + The fixture is not Latent-TRIZ-specific. A separate adopter regression may use the public Latent-TRIZ configuration and trusted digests as end-to-end evidence. diff --git a/src/main.rs b/src/main.rs index 7ee0bda..2cca73a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1248,6 +1248,16 @@ fn config_schema_version(path: &Path) -> Result, CliError> { .map(str::to_owned)) } +fn after_validated_matrix_profile_binding( + envelope: &commit_ci_preflight::matrix::MatrixPlanEnvelopeV2, + operation: impl FnOnce() -> Result, +) -> Result { + envelope + .validate_profile_binding() + .map_err(CliError::Matrix)?; + operation() +} + fn print_matrix_run( path: &Path, profile: MatrixPlanProfile, @@ -1257,10 +1267,11 @@ fn print_matrix_run( json: bool, ) -> Result<(), CliError> { let envelope = load_matrix_plan(path, profile)?; - let root = resolve_cache_root(location)?; + let root = after_validated_matrix_profile_binding(&envelope, || resolve_cache_root(location))?; + let plan_digest = envelope.plan_digest().map_err(CliError::Matrix)?.to_owned(); let cache = ManagedCache::initialize(root).map_err(CliError::Cache)?; let journal = RunJournalStore::initialize(&cache.root().path).map_err(CliError::RunJournal)?; - let journal_id = new_journal_id(&envelope.plan_digest, generation)?; + let journal_id = new_journal_id(&plan_digest, generation)?; let journal_clock = SystemClock; journal .create_run( @@ -1276,6 +1287,73 @@ fn print_matrix_run( let supervisor = Arc::new(ProcessSupervisor::standard()); let cancellation = CancellationToken::default(); install_cancellation_handler(&cancellation)?; + let source_identity = RunIdentity { + project: envelope.plan.project.clone(), + commit: None, + config_digest: plan_digest, + generation: generation.to_string(), + }; + let source_generation = GenerationGuard::new(source_identity.clone()); + let commit = match resolve_clean_head( + &location.repository, + &envelope.plan.receipt.output, + supervisor.as_ref(), + &cancellation, + &source_generation, + &source_identity, + ) { + Ok(commit) => commit, + Err(error) => { + lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::Run(RunError::SourceSnapshot(error))); + } + }; + let source_identity = RunIdentity { + commit: Some(commit.clone()), + ..source_identity + }; + source_generation + .replace(source_identity.clone()) + .map_err(|error| CliError::Run(RunError::Process(error)))?; + let source_resource = match journal.reserve_resource(&journal_id, "source-snapshot-v1") { + Ok(path) => path, + Err(error) => { + lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::RunJournal(error)); + } + }; + let mut source_snapshot = match SourceSnapshot::materialize( + &location.repository, + &commit, + &source_resource, + supervisor.as_ref(), + &cancellation, + &source_generation, + &source_identity, + ) { + Ok(snapshot) => snapshot, + Err(error) => { + lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::Run(RunError::SourceSnapshot(error))); + } + }; + if let Err(error) = envelope.prepare_source_snapshot_overlay(&mut source_snapshot) { + lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::Matrix(error)); + } + if let Err(error) = journal.bind_source( + &journal_id, + &commit, + &source_snapshot.evidence().manifest_digest, + source_snapshot.evidence().entry_count, + ) { + lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::RunJournal(error)); + } + if let Err(error) = envelope.validate_profile_binding() { + lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::Matrix(error)); + } let admission = AdmissionCoordinator::platform_for(&location.repository).map_err(CliError::Admission)?; let guard = match admission.acquire( @@ -1343,6 +1421,7 @@ fn print_matrix_run( repository: &location.repository, cache: &cache, generation, + source_snapshot: &source_snapshot, }, supervisor.as_ref(), &cancellation, @@ -1368,6 +1447,10 @@ fn print_matrix_run( } }, )?; + if let Err(error) = source_snapshot.cleanup() { + lifecycle.transition_state(RunJournalStateV1::CleanupPending, None)?; + return Err(CliError::Run(RunError::SourceSnapshot(error))); + } lifecycle .transition(RunLifecyclePhase::Finalizing) .map_err(CliError::Run)?; @@ -2737,9 +2820,10 @@ mod tests { use super::{ Cli, CliError, GuardCommand, GuardExecArgs, GuardExecError, ResourceCacheStateArg, ResourceExecutionModeArg, ResourceExecutorArg, RunTerminalJournalEvent, - WatchdogCompletionBarrier, detect_resource_executor, finalize_benchmark_terminal, - finalize_guard_exec_result, finalize_run_terminal, new_journal_id, - reconcile_watchdog_outcome, resource_run_outcome, resource_terminal_detail, + WatchdogCompletionBarrier, after_validated_matrix_profile_binding, + detect_resource_executor, finalize_benchmark_terminal, finalize_guard_exec_result, + finalize_run_terminal, new_journal_id, reconcile_watchdog_outcome, + resource_run_outcome, resource_terminal_detail, }; use clap::{CommandFactory, Parser}; use commit_ci_preflight::admission::AdmissionError; @@ -2747,6 +2831,9 @@ mod tests { CacheError, CacheKey, CacheRootSource, ManagedCache, ResolvedCacheRoot, }; use commit_ci_preflight::config::ConfigV1; + use commit_ci_preflight::matrix::{ + MatrixConfigV2, MatrixError, MatrixPlanProfile, build_matrix_plan, + }; use commit_ci_preflight::process::CancellationToken; use commit_ci_preflight::process::{ CleanupStatus, ExitOutcome, ProcessResult, ProcessTermination, RunIdentity, @@ -2969,6 +3056,33 @@ timeout_seconds = 60 ); } + #[test] + fn mutated_matrix_profile_binding_stops_before_all_setup_effects() { + let source = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/config-v2-legacy-compatible.toml"), + ) + .expect("legacy fixture"); + let mut envelope = build_matrix_plan( + MatrixConfigV2::parse(&source).expect("matrix config"), + MatrixPlanProfile::LegacyV1, + ) + .expect("matrix plan"); + envelope.plan.project = "example/mutated-before-effects".to_owned(); + let effects = AtomicUsize::new(0); + + let result = after_validated_matrix_profile_binding(&envelope, || { + effects.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + + assert!(matches!( + result, + Err(CliError::Matrix(MatrixError::PlanDigestMismatch)) + )); + assert_eq!(effects.load(Ordering::SeqCst), 0); + } + #[test] fn guard_exec_requires_double_dash_and_program() { let cli = Cli::try_parse_from([ diff --git a/src/matrix.rs b/src/matrix.rs index 6f3c40a..0bbe961 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -45,6 +45,7 @@ use crate::run::{ write_canonical_receipt_bytes_atomic, }; use crate::runtime::runtime_for; +use crate::source_snapshot::SourceSnapshot; use crate::verify::{ AcceptedPlatformV1, VerificationDecision, VerificationFindingV1, VerificationPolicyV1, VerificationReportV1, VerificationStatus, finding, parse_utc_seconds, validate_commit, @@ -378,7 +379,7 @@ impl MatrixPlanEnvelopeV2 { } } - fn validate_profile_binding(&self) -> Result<(), MatrixError> { + pub fn validate_profile_binding(&self) -> Result<(), MatrixError> { match self.profile { MatrixPlanProfile::CurrentV2 => { if self.legacy_basis.is_some() @@ -449,6 +450,36 @@ impl MatrixPlanEnvelopeV2 { } Ok(result) } + + /// Prepare the one source-snapshot overlay shared by every Matrix runtime. + /// Cache, environment, storage, and fixed-environment fields must be + /// identical because the overlay is materialized once before admission. + pub fn prepare_source_snapshot_overlay( + &self, + snapshot: &mut SourceSnapshot, + ) -> Result<(), MatrixError> { + self.validate_profile_binding()?; + let runtime_envelopes = self.runtime_envelopes()?; + let (_, first) = runtime_envelopes + .first() + .ok_or(MatrixError::InvalidReceipt)?; + let mut overlay = first.clone(); + overlay.plan.checks.clear(); + for (_, runtime) in runtime_envelopes { + if runtime.plan.caches != overlay.plan.caches + || runtime.plan.environment != overlay.plan.environment + || runtime.plan.storage != overlay.plan.storage + || runtime.fixed_environment != overlay.fixed_environment + { + return Err(MatrixError::PlanDigestMismatch); + } + overlay.plan.checks.extend(runtime.plan.checks); + } + snapshot + .prepare_mount_overlay(&overlay) + .map_err(RunError::SourceSnapshot) + .map_err(MatrixError::Run) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -465,6 +496,7 @@ pub struct MatrixRunRequestV2<'a> { pub repository: &'a Path, pub cache: &'a ManagedCache, pub generation: u64, + pub source_snapshot: &'a SourceSnapshot, } /// Execute every independently pinned runtime sequentially under one caller @@ -502,7 +534,7 @@ pub fn execute_matrix_run_v2( cache: request.cache, producer_version: request.envelope.profile().producer_version(), generation: request.generation, - source_snapshot: None, + source_snapshot: Some(request.source_snapshot), }, runtime.as_ref(), supervisor, @@ -1074,12 +1106,17 @@ fn equal( #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use crate::cache::{CacheRootOptions, PlatformFamily, ResolvedCacheRoot}; - use crate::process::{GenerationGuard, ProcessError, ProcessRequest, ProcessResult}; + use crate::process::{ + CapturedStream, CleanupStatus, ExitOutcome, GenerationGuard, ProcessError, ProcessRequest, + ProcessResult, ProcessTermination, RunIdentity, + }; use crate::receipt::{CheckEvidence, PlatformEvidence}; use crate::run::SystemClock; + use crate::source_snapshot::SourceSnapshot; const IMAGE_311: &str = "example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const IMAGE_312: &str = "example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -1259,6 +1296,250 @@ pids_limit = 16 } } + struct SnapshotMatrixSupervisor { + repository: PathBuf, + execution_roots: Mutex>, + containers: Mutex>>, + removals: AtomicU64, + } + + impl SnapshotMatrixSupervisor { + fn new(repository: PathBuf) -> Self { + Self { + repository, + execution_roots: Mutex::new(Vec::new()), + containers: Mutex::new(BTreeMap::new()), + removals: AtomicU64::new(0), + } + } + + fn completed(request: &ProcessRequest, stdout: Vec, success: bool) -> ProcessResult { + ProcessResult { + identity: request.identity.clone(), + termination: ProcessTermination::Completed, + cleanup: CleanupStatus::Verified, + exit: Some(ExitOutcome { + success, + code: Some(if success { 0 } else { 1 }), + }), + stdout: CapturedStream::from_captured(stdout, false), + stderr: CapturedStream::from_captured(Vec::new(), false), + elapsed_millis: 1, + } + } + + fn workspace_source(request: &ProcessRequest) -> Option { + request.argv.iter().find_map(|argument| { + let argument = argument.to_str()?; + let source = argument.strip_prefix("type=bind,src=")?; + let (source, target) = source.split_once(",dst=")?; + (target == "/workspace,readonly").then(|| PathBuf::from(source)) + }) + } + } + + impl SupervisorPort for SnapshotMatrixSupervisor { + fn execute( + &self, + request: &ProcessRequest, + _cancellation: &CancellationToken, + generation: &GenerationGuard, + ) -> Result { + generation.ensure_current(&request.identity)?; + if request.program == "git" { + let command = request.argv.first().and_then(|argument| argument.to_str()); + let stdout = match command { + Some("ls-tree") => { + format!("100644 blob {}\tREADME.md\0", "c".repeat(40)).into_bytes() + } + Some("cat-file") => b"snapshot source\n".to_vec(), + Some("hash-object") => format!("{}\n", "c".repeat(40)).into_bytes(), + Some("status") => Vec::new(), + Some("rev-parse") => format!("{}\n", "a".repeat(40)).into_bytes(), + _ => Vec::new(), + }; + return Ok(Self::completed(request, stdout, true)); + } + + let command = request.argv.first().and_then(|argument| argument.to_str()); + match command { + Some("info") => Ok(Self::completed( + request, + br#"{"ServerVersion":"29.4.0","OperatingSystem":"fixture","OSType":"linux","Name":"private"}"#.to_vec(), + true, + )), + Some("create") => { + let source = Self::workspace_source(request).expect("read-only workspace mount"); + assert_eq!( + fs::read(source.join("README.md")).expect("snapshot source"), + b"snapshot source\n" + ); + assert!( + !source.join("ignored-live.txt").exists(), + "live-only mutation leaked into snapshot execution root" + ); + self.execution_roots.lock().expect("execution roots").push(source); + let mut name = None; + let mut labels = BTreeMap::new(); + let mut arguments = request.argv.iter(); + while let Some(argument) = arguments.next() { + match argument.to_str() { + Some("--name") => name = arguments.next().and_then(|value| value.to_str()), + Some("--label") => { + if let Some((key, value)) = arguments + .next() + .and_then(|value| value.to_str()) + .and_then(|value| value.split_once('=')) + { + labels.insert(key.to_owned(), value.to_owned()); + } + } + _ => {} + } + } + self.containers + .lock() + .expect("containers") + .insert(name.expect("container name").to_owned(), labels); + Ok(Self::completed( + request, + format!("{}\n", "d".repeat(64)).into_bytes(), + true, + )) + } + Some("inspect") => { + let name = request + .argv + .last() + .and_then(|argument| argument.to_str()) + .expect("container name"); + if let Some(labels) = self + .containers + .lock() + .expect("containers") + .get(name) + .cloned() + { + Ok(Self::completed( + request, + serde_json::to_vec(&serde_json::json!({ + "Name": format!("/{name}"), + "Config": {"Labels": labels}, + "State": {"Status": "running"}, + })) + .expect("inspect JSON"), + true, + )) + } else { + Ok(Self::completed(request, b"No such container\n".to_vec(), false)) + } + } + Some("rm") => { + let name = request + .argv + .last() + .and_then(|argument| argument.to_str()) + .expect("container name"); + self.containers.lock().expect("containers").remove(name); + if self.removals.fetch_add(1, Ordering::SeqCst) == 0 { + fs::write(self.repository.join("ignored-live.txt"), b"live mutation\n") + .expect("mutate live repository between runtimes"); + } + Ok(Self::completed(request, Vec::new(), true)) + } + Some("wait") => Ok(Self::completed(request, b"0\n".to_vec(), true)), + Some("attach" | "start" | "stop" | "kill") => { + Ok(Self::completed(request, b"fixture\n".to_vec(), true)) + } + _ => Ok(Self::completed(request, b"fixture\n".to_vec(), true)), + } + } + } + + #[test] + fn matrix_runtimes_share_one_immutable_source_snapshot() { + let root = std::env::current_dir() + .expect("current directory") + .parent() + .expect("repository parent") + .join(format!( + ".ccp-matrix-source-snapshot-{}", + std::process::id() + )); + if root.exists() { + fs::remove_dir_all(&root).expect("clean fixture root"); + } + let repository = root.join("repository"); + fs::create_dir_all(&repository).expect("repository root"); + fs::write(repository.join("README.md"), b"live source\n").expect("live source"); + let cache = ManagedCache::initialize( + ResolvedCacheRoot::resolve( + &repository, + &CacheRootOptions { + explicit: Some(root.join("cache")), + environment: None, + home: None, + xdg_cache_home: None, + local_app_data: None, + platform: PlatformFamily::Unix, + }, + ) + .expect("cache root"), + ) + .expect("cache"); + let envelope = envelope(MatrixPlanProfile::LegacyV1); + let supervisor = SnapshotMatrixSupervisor::new(repository.clone()); + let commit = "a".repeat(40); + let identity = RunIdentity { + project: envelope.plan.project.clone(), + commit: Some(commit.clone()), + config_digest: envelope.plan_digest.clone(), + generation: "7".to_owned(), + }; + let generation = GenerationGuard::new(identity.clone()); + let mut snapshot = SourceSnapshot::materialize( + &repository, + &commit, + &root.join("source-snapshot"), + &supervisor, + &CancellationToken::default(), + &generation, + &identity, + ) + .expect("source snapshot"); + envelope + .prepare_source_snapshot_overlay(&mut snapshot) + .expect("matrix snapshot overlay"); + let mut barrier = NoopCompletionBarrier; + + let outcome = execute_matrix_run_v2( + &MatrixRunRequestV2 { + envelope: &envelope, + repository: &repository, + cache: &cache, + generation: 7, + source_snapshot: &snapshot, + }, + &supervisor, + &CancellationToken::default(), + &SystemClock, + &mut barrier, + ) + .expect("matrix run"); + + assert_eq!(outcome.receipt.receipt.runtime_receipts.len(), 2); + let roots = supervisor.execution_roots.lock().expect("execution roots"); + assert_eq!(roots.len(), 2); + assert_eq!(roots[0], roots[1]); + assert_eq!(roots[0], snapshot.root()); + assert!(repository.join("ignored-live.txt").is_file()); + assert!(!snapshot.root().join("ignored-live.txt").exists()); + drop(roots); + snapshot.cleanup().expect("snapshot cleanup"); + drop(cache); + fs::remove_dir_all(root).expect("remove fixture root"); + } + #[test] fn executor_rejects_tampered_legacy_plan_before_supervisor_execution() { let root = std::env::current_dir() @@ -1290,6 +1571,28 @@ pids_limit = 16 ) .expect("cache"); let mut envelope = envelope(MatrixPlanProfile::LegacyV1); + let snapshot_supervisor = SnapshotMatrixSupervisor::new(repository.clone()); + let commit = "a".repeat(40); + let identity = RunIdentity { + project: envelope.plan.project.clone(), + commit: Some(commit.clone()), + config_digest: envelope.plan_digest.clone(), + generation: "7".to_owned(), + }; + let generation = GenerationGuard::new(identity.clone()); + let mut snapshot = SourceSnapshot::materialize( + &repository, + &commit, + &root.join("source-snapshot"), + &snapshot_supervisor, + &CancellationToken::default(), + &generation, + &identity, + ) + .expect("source snapshot"); + envelope + .prepare_source_snapshot_overlay(&mut snapshot) + .expect("matrix snapshot overlay"); envelope.plan.project = "owner/tampered".to_owned(); let supervisor = CountingSupervisor::default(); let mut barrier = NoopCompletionBarrier; @@ -1300,6 +1603,7 @@ pids_limit = 16 repository: &repository, cache: &cache, generation: 7, + source_snapshot: &snapshot, }, &supervisor, &CancellationToken::default(), @@ -1309,6 +1613,7 @@ pids_limit = 16 assert!(matches!(result, Err(MatrixError::PlanDigestMismatch))); assert_eq!(supervisor.calls.load(Ordering::SeqCst), 0); + snapshot.cleanup().expect("snapshot cleanup"); drop(cache); fs::remove_dir_all(root).expect("remove fixture root"); } diff --git a/src/matrix_legacy.rs b/src/matrix_legacy.rs index c5b6fe5..7bdf7c3 100644 --- a/src/matrix_legacy.rs +++ b/src/matrix_legacy.rs @@ -16,8 +16,12 @@ use std::collections::BTreeMap; use serde::Serialize; -use crate::config::{NormalizedCache, NormalizedReceipt, NormalizedRuntime}; -use crate::matrix::{MatrixError, MatrixPlanV2}; +use crate::config::{ + NormalizedArtifactContract, NormalizedCache, NormalizedCheck, NormalizedEnvironment, + NormalizedFixedEnvironment, NormalizedReceipt, NormalizedRuntime, + NormalizedRuntimeInternalEnvironment, +}; +use crate::matrix::{MatrixError, MatrixPlanV2, MatrixRuntimePlanV2}; use crate::receipt::canonical_digest; #[derive(Debug, Clone, PartialEq, Eq)] @@ -48,9 +52,9 @@ struct LegacyExecutionPlanV1 { schema_version: String, project: String, runtime: LegacyNormalizedRuntime, - receipt: NormalizedReceipt, + receipt: LegacyNormalizedReceipt, environment_allow: Vec, - caches: Vec, + caches: Vec, checks: Vec, } @@ -58,9 +62,9 @@ struct LegacyExecutionPlanV1 { struct LegacyMatrixPlanV2 { schema_version: String, project: String, - receipt: NormalizedReceipt, + receipt: LegacyNormalizedReceipt, environment_allow: Vec, - caches: Vec, + caches: Vec, runtimes: Vec, } @@ -74,6 +78,18 @@ struct LegacyNormalizedRuntime { network: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedReceipt { + output: String, + freshness_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyNormalizedCache { + id: String, + mount_path: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct LegacyNormalizedCheck { id: String, @@ -104,62 +120,39 @@ pub(crate) fn project_legacy_basis( caches, runtimes, } = plan; - // Matrix V2 has no storage field today. This intentionally exhaustive - // destructuring makes any future field addition fail compilation until its - // legacy representability classification, including storage, is explicit. - for runtime in runtimes { - validate_runtime_representability(&runtime.runtime)?; - } - if !environment.fixed.is_empty() { - return Err(MatrixError::LegacyPlanNotRepresentable("environment.fixed")); - } - if !environment.runtime_internal.is_empty() { - return Err(MatrixError::LegacyPlanNotRepresentable( - "environment.runtime_internal", - )); - } - if !environment.remote_secret_only.is_empty() { - return Err(MatrixError::LegacyPlanNotRepresentable( - "environment.remote_secret_only", - )); - } + // Matrix V2 has no storage field today. Exhaustive destructuring at every + // current nested boundary makes a future field addition fail compilation + // until it is either represented in the historical shape or rejected. + let environment_allow = legacy_environment(environment)?; + let legacy_receipt = legacy_receipt(receipt); + let legacy_caches = caches.iter().map(legacy_cache).collect::>(); let mut runtime_digests = BTreeMap::new(); let mut legacy_runtimes = Vec::with_capacity(runtimes.len()); - for runtime in runtimes { - let checks = runtime - .checks + for runtime_plan in runtimes { + let MatrixRuntimePlanV2 { + id, + configuration_digest: _, + runtime, + checks, + } = runtime_plan; + let checks = checks .iter() - .map(|check| { - if !check.artifact_contracts.is_empty() { - return Err(MatrixError::LegacyPlanNotRepresentable( - "checks.artifact_contracts", - )); - } - Ok(LegacyNormalizedCheck { - id: check.id.clone(), - required: check.required, - argv: check.argv.clone(), - working_directory: check.working_directory.clone(), - timeout_seconds: check.timeout_seconds, - depends_on: check.depends_on.clone(), - artifacts: check.artifacts.clone(), - }) - }) + .map(legacy_check) .collect::, MatrixError>>()?; - let legacy_runtime = legacy_runtime(&runtime.runtime); + let legacy_runtime = legacy_runtime(runtime)?; let configuration_digest = canonical_digest(&LegacyExecutionPlanV1 { schema_version: "1.0".to_owned(), project: project.clone(), runtime: legacy_runtime.clone(), - receipt: receipt.clone(), - environment_allow: environment.inherit.clone(), - caches: caches.clone(), + receipt: legacy_receipt.clone(), + environment_allow: environment_allow.clone(), + caches: legacy_caches.clone(), checks: checks.clone(), }) .map_err(MatrixError::Receipt)?; - runtime_digests.insert(runtime.id.clone(), configuration_digest.clone()); + runtime_digests.insert(id.clone(), configuration_digest.clone()); legacy_runtimes.push(LegacyMatrixRuntimePlanV2 { - id: runtime.id.clone(), + id: id.clone(), configuration_digest, runtime: legacy_runtime, checks, @@ -170,34 +163,125 @@ pub(crate) fn project_legacy_basis( plan: LegacyMatrixPlanV2 { schema_version: schema_version.clone(), project: project.clone(), - receipt: receipt.clone(), - environment_allow: environment.inherit.clone(), - caches: caches.clone(), + receipt: legacy_receipt, + environment_allow, + caches: legacy_caches, runtimes: legacy_runtimes, }, runtime_digests, }) } -fn validate_runtime_representability(runtime: &NormalizedRuntime) -> Result<(), MatrixError> { - if runtime.pull_policy.is_some() { +fn legacy_environment(environment: &NormalizedEnvironment) -> Result, MatrixError> { + let NormalizedEnvironment { + inherit, + fixed, + runtime_internal, + remote_secret_only, + } = environment; + if let Some(binding) = fixed.first() { + let NormalizedFixedEnvironment { + name: _, + value_digest: _, + } = binding; + return Err(MatrixError::LegacyPlanNotRepresentable("environment.fixed")); + } + if let Some(binding) = runtime_internal.first() { + let NormalizedRuntimeInternalEnvironment { + name: _, + cache_id: _, + container_target: _, + } = binding; + return Err(MatrixError::LegacyPlanNotRepresentable( + "environment.runtime_internal", + )); + } + if !remote_secret_only.is_empty() { + return Err(MatrixError::LegacyPlanNotRepresentable( + "environment.remote_secret_only", + )); + } + Ok(inherit.clone()) +} + +fn legacy_receipt(receipt: &NormalizedReceipt) -> LegacyNormalizedReceipt { + let NormalizedReceipt { + output, + freshness_seconds, + } = receipt; + LegacyNormalizedReceipt { + output: output.clone(), + freshness_seconds: *freshness_seconds, + } +} + +fn legacy_cache(cache: &NormalizedCache) -> LegacyNormalizedCache { + let NormalizedCache { id, mount_path } = cache; + LegacyNormalizedCache { + id: id.clone(), + mount_path: mount_path.clone(), + } +} + +fn legacy_runtime(runtime: &NormalizedRuntime) -> Result { + let NormalizedRuntime { + kind, + image, + cpu_count, + memory_mib, + pids_limit, + network, + pull_policy, + swap_mode, + } = runtime; + if pull_policy.is_some() { return Err(MatrixError::LegacyPlanNotRepresentable( "runtime.pull_policy", )); } - if runtime.swap_mode.is_some() { + if swap_mode.is_some() { return Err(MatrixError::LegacyPlanNotRepresentable("runtime.swap_mode")); } - Ok(()) + Ok(LegacyNormalizedRuntime { + kind: *kind, + image: image.clone(), + cpu_count: *cpu_count, + memory_mib: *memory_mib, + pids_limit: *pids_limit, + network: *network, + }) } -fn legacy_runtime(runtime: &NormalizedRuntime) -> LegacyNormalizedRuntime { - LegacyNormalizedRuntime { - kind: runtime.kind, - image: runtime.image.clone(), - cpu_count: runtime.cpu_count, - memory_mib: runtime.memory_mib, - pids_limit: runtime.pids_limit, - network: runtime.network, +fn legacy_check(check: &NormalizedCheck) -> Result { + let NormalizedCheck { + id, + required, + argv, + working_directory, + timeout_seconds, + depends_on, + artifacts, + artifact_contracts, + } = check; + if let Some(contract) = artifact_contracts.first() { + let NormalizedArtifactContract { + path: _, + kind: _, + max_bytes: _, + max_entries: _, + producer_check: _, + } = contract; + return Err(MatrixError::LegacyPlanNotRepresentable( + "checks.artifact_contracts", + )); } + Ok(LegacyNormalizedCheck { + id: id.clone(), + required: *required, + argv: argv.clone(), + working_directory: working_directory.clone(), + timeout_seconds: *timeout_seconds, + depends_on: depends_on.clone(), + artifacts: artifacts.clone(), + }) } diff --git a/tests/matrix_contract.rs b/tests/matrix_contract.rs index 1a3eeae..dc02410 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -308,6 +308,61 @@ fn legacy_profile_reproduces_historical_plan() { )); } +#[test] +fn legacy_digest_basis_preserves_exact_historical_nested_json_shape() { + let envelope = build_matrix_plan( + MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"), + MatrixPlanProfile::LegacyV1, + ) + .expect("legacy plan"); + let basis = envelope + .legacy_digest_basis_value() + .expect("legacy basis") + .expect("legacy profile basis"); + let historical: Value = + serde_json::from_str(include_str!("fixtures/matrix-v2-legacy-plan-044697.json")) + .expect("historical plan JSON"); + + assert_eq!(basis, historical["plan"]); + assert_eq!( + basis["receipt"], + serde_json::json!({ + "output": ".ccp/receipt.json", + "freshness_seconds": 300 + }) + ); + assert_eq!( + basis["caches"], + serde_json::json!([{ + "id": "cargo", + "mount_path": ".cache/cargo" + }]) + ); + assert_eq!( + basis["runtimes"][0]["runtime"], + serde_json::json!({ + "kind": "docker_compatible", + "image": IMAGE_311, + "cpu_count": 1, + "memory_mib": 256, + "pids_limit": 64, + "network": false + }) + ); + assert_eq!( + basis["runtimes"][0]["checks"][0], + serde_json::json!({ + "id": "python311-version", + "required": true, + "argv": ["python", "-V"], + "working_directory": ".", + "timeout_seconds": 30, + "depends_on": [], + "artifacts": [] + }) + ); +} + #[test] fn legacy_compatible_policy_fixture_binds_historical_profile_digests() { let provenance: Value = serde_json::from_str(include_str!( @@ -456,6 +511,24 @@ fn legacy_profile_accessors_reject_mutated_public_plan() { )); } +#[test] +fn cli_boundary_can_validate_profile_binding_before_mutation() { + let mut envelope = build_matrix_plan( + MatrixConfigV2::parse(legacy_compatible_config()).expect("parse fixture"), + MatrixPlanProfile::LegacyV1, + ) + .expect("legacy plan"); + envelope + .validate_profile_binding() + .expect("fresh production envelope"); + + envelope.plan.project = "example/mutated-before-run".to_owned(); + assert!(matches!( + envelope.validate_profile_binding(), + Err(MatrixError::PlanDigestMismatch) + )); +} + #[test] fn legacy_runtime_envelopes_recheck_projection() { let legacy = build_matrix_plan( diff --git a/tests/verification_contract.rs b/tests/verification_contract.rs index 366cf07..74aac06 100644 --- a/tests/verification_contract.rs +++ b/tests/verification_contract.rs @@ -19,7 +19,8 @@ use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; use commit_ci_preflight::matrix::{ - MatrixReceiptEnvelopeV2, MatrixReceiptV2, MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, + MatrixConfigV2, MatrixError, MatrixPlanEnvelopeV2, MatrixPlanProfile, MatrixReceiptEnvelopeV2, + MatrixReceiptV2, MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, build_matrix_plan, verify_matrix_receipt_document, }; use commit_ci_preflight::receipt::{ @@ -54,15 +55,6 @@ const HISTORICAL_VERIFIER_PROVENANCE: &str = include_str!("fixtures/historical-verifier-044697.provenance.json"); const LEGACY_MATRIX_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const LEGACY_MATRIX_EVALUATED_AT: &str = "2026-08-16T10:01:00Z"; -const LEGACY_MATRIX_PRODUCER: &str = "0.1.0+matrix-v2-legacy-v1"; -const LEGACY_MATRIX_OUTER_DIGEST: &str = - "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13"; -const LEGACY_MATRIX_PY311_DIGEST: &str = - "sha256:755f77f6815b1ed7b4415b3312c48a6528e2d752270775916efa6c10f1ffe192"; -const LEGACY_MATRIX_PY312_DIGEST: &str = - "sha256:be2eb7d200946e9f1dc84cebd8c0cca8739e424163f91c86063bbe4caed936f9"; -const LEGACY_MATRIX_PY311_IMAGE: &str = "example.invalid/python311@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const LEGACY_MATRIX_PY312_IMAGE: &str = "example.invalid/python312@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); fn policy() -> VerificationPolicyV1 { @@ -111,6 +103,16 @@ fn legacy_matrix_policy() -> MatrixVerificationPolicyV2 { MatrixVerificationPolicyV2::parse(LEGACY_MATRIX_POLICY).expect("legacy Matrix policy") } +fn legacy_production_plan() -> MatrixPlanEnvelopeV2 { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/config-v2-legacy-compatible.toml"); + build_matrix_plan( + MatrixConfigV2::load(&path).expect("legacy Matrix config"), + MatrixPlanProfile::LegacyV1, + ) + .expect("legacy production plan") +} + #[test] fn retained_historical_verifier_provenance_is_separate_from_generator_provenance() { let generator: serde_json::Value = @@ -138,13 +140,34 @@ fn retained_historical_verifier_provenance_is_separate_from_generator_provenance assert_eq!(verifier["runtime_digests"], generator["runtime_digests"]); } +#[test] +fn historical_receipt_builder_rejects_mutated_production_projection() { + let mut plan = legacy_production_plan(); + plan.plan.runtimes[0].checks[0] + .argv + .push("--mutated".to_owned()); + + assert!(matches!( + legacy_matrix_receipt_from_production_plan(&plan), + Err(MatrixError::PlanDigestMismatch) + )); +} + fn legacy_runtime_receipt( + plan: &MatrixPlanEnvelopeV2, runtime_id: &str, - image_reference: &str, - configuration_digest: &str, - check_id: &str, -) -> ReceiptEnvelopeV1 { - let image_digest = image_reference +) -> Result { + plan.validate_profile_binding()?; + let runtime = plan + .plan + .runtimes + .iter() + .find(|runtime| runtime.id == runtime_id) + .ok_or_else(|| MatrixError::UnknownRuntime(runtime_id.to_owned()))?; + let configuration_digest = plan.runtime_configuration_digest(runtime_id)?.to_owned(); + let image_digest = runtime + .runtime + .image .rsplit_once('@') .expect("pinned image") .1 @@ -152,11 +175,11 @@ fn legacy_runtime_receipt( ReceiptEnvelopeV1::seal(ReceiptV1 { schema_version: "1.0".to_owned(), producer: ProducerEvidence { - name: "commit-ci-preflight".to_owned(), - version: LEGACY_MATRIX_PRODUCER.to_owned(), + name: env!("CARGO_PKG_NAME").to_owned(), + version: plan.profile().producer_version().to_owned(), }, repository: RepositoryEvidence { - repository: "example/legacy-matrix".to_owned(), + repository: plan.plan.project.clone(), commit_sha: LEGACY_MATRIX_COMMIT.to_owned(), dirty: false, }, @@ -171,39 +194,57 @@ fn legacy_runtime_receipt( host_arch: "aarch64".to_owned(), runtime_kind: "docker_compatible".to_owned(), runtime_version: "test".to_owned(), - image_reference: image_reference.to_owned(), + image_reference: runtime.runtime.image.clone(), image_digest, }, - configuration_digest: configuration_digest.to_owned(), - checks: vec![CheckEvidence { - id: check_id.to_owned(), - required: true, - argv: vec!["python".to_owned(), "-V".to_owned()], - working_directory: ".".to_owned(), - status: EvidenceStatus::Pass, - exit_code: Some(0), - duration_ms: 1, - timed_out: false, - cancelled: false, - output_digest: Some(configuration_digest.to_owned()), - incomplete_reason: None, - }], + configuration_digest: configuration_digest.clone(), + checks: runtime + .checks + .iter() + .map(|check| CheckEvidence { + id: check.id.clone(), + required: check.required, + argv: check.argv.clone(), + working_directory: check.working_directory.clone(), + status: EvidenceStatus::Pass, + exit_code: Some(0), + duration_ms: 1, + timed_out: false, + cancelled: false, + output_digest: Some(configuration_digest.clone()), + incomplete_reason: None, + }) + .collect(), overall_status: EvidenceStatus::Pass, incomplete_reason: None, redaction_policy_version: "1.0".to_owned(), }) - .expect("seal legacy runtime receipt") + .map_err(MatrixError::Receipt) } -fn legacy_matrix_receipt() -> MatrixReceiptEnvelopeV2 { +fn legacy_matrix_receipt_from_production_plan( + plan: &MatrixPlanEnvelopeV2, +) -> Result { + plan.validate_profile_binding()?; + let runtime_receipts = plan + .plan + .runtimes + .iter() + .map(|runtime| { + Ok(MatrixRuntimeReceiptV2 { + runtime_id: runtime.id.clone(), + receipt: legacy_runtime_receipt(plan, &runtime.id)?, + }) + }) + .collect::, MatrixError>>()?; MatrixReceiptEnvelopeV2::seal(MatrixReceiptV2 { schema_version: "2.0".to_owned(), producer: ProducerEvidence { - name: "commit-ci-preflight".to_owned(), - version: LEGACY_MATRIX_PRODUCER.to_owned(), + name: env!("CARGO_PKG_NAME").to_owned(), + version: plan.profile().producer_version().to_owned(), }, repository: RepositoryEvidence { - repository: "example/legacy-matrix".to_owned(), + repository: plan.plan.project.clone(), commit_sha: LEGACY_MATRIX_COMMIT.to_owned(), dirty: false, }, @@ -213,32 +254,17 @@ fn legacy_matrix_receipt() -> MatrixReceiptEnvelopeV2 { started_at_utc: "2026-08-16T10:00:00Z".to_owned(), finished_at_utc: "2026-08-16T10:00:02Z".to_owned(), }, - configuration_digest: LEGACY_MATRIX_OUTER_DIGEST.to_owned(), - runtime_receipts: vec![ - MatrixRuntimeReceiptV2 { - runtime_id: "python311".to_owned(), - receipt: legacy_runtime_receipt( - "python311", - LEGACY_MATRIX_PY311_IMAGE, - LEGACY_MATRIX_PY311_DIGEST, - "python311-version", - ), - }, - MatrixRuntimeReceiptV2 { - runtime_id: "python312".to_owned(), - receipt: legacy_runtime_receipt( - "python312", - LEGACY_MATRIX_PY312_IMAGE, - LEGACY_MATRIX_PY312_DIGEST, - "python312-version", - ), - }, - ], + configuration_digest: plan.plan_digest()?.to_owned(), + runtime_receipts, overall_status: EvidenceStatus::Pass, incomplete_reason: None, redaction_policy_version: "1.0".to_owned(), }) - .expect("seal legacy Matrix receipt") +} + +fn legacy_matrix_receipt() -> MatrixReceiptEnvelopeV2 { + legacy_matrix_receipt_from_production_plan(&legacy_production_plan()) + .expect("production-derived legacy Matrix receipt") } fn matrix_report( From 700878e4506b569183c74fc2f7e65d51ca454da7 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 05:51:02 +0200 Subject: [PATCH 25/33] fix: recheck Matrix profile before admission --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2cca73a..4f30b48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1350,12 +1350,12 @@ fn print_matrix_run( lifecycle.fail(RunFailureKindV1::PreparationFailed)?; return Err(CliError::RunJournal(error)); } + let admission = + AdmissionCoordinator::platform_for(&location.repository).map_err(CliError::Admission)?; if let Err(error) = envelope.validate_profile_binding() { lifecycle.fail(RunFailureKindV1::PreparationFailed)?; return Err(CliError::Matrix(error)); } - let admission = - AdmissionCoordinator::platform_for(&location.repository).map_err(CliError::Admission)?; let guard = match admission.acquire( Duration::from_secs(admission_timeout_seconds), &cancellation, From 3870df2d0447c75685fb5b145f370e32241c8923 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Wed, 26 Aug 2026 21:31:52 +0200 Subject: [PATCH 26/33] test: cover Matrix terminal profile reconciliation --- src/main.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++-- tests/plan_cli.rs | 7 +++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4f30b48..12fd53a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2822,8 +2822,8 @@ mod tests { ResourceExecutionModeArg, ResourceExecutorArg, RunTerminalJournalEvent, WatchdogCompletionBarrier, after_validated_matrix_profile_binding, detect_resource_executor, finalize_benchmark_terminal, finalize_guard_exec_result, - finalize_run_terminal, new_journal_id, reconcile_watchdog_outcome, - resource_run_outcome, resource_terminal_detail, + finalize_run_terminal, new_journal_id, reconcile_watchdog_outcome, resource_run_outcome, + resource_terminal_detail, }; use clap::{CommandFactory, Parser}; use commit_ci_preflight::admission::AdmissionError; @@ -3083,6 +3083,61 @@ timeout_seconds = 60 assert_eq!(effects.load(Ordering::SeqCst), 0); } + #[test] + fn matrix_profile_recheck_precedes_shared_terminal_finalization() { + use std::cell::RefCell; + + let source = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/config-v2-legacy-compatible.toml"), + ) + .expect("legacy fixture"); + let envelope = build_matrix_plan( + MatrixConfigV2::parse(&source).expect("matrix config"), + MatrixPlanProfile::LegacyV1, + ) + .expect("matrix plan"); + let events = RefCell::new(Vec::new()); + + let valid = after_validated_matrix_profile_binding(&envelope, || { + finalize_run_terminal( + Ok(()), + |primary| { + events.borrow_mut().push("complete"); + primary + }, + || { + events.borrow_mut().push("release"); + Ok(()) + }, + |_| Ok(()), + ) + }); + assert!(valid.is_ok()); + assert_eq!(&*events.borrow(), &["complete", "release"]); + + let mut mutated = envelope; + mutated.plan.project = "example/mutated-before-admission".to_owned(); + let release_attempts = AtomicUsize::new(0); + let rejected = after_validated_matrix_profile_binding(&mutated, || { + finalize_run_terminal( + Ok(()), + std::convert::identity, + || { + release_attempts.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + |_| Ok(()), + ) + }); + + assert!(matches!( + rejected, + Err(CliError::Matrix(MatrixError::PlanDigestMismatch)) + )); + assert_eq!(release_attempts.load(Ordering::SeqCst), 0); + } + #[test] fn guard_exec_requires_double_dash_and_program() { let cli = Cli::try_parse_from([ diff --git a/tests/plan_cli.rs b/tests/plan_cli.rs index f0b383b..7f02da7 100644 --- a/tests/plan_cli.rs +++ b/tests/plan_cli.rs @@ -167,10 +167,9 @@ fn matrix_legacy_profile_discloses_a_reconstructible_digest_basis() { #[test] fn legacy_matrix_profile_reproduces_the_pinned_historical_plan_digest() { - let expected: serde_json::Value = serde_json::from_str(include_str!( - "fixtures/matrix-v2-legacy-plan-044697.json" - )) - .expect("historical plan fixture"); + let expected: serde_json::Value = + serde_json::from_str(include_str!("fixtures/matrix-v2-legacy-plan-044697.json")) + .expect("historical plan fixture"); let output = Command::new(env!("CARGO_BIN_EXE_commit-ci-preflight")) .args([ "plan", From db8476498a72cb1f871cdfbbf1eb8ebf5157faa6 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Thu, 27 Aug 2026 06:34:24 +0200 Subject: [PATCH 27/33] fix: finalize Matrix receipts after terminal cleanup --- src/main.rs | 518 +++++++++++++++++++++++++++++++++++--------------- src/matrix.rs | 62 ++++-- 2 files changed, 413 insertions(+), 167 deletions(-) diff --git a/src/main.rs b/src/main.rs index 12fd53a..ef92c89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod terminal; use terminal::{TerminalFailure, finalize_owned_terminal}; +use std::cell::RefCell; use std::collections::BTreeMap; use std::ffi::OsString; use std::fmt; @@ -41,7 +42,8 @@ use commit_ci_preflight::github_actions::{ }; use commit_ci_preflight::matrix::{ MatrixConfigV2, MatrixError, MatrixPlanEnvelopeV2, MatrixPlanProfile, MatrixPlanV2, - MatrixRunRequestV2, build_matrix_plan, execute_matrix_run_v2, + MatrixRunOutcomeV2, MatrixRunRequestV2, build_matrix_plan, execute_matrix_run_v2, + seal_matrix_run_material, write_matrix_receipt, }; use commit_ci_preflight::process::{ CancellationReason, CancellationToken, GenerationGuard, OutputMode, ProcessRequest, @@ -1279,11 +1281,11 @@ fn print_matrix_run( &journal_clock.now_utc().map_err(CliError::Run)?, ) .map_err(CliError::RunJournal)?; - let mut lifecycle = JournalLifecycleObserver { + let lifecycle = RefCell::new(JournalLifecycleObserver { store: &journal, run_id: &journal_id, clock: &journal_clock, - }; + }); let supervisor = Arc::new(ProcessSupervisor::standard()); let cancellation = CancellationToken::default(); install_cancellation_handler(&cancellation)?; @@ -1304,7 +1306,9 @@ fn print_matrix_run( ) { Ok(commit) => commit, Err(error) => { - lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; return Err(CliError::Run(RunError::SourceSnapshot(error))); } }; @@ -1318,7 +1322,9 @@ fn print_matrix_run( let source_resource = match journal.reserve_resource(&journal_id, "source-snapshot-v1") { Ok(path) => path, Err(error) => { - lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; return Err(CliError::RunJournal(error)); } }; @@ -1333,12 +1339,16 @@ fn print_matrix_run( ) { Ok(snapshot) => snapshot, Err(error) => { - lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; return Err(CliError::Run(RunError::SourceSnapshot(error))); } }; if let Err(error) = envelope.prepare_source_snapshot_overlay(&mut source_snapshot) { - lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; return Err(CliError::Matrix(error)); } if let Err(error) = journal.bind_source( @@ -1347,114 +1357,133 @@ fn print_matrix_run( &source_snapshot.evidence().manifest_digest, source_snapshot.evidence().entry_count, ) { - lifecycle.fail(RunFailureKindV1::PreparationFailed)?; + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; return Err(CliError::RunJournal(error)); } + let source_snapshot = RefCell::new(source_snapshot); let admission = AdmissionCoordinator::platform_for(&location.repository).map_err(CliError::Admission)?; - if let Err(error) = envelope.validate_profile_binding() { - lifecycle.fail(RunFailureKindV1::PreparationFailed)?; - return Err(CliError::Matrix(error)); - } - let guard = match admission.acquire( - Duration::from_secs(admission_timeout_seconds), - &cancellation, - ) { - Ok(guard) => guard, - Err(error) => { - lifecycle.fail(RunFailureKindV1::AdmissionRejected)?; - return Err(CliError::Admission(error)); - } - }; - lifecycle.transition_state(RunJournalStateV1::Admitted, None)?; - if let Err(error) = resource_pre_start(supervisor.clone(), &cancellation) { - return finalize_run_terminal( - Err::<(), _>(error), - std::convert::identity, - || guard.release(), - |event| match event { - RunTerminalJournalEvent::PrimaryFailure(kind) => lifecycle.fail(kind), - RunTerminalJournalEvent::ReleaseFailure => { - lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) - } - }, - ); - } - let watchdog = if ResourcePlatform::current() == ResourcePlatform::MacOs { - let current_dir = match std::env::current_dir() { - Ok(path) => path, + let completion_barrier = RefCell::new(None); + let outcome = orchestrate_matrix_terminal_lifecycle(MatrixTerminalLifecyclePorts { + validate_before_admission: || { + if let Err(error) = envelope.validate_profile_binding() { + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::Matrix(error)); + } + Ok(()) + }, + acquire: || match admission.acquire( + Duration::from_secs(admission_timeout_seconds), + &cancellation, + ) { + Ok(guard) => { + lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::Admitted, None)?; + Ok(guard) + } Err(error) => { - return finalize_run_terminal( - Err::<(), _>(CliError::internal(error)), - std::convert::identity, - || guard.release(), - |event| match event { - RunTerminalJournalEvent::PrimaryFailure(kind) => lifecycle.fail(kind), - RunTerminalJournalEvent::ReleaseFailure => { - lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) - } - }, - ); + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::AdmissionRejected)?; + Err(CliError::Admission(error)) } - }; - Some(ResourceWatchdog::start( - ResourceProbe::new(SupervisorResourceRunner::new( - supervisor.clone(), - current_dir, - cancellation.clone(), - )), - cancellation.clone(), - )) - } else { - None - }; - let mut completion_barrier = WatchdogCompletionBarrier::new(watchdog); - lifecycle - .transition(RunLifecyclePhase::Prepared) - .map_err(CliError::Run)?; - lifecycle - .transition(RunLifecyclePhase::Executing) - .map_err(CliError::Run)?; - let result = execute_matrix_run_v2( - &MatrixRunRequestV2 { - envelope: &envelope, - repository: &location.repository, - cache: &cache, - generation, - source_snapshot: &source_snapshot, }, - supervisor.as_ref(), - &cancellation, - &SystemClock, - &mut completion_barrier, - ) - .map_err(CliError::Matrix); - let outcome = finalize_run_terminal( - result, - |result| { - completion_barrier.ensure_joined(); - if let Some(error) = completion_barrier.take_join_error() { - Err(CliError::Resource(ResourceGuardError::Watchdog(error))) + execute: || { + resource_pre_start(supervisor.clone(), &cancellation)?; + let watchdog = if ResourcePlatform::current() == ResourcePlatform::MacOs { + let current_dir = std::env::current_dir().map_err(CliError::internal)?; + Some(ResourceWatchdog::start( + ResourceProbe::new(SupervisorResourceRunner::new( + supervisor.clone(), + current_dir, + cancellation.clone(), + )), + cancellation.clone(), + )) } else { - result + None + }; + *completion_barrier.borrow_mut() = Some(WatchdogCompletionBarrier::new(watchdog)); + lifecycle + .borrow_mut() + .transition(RunLifecyclePhase::Prepared) + .map_err(CliError::Run)?; + lifecycle + .borrow_mut() + .transition(RunLifecyclePhase::Executing) + .map_err(CliError::Run)?; + let mut barrier = completion_barrier.borrow_mut(); + let snapshot = source_snapshot.borrow(); + execute_matrix_run_v2( + &MatrixRunRequestV2 { + envelope: &envelope, + repository: &location.repository, + cache: &cache, + generation, + source_snapshot: &snapshot, + }, + supervisor.as_ref(), + &cancellation, + &SystemClock, + barrier + .as_mut() + .expect("matrix execution must own a completion barrier"), + ) + .map_err(CliError::Matrix) + }, + complete: |result| { + let mut barrier = completion_barrier.borrow_mut(); + if let Some(barrier) = barrier.as_mut() { + barrier.ensure_joined(); + if let Some(error) = barrier.take_join_error() { + return Err(CliError::Resource(ResourceGuardError::Watchdog(error))); + } } + result }, - || guard.release(), - |event| match event { - RunTerminalJournalEvent::PrimaryFailure(kind) => lifecycle.fail(kind), - RunTerminalJournalEvent::ReleaseFailure => { - lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) + release: |guard: AdmissionGuard| guard.release(), + journal: |event| match event { + RunTerminalJournalEvent::PrimaryFailure(kind) => lifecycle.borrow_mut().fail(kind), + RunTerminalJournalEvent::ReleaseFailure => lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::CleanupPending, None), + }, + cleanup_snapshot: || { + if let Err(error) = source_snapshot.borrow_mut().cleanup() { + lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::CleanupPending, None)?; + return Err(CliError::Run(RunError::SourceSnapshot(error))); } + Ok(()) }, - )?; - if let Err(error) = source_snapshot.cleanup() { - lifecycle.transition_state(RunJournalStateV1::CleanupPending, None)?; - return Err(CliError::Run(RunError::SourceSnapshot(error))); - } - lifecycle - .transition(RunLifecyclePhase::Finalizing) - .map_err(CliError::Run)?; + seal: |material| { + lifecycle + .borrow_mut() + .transition(RunLifecyclePhase::Finalizing) + .map_err(CliError::Run)?; + seal_matrix_run_material(&envelope, material).map_err(CliError::Matrix) + }, + write: |receipt| { + let receipt_path = write_matrix_receipt( + &location.repository, + &envelope.plan.receipt.output, + &receipt, + ) + .map_err(CliError::Matrix)?; + Ok(MatrixRunOutcomeV2 { + receipt, + receipt_path, + }) + }, + })?; lifecycle + .borrow_mut() .transition(RunLifecyclePhase::Sealed) .map_err(CliError::Run)?; if json { @@ -2067,6 +2096,82 @@ fn finalize_run_terminal( } } +/// Caller-owned ports for the Matrix terminal lifecycle. Runtime execution +/// yields only unsealed material; terminal release and snapshot cleanup must +/// complete before outer receipt sealing and atomic publication. +struct MatrixTerminalLifecyclePorts< + Validate, + Acquire, + Execute, + Complete, + Release, + Journal, + Cleanup, + Seal, + Write, +> { + validate_before_admission: Validate, + acquire: Acquire, + execute: Execute, + complete: Complete, + release: Release, + journal: Journal, + cleanup_snapshot: Cleanup, + seal: Seal, + write: Write, +} + +fn orchestrate_matrix_terminal_lifecycle< + T, + S, + O, + G, + Validate, + Acquire, + Execute, + Complete, + Release, + Journal, + Cleanup, + Seal, + Write, +>( + ports: MatrixTerminalLifecyclePorts< + Validate, + Acquire, + Execute, + Complete, + Release, + Journal, + Cleanup, + Seal, + Write, + >, +) -> Result +where + Validate: FnOnce() -> Result<(), CliError>, + Acquire: FnOnce() -> Result, + Execute: FnOnce() -> Result, + Complete: FnOnce(Result) -> Result, + Release: FnOnce(G) -> Result<(), AdmissionError>, + Journal: FnMut(RunTerminalJournalEvent) -> Result<(), CliError>, + Cleanup: FnOnce() -> Result<(), CliError>, + Seal: FnOnce(T) -> Result, + Write: FnOnce(S) -> Result, +{ + (ports.validate_before_admission)()?; + let guard = (ports.acquire)()?; + let material = finalize_run_terminal( + (ports.execute)(), + ports.complete, + || (ports.release)(guard), + ports.journal, + )?; + (ports.cleanup_snapshot)()?; + let sealed = (ports.seal)(material)?; + (ports.write)(sealed) +} + struct GuardExecSession { admission: Option, watchdog: WatchdogCompletionBarrier, @@ -2818,12 +2923,12 @@ impl std::error::Error for CliMessageError {} #[cfg(test)] mod tests { use super::{ - Cli, CliError, GuardCommand, GuardExecArgs, GuardExecError, ResourceCacheStateArg, - ResourceExecutionModeArg, ResourceExecutorArg, RunTerminalJournalEvent, - WatchdogCompletionBarrier, after_validated_matrix_profile_binding, + Cli, CliError, GuardCommand, GuardExecArgs, GuardExecError, MatrixTerminalLifecyclePorts, + ResourceCacheStateArg, ResourceExecutionModeArg, ResourceExecutorArg, + RunTerminalJournalEvent, WatchdogCompletionBarrier, after_validated_matrix_profile_binding, detect_resource_executor, finalize_benchmark_terminal, finalize_guard_exec_result, - finalize_run_terminal, new_journal_id, reconcile_watchdog_outcome, resource_run_outcome, - resource_terminal_detail, + finalize_run_terminal, new_journal_id, orchestrate_matrix_terminal_lifecycle, + reconcile_watchdog_outcome, resource_run_outcome, resource_terminal_detail, }; use clap::{CommandFactory, Parser}; use commit_ci_preflight::admission::AdmissionError; @@ -3084,58 +3189,175 @@ timeout_seconds = 60 } #[test] - fn matrix_profile_recheck_precedes_shared_terminal_finalization() { + fn matrix_terminal_orchestration_orders_finalization_before_publication() { use std::cell::RefCell; - let source = fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/config-v2-legacy-compatible.toml"), - ) - .expect("legacy fixture"); - let envelope = build_matrix_plan( - MatrixConfigV2::parse(&source).expect("matrix config"), - MatrixPlanProfile::LegacyV1, - ) - .expect("matrix plan"); - let events = RefCell::new(Vec::new()); - - let valid = after_validated_matrix_profile_binding(&envelope, || { - finalize_run_terminal( - Ok(()), - |primary| { - events.borrow_mut().push("complete"); - primary + #[derive(Clone, Copy)] + enum Failure { + None, + Validation, + Release, + Cleanup, + } + + for (failure, expected) in [ + ( + Failure::None, + vec![ + "validate", + "acquire", + "complete/join", + "release", + "snapshot cleanup", + "seal", + "write", + ], + ), + (Failure::Validation, vec!["validate"]), + ( + Failure::Release, + vec![ + "validate", + "acquire", + "complete/join", + "release", + "journal cleanup-pending", + ], + ), + ( + Failure::Cleanup, + vec![ + "validate", + "acquire", + "complete/join", + "release", + "snapshot cleanup", + "journal cleanup-pending", + ], + ), + ] { + let events = RefCell::new(Vec::new()); + let release_attempts = AtomicUsize::new(0); + let result = orchestrate_matrix_terminal_lifecycle(MatrixTerminalLifecyclePorts { + validate_before_admission: || { + events.borrow_mut().push("validate"); + if matches!(failure, Failure::Validation) { + return Err(CliError::Matrix(MatrixError::PlanDigestMismatch)); + } + Ok(()) }, - || { - events.borrow_mut().push("release"); + acquire: || { + events.borrow_mut().push("acquire"); Ok(()) }, - |_| Ok(()), - ) - }); - assert!(valid.is_ok()); - assert_eq!(&*events.borrow(), &["complete", "release"]); - - let mut mutated = envelope; - mutated.plan.project = "example/mutated-before-admission".to_owned(); - let release_attempts = AtomicUsize::new(0); - let rejected = after_validated_matrix_profile_binding(&mutated, || { - finalize_run_terminal( - Ok(()), - std::convert::identity, - || { + execute: || Ok("material"), + complete: |primary| { + events.borrow_mut().push("complete/join"); + primary + }, + release: |_| { + events.borrow_mut().push("release"); release_attempts.fetch_add(1, Ordering::SeqCst); + if matches!(failure, Failure::Release) { + Err(AdmissionError::Clock) + } else { + Ok(()) + } + }, + journal: |event| match event { + RunTerminalJournalEvent::ReleaseFailure => { + events.borrow_mut().push("journal cleanup-pending"); + Ok(()) + } + RunTerminalJournalEvent::PrimaryFailure(_) => Ok(()), + }, + cleanup_snapshot: || { + events.borrow_mut().push("snapshot cleanup"); + if matches!(failure, Failure::Cleanup) { + events.borrow_mut().push("journal cleanup-pending"); + return Err(CliError::internal(std::io::Error::other("cleanup"))); + } Ok(()) }, - |_| Ok(()), - ) - }); + seal: |material| { + events.borrow_mut().push("seal"); + Ok(material) + }, + write: |sealed| { + events.borrow_mut().push("write"); + Ok(sealed) + }, + }); + + assert_eq!(&*events.borrow(), expected.as_slice()); + assert!( + release_attempts.load(Ordering::SeqCst) <= 1, + "release is attempted at most once" + ); + match failure { + Failure::None => assert_eq!(result.expect("successful write"), "material"), + Failure::Validation => assert!(matches!( + result, + Err(CliError::Matrix(MatrixError::PlanDigestMismatch)) + )), + Failure::Release => { + assert_eq!(release_attempts.load(Ordering::SeqCst), 1); + assert!(matches!( + result, + Err(CliError::Admission(AdmissionError::Clock)) + )); + } + Failure::Cleanup => assert!(matches!(result, Err(CliError::Internal(_)))), + } + } + let events = RefCell::new(Vec::new()); + let result: Result<(), CliError> = + orchestrate_matrix_terminal_lifecycle(MatrixTerminalLifecyclePorts { + validate_before_admission: || { + events.borrow_mut().push("validate"); + Ok(()) + }, + acquire: || { + events.borrow_mut().push("acquire"); + Ok(()) + }, + execute: || { + Err::<&'static str, _>(CliError::Resource(ResourceGuardError::PreStartDenied)) + }, + complete: |primary| { + events.borrow_mut().push("complete/join"); + primary + }, + release: |_| { + events.borrow_mut().push("release"); + Err(AdmissionError::Clock) + }, + journal: |event| match event { + RunTerminalJournalEvent::ReleaseFailure => { + events.borrow_mut().push("journal cleanup-pending"); + Ok(()) + } + RunTerminalJournalEvent::PrimaryFailure(_) => Ok(()), + }, + cleanup_snapshot: || unreachable!("release failure suppresses snapshot cleanup"), + seal: |_: &'static str| unreachable!("release failure suppresses seal"), + write: |_: &'static str| unreachable!("release failure suppresses write"), + }); assert!(matches!( - rejected, - Err(CliError::Matrix(MatrixError::PlanDigestMismatch)) + result, + Err(CliError::Admission(AdmissionError::Clock)) )); - assert_eq!(release_attempts.load(Ordering::SeqCst), 0); + assert_eq!( + &*events.borrow(), + &[ + "validate", + "acquire", + "complete/join", + "release", + "journal cleanup-pending" + ] + ); } #[test] diff --git a/src/matrix.rs b/src/matrix.rs index 0bbe961..0758325 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -488,6 +488,14 @@ pub struct MatrixRunOutcomeV2 { pub receipt_path: PathBuf, } +/// Unsealed, unpublished Matrix receipt material collected while the caller +/// owns admission and source-snapshot lifecycle. It must be revalidated and +/// sealed only after terminal admission finalization and snapshot cleanup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatrixRunMaterialV2 { + receipt: MatrixReceiptV2, +} + /// Inputs that are fixed for the complete matrix execution. Grouping these /// immutable values keeps the executor below Clippy's argument-count limit /// without obscuring the owned admission/watchdog dependencies. @@ -500,16 +508,18 @@ pub struct MatrixRunRequestV2<'a> { } /// Execute every independently pinned runtime sequentially under one caller -/// owned admission/watchdog session, then publish exactly one outer receipt. -/// The inner v1 receipts stay in memory: no intermediate source-tree mutation -/// can make a later runtime observe a different Git state. +/// owned admission/watchdog session and return unsealed receipt material. The +/// caller must finalize admission, clean the source snapshot, then seal and +/// publish the outer receipt. The inner v1 receipts stay in memory: no +/// intermediate source-tree mutation can make a later runtime observe a +/// different Git state. pub fn execute_matrix_run_v2( request: &MatrixRunRequestV2<'_>, supervisor: &dyn SupervisorPort, cancellation: &CancellationToken, clock: &dyn Clock, barrier: &mut dyn CompletionBarrier, -) -> Result { +) -> Result { request.envelope.validate_profile_binding()?; let started_at_utc = clock.now_utc().map_err(MatrixError::Run)?; let mut runtime_receipts = Vec::new(); @@ -574,10 +584,9 @@ pub fn execute_matrix_run_v2( started_at_utc: &started_at_utc, }) .map_err(MatrixError::Receipt)?; - let producer = validate_matrix_receipts_for_seal(request.envelope, &runtime_receipts)?; - let receipt = MatrixReceiptEnvelopeV2::seal(MatrixReceiptV2 { + let receipt = MatrixReceiptV2 { schema_version: MATRIX_RECEIPT_SCHEMA_VERSION.to_owned(), - producer, + producer: first.receipt.receipt.producer.clone(), repository: repository_evidence, run: RunEvidence { run_id, @@ -591,18 +600,29 @@ pub fn execute_matrix_run_v2( incomplete_reason: (overall_status == EvidenceStatus::Pending) .then(|| "one or more required checks were not run".to_owned()), redaction_policy_version, - })?; + }; + Ok(MatrixRunMaterialV2 { receipt }) +} + +/// Revalidate the selected profile and every inner receipt immediately before +/// sealing the outer Matrix receipt. +pub fn seal_matrix_run_material( + envelope: &MatrixPlanEnvelopeV2, + material: MatrixRunMaterialV2, +) -> Result { + validate_matrix_receipts_for_seal(envelope, &material.receipt.runtime_receipts)?; + MatrixReceiptEnvelopeV2::seal(material.receipt) +} + +/// Atomically publish a previously sealed Matrix receipt. Callers must invoke +/// this only after caller-owned terminal and source-snapshot lifecycle steps. +pub fn write_matrix_receipt( + repository: &Path, + output: &str, + receipt: &MatrixReceiptEnvelopeV2, +) -> Result { let bytes = receipt.canonical_bytes()?; - let receipt_path = write_canonical_receipt_bytes_atomic( - request.repository, - &request.envelope.plan.receipt.output, - &bytes, - ) - .map_err(MatrixError::Run)?; - Ok(MatrixRunOutcomeV2 { - receipt, - receipt_path, - }) + write_canonical_receipt_bytes_atomic(repository, output, &bytes).map_err(MatrixError::Run) } fn validate_matrix_receipts_for_seal( @@ -1527,7 +1547,11 @@ pids_limit = 16 ) .expect("matrix run"); - assert_eq!(outcome.receipt.receipt.runtime_receipts.len(), 2); + assert_eq!(outcome.receipt.runtime_receipts.len(), 2); + assert!( + !repository.join(&envelope.plan.receipt.output).exists(), + "matrix execution must return unsealed, unpublished material" + ); let roots = supervisor.execution_roots.lock().expect("execution roots"); assert_eq!(roots.len(), 2); assert_eq!(roots[0], roots[1]); From 8e9d487e49fb8b2e25083837a01c75ae8b758a58 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Thu, 27 Aug 2026 06:50:34 +0200 Subject: [PATCH 28/33] fix: journal Matrix finalization failures --- src/main.rs | 144 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index ef92c89..06d3905 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2159,17 +2159,39 @@ where Seal: FnOnce(T) -> Result, Write: FnOnce(S) -> Result, { - (ports.validate_before_admission)()?; - let guard = (ports.acquire)()?; - let material = finalize_run_terminal( - (ports.execute)(), - ports.complete, - || (ports.release)(guard), - ports.journal, - )?; - (ports.cleanup_snapshot)()?; - let sealed = (ports.seal)(material)?; - (ports.write)(sealed) + let MatrixTerminalLifecyclePorts { + validate_before_admission, + acquire, + execute, + complete, + release, + mut journal, + cleanup_snapshot, + seal, + write, + } = ports; + validate_before_admission()?; + let guard = acquire()?; + let material = finalize_run_terminal(execute(), complete, || release(guard), &mut journal)?; + cleanup_snapshot()?; + let sealed = match seal(material) { + Ok(sealed) => sealed, + Err(error) => { + journal(RunTerminalJournalEvent::PrimaryFailure( + RunFailureKindV1::FinalizationFailed, + ))?; + return Err(error); + } + }; + match write(sealed) { + Ok(outcome) => Ok(outcome), + Err(error) => { + journal(RunTerminalJournalEvent::PrimaryFailure( + RunFailureKindV1::FinalizationFailed, + ))?; + Err(error) + } + } } struct GuardExecSession { @@ -2948,6 +2970,7 @@ mod tests { ResourceCommand, ResourceCommandRunner, ResourceProbe, ResourceProbeError, ResourceWatchdog, }; use commit_ci_preflight::resource_history::{ResourceExecutorV2, ResourceTerminalDetailV2}; + use commit_ci_preflight::run::RunError; use commit_ci_preflight::run_journal::{RunFailureKindV1, RunJournalError}; use std::ffi::OsString; use std::fs; @@ -3360,6 +3383,105 @@ timeout_seconds = 60 ); } + #[test] + fn matrix_terminal_orchestration_journals_seal_and_write_failures() { + use std::cell::RefCell; + + #[derive(Clone, Copy)] + enum Failure { + Seal, + Write, + } + + for (failure, expected) in [ + ( + Failure::Seal, + vec![ + "validate", + "acquire", + "complete/join", + "release", + "snapshot cleanup", + "seal", + "journal finalization-failed", + ], + ), + ( + Failure::Write, + vec![ + "validate", + "acquire", + "complete/join", + "release", + "snapshot cleanup", + "seal", + "write", + "journal finalization-failed", + ], + ), + ] { + let events = RefCell::new(Vec::new()); + let result = orchestrate_matrix_terminal_lifecycle(MatrixTerminalLifecyclePorts { + validate_before_admission: || { + events.borrow_mut().push("validate"); + Ok(()) + }, + acquire: || { + events.borrow_mut().push("acquire"); + Ok(()) + }, + execute: || Ok("material"), + complete: |primary| { + events.borrow_mut().push("complete/join"); + primary + }, + release: |_| { + events.borrow_mut().push("release"); + Ok(()) + }, + journal: |event| { + events.borrow_mut().push(match event { + RunTerminalJournalEvent::PrimaryFailure( + RunFailureKindV1::FinalizationFailed, + ) => "journal finalization-failed", + _ => "unexpected journal event", + }); + Ok(()) + }, + cleanup_snapshot: || { + events.borrow_mut().push("snapshot cleanup"); + Ok(()) + }, + seal: |material| { + events.borrow_mut().push("seal"); + if matches!(failure, Failure::Seal) { + Err(CliError::Matrix(MatrixError::InvalidReceipt)) + } else { + Ok(material) + } + }, + write: |sealed| { + events.borrow_mut().push("write"); + if matches!(failure, Failure::Write) { + Err(CliError::Matrix(MatrixError::Run( + RunError::UnsafeReceiptPath, + ))) + } else { + events.borrow_mut().push("sealed"); + Ok(sealed) + } + }, + }); + + assert!(matches!(result, Err(CliError::Matrix(_)))); + assert_eq!(&*events.borrow(), expected.as_slice()); + assert!( + !events.borrow().contains(&"sealed"), + "failed sealing or writing must not transition the journal to sealed" + ); + } + } + #[test] fn guard_exec_requires_double_dash_and_program() { let cli = Cli::try_parse_from([ From 1f9fef59d404df6657403dc4d4e17308c9a7d2a3 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Thu, 27 Aug 2026 07:07:06 +0200 Subject: [PATCH 29/33] test: persist Matrix finalization journal failures --- src/main.rs | 189 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 181 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 06d3905..05ccfad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2945,12 +2945,13 @@ impl std::error::Error for CliMessageError {} #[cfg(test)] mod tests { use super::{ - Cli, CliError, GuardCommand, GuardExecArgs, GuardExecError, MatrixTerminalLifecyclePorts, - ResourceCacheStateArg, ResourceExecutionModeArg, ResourceExecutorArg, - RunTerminalJournalEvent, WatchdogCompletionBarrier, after_validated_matrix_profile_binding, - detect_resource_executor, finalize_benchmark_terminal, finalize_guard_exec_result, - finalize_run_terminal, new_journal_id, orchestrate_matrix_terminal_lifecycle, - reconcile_watchdog_outcome, resource_run_outcome, resource_terminal_detail, + Cli, CliError, GuardCommand, GuardExecArgs, GuardExecError, JournalLifecycleObserver, + MatrixTerminalLifecyclePorts, ResourceCacheStateArg, ResourceExecutionModeArg, + ResourceExecutorArg, RunTerminalJournalEvent, WatchdogCompletionBarrier, + after_validated_matrix_profile_binding, detect_resource_executor, + finalize_benchmark_terminal, finalize_guard_exec_result, finalize_run_terminal, + new_journal_id, orchestrate_matrix_terminal_lifecycle, reconcile_watchdog_outcome, + resource_run_outcome, resource_terminal_detail, }; use clap::{CommandFactory, Parser}; use commit_ci_preflight::admission::AdmissionError; @@ -2970,14 +2971,25 @@ mod tests { ResourceCommand, ResourceCommandRunner, ResourceProbe, ResourceProbeError, ResourceWatchdog, }; use commit_ci_preflight::resource_history::{ResourceExecutorV2, ResourceTerminalDetailV2}; - use commit_ci_preflight::run::RunError; - use commit_ci_preflight::run_journal::{RunFailureKindV1, RunJournalError}; + use commit_ci_preflight::run::{Clock, RunError, RunLifecycleObserver, RunLifecyclePhase}; + use commit_ci_preflight::run_journal::{ + RecoveryClassificationV1, RunFailureKindV1, RunJournalEntryV1, RunJournalError, + RunJournalStateV1, RunJournalStore, + }; use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; + struct FixedJournalClock; + + impl Clock for FixedJournalClock { + fn now_utc(&self) -> Result { + Ok("2026-08-27T12:00:00Z".to_owned()) + } + } + fn guard_cache_fixture(name: &str) -> (ManagedCache, PathBuf, PathBuf) { let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -3482,6 +3494,167 @@ timeout_seconds = 60 } } + #[test] + fn matrix_terminal_finalization_failures_persist_durable_journal_states() { + use std::cell::RefCell; + + #[derive(Clone, Copy)] + enum Failure { + Seal, + Write, + } + + for (case_index, (failure, persistence_fails)) in [ + (Failure::Seal, false), + (Failure::Write, false), + (Failure::Seal, true), + (Failure::Write, true), + ] + .into_iter() + .enumerate() + { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ccp-matrix-finalization-journal-{case_index}-{}-{stamp}", + std::process::id() + )); + fs::create_dir(&root).expect("temporary journal root"); + let store = RunJournalStore::initialize(&root).expect("journal store"); + let run_id = format!("{case_index:064x}"); + let clock = FixedJournalClock; + store + .create_run(&run_id, "2026-08-27T12:00:00Z") + .expect("created journal"); + store + .transition( + &run_id, + RunJournalStateV1::Admitted, + "2026-08-27T12:00:00Z", + None, + ) + .expect("admitted journal"); + let lifecycle = RefCell::new(JournalLifecycleObserver { + store: &store, + run_id: &run_id, + clock: &clock, + }); + lifecycle + .borrow_mut() + .transition(RunLifecyclePhase::Prepared) + .expect("prepared journal"); + lifecycle + .borrow_mut() + .transition(RunLifecyclePhase::Executing) + .expect("executing journal"); + let run_path = root.join("run-journal-v1").join("runs").join(&run_id); + let persistence_target = run_path.clone(); + + let result: Result<&str, CliError> = + orchestrate_matrix_terminal_lifecycle(MatrixTerminalLifecyclePorts { + validate_before_admission: || Ok(()), + acquire: || Ok(()), + execute: || Ok("material"), + complete: std::convert::identity, + release: |_| Ok(()), + journal: |event| match event { + RunTerminalJournalEvent::PrimaryFailure(kind) => { + lifecycle.borrow_mut().fail(kind) + } + RunTerminalJournalEvent::ReleaseFailure => lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::CleanupPending, None), + }, + cleanup_snapshot: || Ok(()), + seal: |material| { + lifecycle + .borrow_mut() + .transition(RunLifecyclePhase::Finalizing) + .map_err(CliError::Run)?; + if matches!(failure, Failure::Seal) { + if persistence_fails { + fs::remove_dir_all(&persistence_target) + .map_err(CliError::internal)?; + } + Err(CliError::Matrix(MatrixError::InvalidReceipt)) + } else { + Ok(material) + } + }, + write: |sealed| { + if matches!(failure, Failure::Write) { + if persistence_fails { + fs::remove_dir_all(&persistence_target) + .map_err(CliError::internal)?; + } + Err(CliError::Matrix(MatrixError::Run( + RunError::UnsafeReceiptPath, + ))) + } else { + Ok(sealed) + } + }, + }); + + if persistence_fails { + assert!(matches!( + result, + Err(CliError::RunJournal(RunJournalError::OwnershipMismatch)) + )); + assert!( + !persistence_target.exists(), + "the injected temporary-store removal must make persistence fail" + ); + } else { + match failure { + Failure::Seal => assert!(matches!( + result, + Err(CliError::Matrix(MatrixError::InvalidReceipt)) + )), + Failure::Write => assert!(matches!( + result, + Err(CliError::Matrix(MatrixError::Run( + RunError::UnsafeReceiptPath + ))) + )), + } + let status = store.status().expect("durable journal status"); + assert_eq!(status.runs.len(), 1); + assert_eq!(status.runs[0].state, Some(RunJournalStateV1::Failed)); + assert_eq!( + status.runs[0].classification, + RecoveryClassificationV1::Terminal + ); + let final_entry: RunJournalEntryV1 = serde_json::from_slice( + &fs::read(run_path.join("00000000000000000005-failed.json")) + .expect("durable failed journal entry"), + ) + .expect("decode failed journal entry"); + assert_eq!(final_entry.state, RunJournalStateV1::Failed); + assert_eq!( + final_entry.failure_kind, + Some(RunFailureKindV1::FinalizationFailed) + ); + assert!( + !fs::read_dir(&run_path) + .expect("journal entries") + .any(|entry| { + entry + .expect("journal entry") + .file_name() + .to_string_lossy() + .ends_with("-sealed.json") + }), + "failed sealing or writing must not persist a sealed journal state" + ); + } + + fs::remove_dir_all(root).expect("remove temporary journal root"); + } + } + #[test] fn guard_exec_requires_double_dash_and_program() { let cli = Cli::try_parse_from([ From 24dbcc0b9b733e4e6082206bec570c8b49d34280 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Thu, 27 Aug 2026 07:32:41 +0200 Subject: [PATCH 30/33] fix: finalize post-admission journal failures --- src/main.rs | 285 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 254 insertions(+), 31 deletions(-) diff --git a/src/main.rs b/src/main.rs index 05ccfad..157500e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1124,17 +1124,27 @@ fn print_run( } let admission = AdmissionCoordinator::platform_for(&location.repository).map_err(CliError::Admission)?; - let guard = match admission.acquire( - Duration::from_secs(admission_timeout_seconds), - &cancellation, - ) { - Ok(guard) => guard, - Err(error) => { - lifecycle.fail(RunFailureKindV1::AdmissionRejected)?; - return Err(CliError::Admission(error)); - } - }; - lifecycle.transition_state(RunJournalStateV1::Admitted, None)?; + let guard = acquire_admission_with_journal( + &mut lifecycle, + |lifecycle| match admission.acquire( + Duration::from_secs(admission_timeout_seconds), + &cancellation, + ) { + Ok(guard) => Ok(guard), + Err(error) => { + lifecycle.fail(RunFailureKindV1::AdmissionRejected)?; + Err(CliError::Admission(error)) + } + }, + |lifecycle| lifecycle.transition_state(RunJournalStateV1::Admitted, None), + |guard| guard.release(), + |lifecycle, event| match event { + RunTerminalJournalEvent::PrimaryFailure(kind) => lifecycle.fail(kind), + RunTerminalJournalEvent::ReleaseFailure => { + lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) + } + }, + )?; if let Err(error) = resource_pre_start(supervisor.clone(), &cancellation) { return finalize_run_terminal( Err::<(), _>(error), @@ -1376,22 +1386,37 @@ fn print_matrix_run( } Ok(()) }, - acquire: || match admission.acquire( - Duration::from_secs(admission_timeout_seconds), - &cancellation, - ) { - Ok(guard) => { - lifecycle - .borrow_mut() - .transition_state(RunJournalStateV1::Admitted, None)?; - Ok(guard) - } - Err(error) => { - lifecycle - .borrow_mut() - .fail(RunFailureKindV1::AdmissionRejected)?; - Err(CliError::Admission(error)) - } + acquire: || { + let mut matrix_lifecycle = &lifecycle; + acquire_admission_with_journal( + &mut matrix_lifecycle, + |lifecycle| match admission.acquire( + Duration::from_secs(admission_timeout_seconds), + &cancellation, + ) { + Ok(guard) => Ok(guard), + Err(error) => { + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::AdmissionRejected)?; + Err(CliError::Admission(error)) + } + }, + |lifecycle| { + lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::Admitted, None) + }, + |guard| guard.release(), + |lifecycle, event| match event { + RunTerminalJournalEvent::PrimaryFailure(kind) => { + lifecycle.borrow_mut().fail(kind) + } + RunTerminalJournalEvent::ReleaseFailure => lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::CleanupPending, None), + }, + ) }, execute: || { resource_pre_start(supervisor.clone(), &cancellation)?; @@ -2096,6 +2121,25 @@ fn finalize_run_terminal( } } +fn acquire_admission_with_journal( + lifecycle: &mut L, + acquire: impl FnOnce(&mut L) -> Result, + record_admitted: impl FnOnce(&mut L) -> Result<(), CliError>, + release: impl FnOnce(G) -> Result<(), AdmissionError>, + mut journal: impl FnMut(&mut L, RunTerminalJournalEvent) -> Result<(), CliError>, +) -> Result { + let guard = acquire(lifecycle)?; + match record_admitted(lifecycle) { + Ok(()) => Ok(guard), + Err(error) => finalize_run_terminal( + Err::(error), + std::convert::identity, + || release(guard), + |event| journal(lifecycle, event), + ), + } +} + /// Caller-owned ports for the Matrix terminal lifecycle. Runtime execution /// yields only unsealed material; terminal release and snapshot cleanup must /// complete before outer receipt sealing and atomic publication. @@ -2948,10 +2992,10 @@ mod tests { Cli, CliError, GuardCommand, GuardExecArgs, GuardExecError, JournalLifecycleObserver, MatrixTerminalLifecyclePorts, ResourceCacheStateArg, ResourceExecutionModeArg, ResourceExecutorArg, RunTerminalJournalEvent, WatchdogCompletionBarrier, - after_validated_matrix_profile_binding, detect_resource_executor, - finalize_benchmark_terminal, finalize_guard_exec_result, finalize_run_terminal, - new_journal_id, orchestrate_matrix_terminal_lifecycle, reconcile_watchdog_outcome, - resource_run_outcome, resource_terminal_detail, + acquire_admission_with_journal, after_validated_matrix_profile_binding, + detect_resource_executor, finalize_benchmark_terminal, finalize_guard_exec_result, + finalize_run_terminal, new_journal_id, orchestrate_matrix_terminal_lifecycle, + reconcile_watchdog_outcome, resource_run_outcome, resource_terminal_detail, }; use clap::{CommandFactory, Parser}; use commit_ci_preflight::admission::AdmissionError; @@ -2976,6 +3020,7 @@ mod tests { RecoveryClassificationV1, RunFailureKindV1, RunJournalEntryV1, RunJournalError, RunJournalStateV1, RunJournalStore, }; + use std::cell::RefCell; use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; @@ -3395,6 +3440,184 @@ timeout_seconds = 60 ); } + #[test] + fn matrix_post_acquisition_journal_failure_releases_the_owned_guard() { + struct OwnedGuard<'a> { + dropped: &'a AtomicUsize, + } + + impl Drop for OwnedGuard<'_> { + fn drop(&mut self) { + self.dropped.fetch_add(1, Ordering::SeqCst); + } + } + + let explicit_releases = AtomicUsize::new(0); + let dropped_guards = AtomicUsize::new(0); + let mut post_acquisition = (); + let result: Result<(), CliError> = + orchestrate_matrix_terminal_lifecycle(MatrixTerminalLifecyclePorts { + validate_before_admission: || Ok(()), + acquire: || { + acquire_admission_with_journal( + &mut post_acquisition, + |_| { + Ok(OwnedGuard { + dropped: &dropped_guards, + }) + }, + |_| Err(CliError::RunJournal(RunJournalError::InvalidTransition)), + |guard| { + explicit_releases.fetch_add(1, Ordering::SeqCst); + drop(guard); + Ok(()) + }, + |_, _| Ok(()), + ) + }, + execute: || unreachable!("acquisition failure stops execution"), + complete: |_| unreachable!("acquisition failure stops completion"), + release: |_: OwnedGuard<'_>| { + explicit_releases.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + journal: |_| { + unreachable!("acquisition failure currently bypasses terminal journal") + }, + cleanup_snapshot: || unreachable!("acquisition failure stops cleanup"), + seal: |_: ()| unreachable!("acquisition failure stops sealing"), + write: |_: ()| unreachable!("acquisition failure stops writing"), + }); + + assert!(matches!( + result, + Err(CliError::RunJournal(RunJournalError::InvalidTransition)) + )); + assert_eq!( + explicit_releases.load(Ordering::SeqCst), + 1, + "a post-acquisition journal failure must explicitly release its guard" + ); + assert_eq!(dropped_guards.load(Ordering::SeqCst), 1); + } + + #[test] + fn run_families_persist_post_acquisition_journal_failure_outcomes() { + struct OwnedGuard<'a> { + releases: &'a AtomicUsize, + release_fails: bool, + } + + impl OwnedGuard<'_> { + fn release(self) -> Result<(), AdmissionError> { + self.releases.fetch_add(1, Ordering::SeqCst); + if self.release_fails { + Err(AdmissionError::Clock) + } else { + Ok(()) + } + } + } + + for (case_index, (family, release_fails)) in [ + ("historical", false), + ("historical", true), + ("matrix", false), + ("matrix", true), + ] + .into_iter() + .enumerate() + { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ccp-post-acquisition-{family}-{case_index}-{}-{stamp}", + std::process::id() + )); + fs::create_dir(&root).expect("temporary journal root"); + let store = RunJournalStore::initialize(&root).expect("journal store"); + let run_id = format!("{case_index:064x}"); + let clock = FixedJournalClock; + store + .create_run(&run_id, "2026-08-27T12:00:00Z") + .expect("created journal"); + store + .transition( + &run_id, + RunJournalStateV1::Admitted, + "2026-08-27T12:00:00Z", + None, + ) + .expect("admitted journal"); + let lifecycle = RefCell::new(JournalLifecycleObserver { + store: &store, + run_id: &run_id, + clock: &clock, + }); + let releases = AtomicUsize::new(0); + let mut lifecycle_ref = &lifecycle; + let result = acquire_admission_with_journal( + &mut lifecycle_ref, + |_| { + Ok(OwnedGuard { + releases: &releases, + release_fails, + }) + }, + |lifecycle| { + lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::Admitted, None) + }, + |guard| guard.release(), + |lifecycle, event| match event { + RunTerminalJournalEvent::PrimaryFailure(kind) => { + lifecycle.borrow_mut().fail(kind) + } + RunTerminalJournalEvent::ReleaseFailure => lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::CleanupPending, None), + }, + ); + + assert_eq!( + releases.load(Ordering::SeqCst), + 1, + "{family} must explicitly release the acquired guard once" + ); + let status = store.status().expect("durable journal status"); + assert_eq!(status.runs.len(), 1); + assert_ne!(status.runs[0].state, Some(RunJournalStateV1::Sealed)); + if release_fails { + assert!(matches!( + result, + Err(CliError::Admission(AdmissionError::Clock)) + )); + assert_eq!( + status.runs[0].state, + Some(RunJournalStateV1::CleanupPending) + ); + assert_eq!( + status.runs[0].classification, + RecoveryClassificationV1::CleanupRequired + ); + } else { + assert!(matches!( + result, + Err(CliError::RunJournal(RunJournalError::InvalidTransition)) + )); + assert_eq!(status.runs[0].state, Some(RunJournalStateV1::Failed)); + assert_eq!( + status.runs[0].classification, + RecoveryClassificationV1::Terminal + ); + } + fs::remove_dir_all(root).expect("remove temporary journal root"); + } + } + #[test] fn matrix_terminal_orchestration_journals_seal_and_write_failures() { use std::cell::RefCell; From a73ebed945d9d9e9744c4aff987589f3478a7f3c Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Thu, 27 Aug 2026 07:59:17 +0200 Subject: [PATCH 31/33] fix: persist pre-admitted cleanup uncertainty --- src/main.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++- src/run_journal.rs | 69 ++++++++++++++++++++-------- 2 files changed, 161 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 157500e..37d42ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1143,6 +1143,9 @@ fn print_run( RunTerminalJournalEvent::ReleaseFailure => { lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) } + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => { + lifecycle.cleanup_pending_after_admission_acquired() + } }, )?; if let Err(error) = resource_pre_start(supervisor.clone(), &cancellation) { @@ -1155,6 +1158,9 @@ fn print_run( RunTerminalJournalEvent::ReleaseFailure => { lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) } + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => { + lifecycle.cleanup_pending_after_admission_acquired() + } }, ); } @@ -1171,6 +1177,9 @@ fn print_run( RunTerminalJournalEvent::ReleaseFailure => { lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) } + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => { + lifecycle.cleanup_pending_after_admission_acquired() + } }, ); } @@ -1216,6 +1225,9 @@ fn print_run( RunTerminalJournalEvent::ReleaseFailure => { lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) } + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => { + lifecycle.cleanup_pending_after_admission_acquired() + } }, )?; if let Err(error) = source_snapshot.cleanup() { @@ -1415,6 +1427,9 @@ fn print_matrix_run( RunTerminalJournalEvent::ReleaseFailure => lifecycle .borrow_mut() .transition_state(RunJournalStateV1::CleanupPending, None), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), }, ) }, @@ -1477,6 +1492,9 @@ fn print_matrix_run( RunTerminalJournalEvent::ReleaseFailure => lifecycle .borrow_mut() .transition_state(RunJournalStateV1::CleanupPending, None), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), }, cleanup_snapshot: || { if let Err(error) = source_snapshot.borrow_mut().cleanup() { @@ -1592,6 +1610,14 @@ impl JournalLifecycleObserver<'_> { fn fail(&mut self, kind: RunFailureKindV1) -> Result<(), CliError> { self.transition_state(RunJournalStateV1::Failed, Some(kind)) } + + fn cleanup_pending_after_admission_acquired(&mut self) -> Result<(), CliError> { + let at_utc = self.clock.now_utc().map_err(CliError::Run)?; + self.store + .transition_cleanup_pending_after_admission_acquired(self.run_id, &at_utc) + .map_err(CliError::RunJournal)?; + Ok(()) + } } impl RunLifecycleObserver for JournalLifecycleObserver<'_> { @@ -2098,6 +2124,7 @@ fn finalize_benchmark_terminal( enum RunTerminalJournalEvent { PrimaryFailure(RunFailureKindV1), ReleaseFailure, + ReleaseFailureAfterAdmissionAcquired, } fn finalize_run_terminal( @@ -2135,7 +2162,13 @@ fn acquire_admission_with_journal( Err::(error), std::convert::identity, || release(guard), - |event| journal(lifecycle, event), + |event| match event { + RunTerminalJournalEvent::ReleaseFailure => journal( + lifecycle, + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired, + ), + event => journal(lifecycle, event), + }, ), } } @@ -3350,6 +3383,7 @@ timeout_seconds = 60 Ok(()) } RunTerminalJournalEvent::PrimaryFailure(_) => Ok(()), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => Ok(()), }, cleanup_snapshot: || { events.borrow_mut().push("snapshot cleanup"); @@ -3419,6 +3453,7 @@ timeout_seconds = 60 Ok(()) } RunTerminalJournalEvent::PrimaryFailure(_) => Ok(()), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => Ok(()), }, cleanup_snapshot: || unreachable!("release failure suppresses snapshot cleanup"), seal: |_: &'static str| unreachable!("release failure suppresses seal"), @@ -3579,6 +3614,9 @@ timeout_seconds = 60 RunTerminalJournalEvent::ReleaseFailure => lifecycle .borrow_mut() .transition_state(RunJournalStateV1::CleanupPending, None), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), }, ); @@ -3618,6 +3656,75 @@ timeout_seconds = 60 } } + #[test] + fn post_acquisition_release_failure_from_created_persists_cleanup_pending() { + struct OwnedGuard<'a> { + releases: &'a AtomicUsize, + } + + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ccp-created-post-acquisition-{}-{stamp}", + std::process::id() + )); + fs::create_dir(&root).expect("temporary journal root"); + let store = RunJournalStore::initialize(&root).expect("journal store"); + let run_id = "c".repeat(64); + let clock = FixedJournalClock; + store + .create_run(&run_id, "2026-08-27T12:00:00Z") + .expect("created journal"); + let lifecycle = RefCell::new(JournalLifecycleObserver { + store: &store, + run_id: &run_id, + clock: &clock, + }); + let releases = AtomicUsize::new(0); + let mut lifecycle_ref = &lifecycle; + let result = acquire_admission_with_journal( + &mut lifecycle_ref, + |_| { + Ok(OwnedGuard { + releases: &releases, + }) + }, + |_| Err(CliError::RunJournal(RunJournalError::InvalidTransition)), + |guard| { + guard.releases.fetch_add(1, Ordering::SeqCst); + Err(AdmissionError::Clock) + }, + |lifecycle, event| match event { + RunTerminalJournalEvent::PrimaryFailure(kind) => lifecycle.borrow_mut().fail(kind), + RunTerminalJournalEvent::ReleaseFailure => lifecycle + .borrow_mut() + .transition_state(RunJournalStateV1::CleanupPending, None), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), + }, + ); + + assert_eq!(releases.load(Ordering::SeqCst), 1); + assert!(matches!( + result, + Err(CliError::Admission(AdmissionError::Clock)) + )); + let status = store.status().expect("durable journal status"); + assert_eq!( + status.runs[0].state, + Some(RunJournalStateV1::CleanupPending) + ); + assert_eq!( + status.runs[0].classification, + RecoveryClassificationV1::CleanupRequired + ); + assert_ne!(status.runs[0].state, Some(RunJournalStateV1::Sealed)); + fs::remove_dir_all(root).expect("remove temporary journal root"); + } + #[test] fn matrix_terminal_orchestration_journals_seal_and_write_failures() { use std::cell::RefCell; @@ -3789,6 +3896,9 @@ timeout_seconds = 60 RunTerminalJournalEvent::ReleaseFailure => lifecycle .borrow_mut() .transition_state(RunJournalStateV1::CleanupPending, None), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), }, cleanup_snapshot: || Ok(()), seal: |material| { diff --git a/src/run_journal.rs b/src/run_journal.rs index 50768d3..13441b3 100644 --- a/src/run_journal.rs +++ b/src/run_journal.rs @@ -275,7 +275,10 @@ impl RunJournalStore { validate_timestamp(at_utc)?; let entries = self.read_entries(run_id)?; let previous = entries.last().ok_or(RunJournalError::Corrupt)?; - if !legal_transition(previous.state, state) { + if !legal_transition(previous.state, state) + || (previous.state == RunJournalStateV1::Created + && state == RunJournalStateV1::CleanupPending) + { return Err(RunJournalError::InvalidTransition); } if (state == RunJournalStateV1::Failed) != failure_kind.is_some() { @@ -284,6 +287,36 @@ impl RunJournalStore { self.append_entry(run_id, previous.seq + 1, state, at_utc, failure_kind) } + /// Persist cleanup uncertainty after admission ownership was acquired but + /// its durable `Admitted` record could not be written. + pub fn transition_cleanup_pending_after_admission_acquired( + &self, + run_id: &str, + at_utc: &str, + ) -> Result { + validate_run_id(run_id)?; + validate_timestamp(at_utc)?; + let entries = self.read_entries(run_id)?; + let previous = entries.last().ok_or(RunJournalError::Corrupt)?; + if !matches!( + previous.state, + RunJournalStateV1::Created + | RunJournalStateV1::Admitted + | RunJournalStateV1::Prepared + | RunJournalStateV1::Executing + | RunJournalStateV1::Finalizing + ) { + return Err(RunJournalError::InvalidTransition); + } + self.append_entry( + run_id, + previous.seq + 1, + RunJournalStateV1::CleanupPending, + at_utc, + None, + ) + } + /// Reserve one exact CCP-owned location for ephemeral run resources. /// The directory remains inside the owned run tree so `recover apply` /// quarantines it together with an interrupted journal. @@ -740,24 +773,22 @@ fn legal_transition(previous: RunJournalStateV1, next: RunJournalStateV1) -> boo use RunJournalStateV1 as State; matches!( (previous, next), - (State::Created, State::Admitted | State::Failed) - | ( - State::Admitted, - State::Prepared | State::Failed | State::CleanupPending - ) - | ( - State::Prepared, - State::Executing | State::Failed | State::CleanupPending - ) - | ( - State::Executing, - State::Finalizing | State::Failed | State::CleanupPending - ) - | ( - State::Finalizing, - State::Sealed | State::Failed | State::CleanupPending - ) - | (State::CleanupPending, State::Failed) + ( + State::Created, + State::Admitted | State::Failed | State::CleanupPending + ) | ( + State::Admitted, + State::Prepared | State::Failed | State::CleanupPending + ) | ( + State::Prepared, + State::Executing | State::Failed | State::CleanupPending + ) | ( + State::Executing, + State::Finalizing | State::Failed | State::CleanupPending + ) | ( + State::Finalizing, + State::Sealed | State::Failed | State::CleanupPending + ) | (State::CleanupPending, State::Failed) ) } From faf587890e4f899803f027660bc66452623f405e Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Fri, 28 Aug 2026 06:14:24 +0200 Subject: [PATCH 32/33] test: honor CCP test root in matrix fixtures --- src/matrix.rs | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/matrix.rs b/src/matrix.rs index 0758325..b1ad385 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1143,6 +1143,31 @@ mod tests { const OUTPUT_DIGEST: &str = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + #[test] + fn matrix_fixture_root_is_nested_under_selected_test_root() { + assert_eq!( + matrix_fixture_root(Path::new("/private-test-root"), "snapshot", 7), + PathBuf::from("/private-test-root/.ccp-matrix-snapshot-7") + ); + } + + fn matrix_fixture_root(base: &Path, name: &str, process_id: u32) -> PathBuf { + base.join(format!(".ccp-matrix-{name}-{process_id}")) + } + + fn matrix_test_root(name: &str) -> PathBuf { + let base = std::env::var_os("CCP_TEST_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::current_dir() + .expect("current directory") + .parent() + .expect("repository parent") + .to_path_buf() + }); + matrix_fixture_root(&base, name, std::process::id()) + } + fn envelope(profile: MatrixPlanProfile) -> MatrixPlanEnvelopeV2 { build_matrix_plan( MatrixConfigV2::parse(&format!( @@ -1478,14 +1503,7 @@ pids_limit = 16 #[test] fn matrix_runtimes_share_one_immutable_source_snapshot() { - let root = std::env::current_dir() - .expect("current directory") - .parent() - .expect("repository parent") - .join(format!( - ".ccp-matrix-source-snapshot-{}", - std::process::id() - )); + let root = matrix_test_root("source-snapshot"); if root.exists() { fs::remove_dir_all(&root).expect("clean fixture root"); } @@ -1566,14 +1584,7 @@ pids_limit = 16 #[test] fn executor_rejects_tampered_legacy_plan_before_supervisor_execution() { - let root = std::env::current_dir() - .expect("current directory") - .parent() - .expect("repository parent") - .join(format!( - ".ccp-matrix-provenance-test-{}", - std::process::id() - )); + let root = matrix_test_root("provenance-test"); if root.exists() { fs::remove_dir_all(&root).expect("clean fixture root"); } From 27adf8d0820b3cd96f9c5e149de9b580ae41f639 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Sat, 29 Aug 2026 00:36:20 +0200 Subject: [PATCH 33/33] fix: support bounded large source blobs --- docs/INVARIANT_EVIDENCE_MATRIX.md | 2 +- docs/TROUBLESHOOTING.md | 21 +++ src/matrix.rs | 5 + src/process.rs | 67 ++++++++- src/run.rs | 4 + src/source_snapshot.rs | 220 ++++++++++++++++++++++++++++-- 6 files changed, 307 insertions(+), 12 deletions(-) diff --git a/docs/INVARIANT_EVIDENCE_MATRIX.md b/docs/INVARIANT_EVIDENCE_MATRIX.md index b21c038..6878b03 100644 --- a/docs/INVARIANT_EVIDENCE_MATRIX.md +++ b/docs/INVARIANT_EVIDENCE_MATRIX.md @@ -16,7 +16,7 @@ deterministic source evidence from native qualification still pending. | Invariant | Implemented evidence | Residual gap | Proof artifact | Gate | | --- | --- | --- | --- | --- | -| Exact commit bytes are isolated from the user's mutable working tree | `SourceSnapshot::materialize` reads the committed tree and blobs through Git, writes a CCP-owned tree, and `PreparedWorkspace::prepare_snapshot` mounts only that tree | Native runtime observation remains pending | `src/source_snapshot.rs`, `src/workspace.rs`, and the snapshot-backed run test | Deterministic isolation PASS; native qualification PENDING | +| Exact commit bytes are isolated from the user's mutable working tree | `SourceSnapshot::materialize` reads the committed tree and blobs through Git, writes a CCP-owned tree, and `PreparedWorkspace::prepare_snapshot` mounts only that tree; source blobs use a dedicated 64 MiB ceiling while ordinary process output remains limited to 1 MiB | Native runtime observation remains pending | `src/source_snapshot.rs`, `src/process.rs`, and the real-Git large-blob snapshot test | Deterministic isolation PASS; native qualification PENDING | | Source identity is canonical and reproducible | `SourceManifestV1` sorts entries and binds commit, path, mode, object kind and object ID; canonical SHA-256 produces `manifest_digest` | Cross-platform native vectors remain pending | Source-snapshot unit tests and receipt v2 golden fixture | Repeated supported manifests have identical digest: PASS | | Unsupported Git states fail closed | Submodules, symlinks and LFS pointers are rejected; unsupported modes fail; executable entries are supported on Unix and rejected on unsupported platforms. Sparse working-tree shape is ignored because materialization reads the full committed tree; unavailable objects fail as Git errors | Windows-native executable-mode behavior is not qualified | Typed `SourceSnapshotError` variants and deterministic rejection tests | Deterministic policy PASS; Windows-native qualification PENDING | | Receipt evidence binds source identity | Snapshot-backed runs publish strict receipt v2 with strategy, manifest digest and entry count; historical v1 remains readable without implied snapshot assurance | Trusted producer identity and signing are later tranches | `schema/receipt-v2.schema.json`, `tests/fixtures/receipt-v2-pass.json`, and dual-version verifier dispatch | Tampered snapshot digest fails integrity: PASS | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 8bc86d4..5d96344 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -100,6 +100,27 @@ commit-ci-preflight dry-run --config .commit-ci-preflight.toml --repository . -- Reproduce only the failing explicit check in a deliberate diagnostic context. Do not synthesize a receipt or reinterpret an absent receipt as PASS. +## Source snapshot fails before project checks + +Snapshot materialization reads the exact committed Git blobs before project +checks start. Regular blobs up to 64 MiB are accepted by this producer through +a snapshot-only capture path. The ordinary process stdout and stderr limit +remains 1 MiB. A larger source blob fails closed before its contents are read +and reports the path, observed size, and 64 MiB limit; the partial snapshot is +removed. + +Older producers used the ordinary 1 MiB process limit for source blobs as well. +They can therefore stop with code `70` and the generic message `bounded Git +snapshot command failed` when an otherwise valid tracked file is only slightly +larger than 1 MiB. `plan`, `doctor`, and `dry-run` do not materialize Git blobs, +so their success does not establish compatibility with that older ceiling. + +Preserve the exact producer hash, source commit, failing repository commit, and +terminal output. Inspect tracked blob sizes without changing the repository, +then use a reviewed and qualified producer that supports the required bounded +snapshot size. Do not delete scientific evidence, rewrite history, synthesize +a receipt, or retry a consumed run authorization merely to bypass this failure. + ## First Rust check fails only inside the read-only container The repository's own `.commit-ci-preflight.toml` uses schema `1.1` and derives diff --git a/src/matrix.rs b/src/matrix.rs index b1ad385..7bd7742 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1397,6 +1397,11 @@ pids_limit = 16 Some("ls-tree") => { format!("100644 blob {}\tREADME.md\0", "c".repeat(40)).into_bytes() } + Some("cat-file") + if request.argv.get(1).is_some_and(|argument| argument == "-s") => + { + b"16\n".to_vec() + } Some("cat-file") => b"snapshot source\n".to_vec(), Some("hash-object") => format!("{}\n", "c".repeat(40)).into_bytes(), Some("status") => Vec::new(), diff --git a/src/process.rs b/src/process.rs index d762f90..0c2725a 100644 --- a/src/process.rs +++ b/src/process.rs @@ -36,6 +36,7 @@ const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(10); const DEFAULT_GRACE_PERIOD: Duration = Duration::from_millis(500); const GROUP_EXIT_POLL: Duration = Duration::from_millis(5); const MAX_CAPTURE_BYTES: usize = 1_048_576; +pub(crate) const MAX_SOURCE_BLOB_CAPTURE_BYTES: usize = 64 * 1_048_576; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RunIdentity { @@ -137,6 +138,13 @@ pub enum OutputMode { impl ProcessRequest { pub fn validate(&self) -> Result<(), ProcessError> { + self.validate_with_capture_ceiling(MAX_CAPTURE_BYTES) + } + + fn validate_with_capture_ceiling( + &self, + max_allowed_capture_bytes: usize, + ) -> Result<(), ProcessError> { if self.program.is_empty() { return Err(ProcessError::InvalidRequest("program must not be empty")); } @@ -145,7 +153,7 @@ impl ProcessRequest { "timeout must be greater than zero", )); } - if self.max_capture_bytes == 0 || self.max_capture_bytes > MAX_CAPTURE_BYTES { + if self.max_capture_bytes == 0 || self.max_capture_bytes > max_allowed_capture_bytes { return Err(ProcessError::InvalidRequest( "max_capture_bytes must be between 1 and 1048576", )); @@ -232,6 +240,15 @@ pub trait SupervisorPort: Send + Sync { cancellation: &CancellationToken, generation: &GenerationGuard, ) -> Result; + + fn execute_source_blob( + &self, + request: &ProcessRequest, + cancellation: &CancellationToken, + generation: &GenerationGuard, + ) -> Result { + self.execute(request, cancellation, generation) + } } pub trait ProcessSpawner: Send + Sync { @@ -389,6 +406,21 @@ impl SupervisorPort for ProcessSupervisor { ) -> Result { self.execute_with_output(request, cancellation, generation, OutputMode::Capture) } + + fn execute_source_blob( + &self, + request: &ProcessRequest, + cancellation: &CancellationToken, + generation: &GenerationGuard, + ) -> Result { + self.execute_with_output_and_capture_ceiling( + request, + cancellation, + generation, + OutputMode::Capture, + MAX_SOURCE_BLOB_CAPTURE_BYTES, + ) + } } impl ProcessSupervisor { @@ -399,7 +431,24 @@ impl ProcessSupervisor { generation: &GenerationGuard, output_mode: OutputMode, ) -> Result { - request.validate()?; + self.execute_with_output_and_capture_ceiling( + request, + cancellation, + generation, + output_mode, + MAX_CAPTURE_BYTES, + ) + } + + fn execute_with_output_and_capture_ceiling( + &self, + request: &ProcessRequest, + cancellation: &CancellationToken, + generation: &GenerationGuard, + output_mode: OutputMode, + max_allowed_capture_bytes: usize, + ) -> Result { + request.validate_with_capture_ceiling(max_allowed_capture_bytes)?; generation.ensure_current(&request.identity)?; let started = Instant::now(); let deadline = ProcessDeadline::new(started, request.timeout, self.grace_period); @@ -1198,6 +1247,20 @@ mod tests { assert_eq!(state.lock().expect("state").spawned, 0); } + #[test] + fn ordinary_process_capture_remains_limited_to_one_mib() { + let (supervisor, state, _) = supervisor(FakeBehavior::Complete); + let mut request = request(); + request.max_capture_bytes = 1_048_577; + let guard = GenerationGuard::new(request.identity.clone()); + + assert!(matches!( + supervisor.execute(&request, &CancellationToken::default(), &guard), + Err(ProcessError::InvalidRequest(_)) + )); + assert_eq!(state.lock().expect("state").spawned, 0); + } + #[test] fn cancellation_token_is_thread_safe() { let token = CancellationToken::default(); diff --git a/src/run.rs b/src/run.rs index 1e86837..159cbac 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1238,6 +1238,10 @@ mod tests { } let stdout = if git_command == Some("ls-tree") { format!("100644 blob {}\tREADME.md\0", "c".repeat(40)).into_bytes() + } else if git_command == Some("cat-file") + && request.argv.get(1).is_some_and(|argument| argument == "-s") + { + b"16\n".to_vec() } else if git_command == Some("cat-file") { b"snapshot source\n".to_vec() } else if git_command == Some("hash-object") { diff --git a/src/source_snapshot.rs b/src/source_snapshot.rs index 5ebfd32..48365df 100644 --- a/src/source_snapshot.rs +++ b/src/source_snapshot.rs @@ -16,19 +16,16 @@ use serde::{Deserialize, Serialize}; use crate::config::{ArtifactKind, ExecutionPlanEnvelopeV1, NormalizedCheck}; use crate::process::{ - CancellationToken, CleanupStatus, GenerationGuard, ProcessRequest, ProcessTermination, - RunIdentity, SupervisorPort, + CancellationToken, CleanupStatus, GenerationGuard, MAX_SOURCE_BLOB_CAPTURE_BYTES, + ProcessRequest, ProcessTermination, RunIdentity, SupervisorPort, }; use crate::receipt::canonical_digest; pub const SOURCE_SNAPSHOT_SCHEMA_VERSION: &str = "1.0"; const GIT_TIMEOUT: Duration = Duration::from_secs(30); -// ProcessSupervisor deliberately caps every captured child stream at one MiB. -// Snapshot materialization uses the same bound so oversized Git objects fail -// closed instead of bypassing the supervisor's capture contract. const GIT_CAPTURE_LIMIT: usize = 1_048_576; const TREE_CAPTURE_LIMIT: usize = GIT_CAPTURE_LIMIT; -const BLOB_CAPTURE_LIMIT: usize = GIT_CAPTURE_LIMIT; +const BLOB_CAPTURE_LIMIT: usize = MAX_SOURCE_BLOB_CAPTURE_BYTES; const MAX_ENTRIES: usize = 50_000; const LFS_HEADER: &[u8] = b"version https://git-lfs.github.com/spec/v1\n"; @@ -133,10 +130,30 @@ impl SourceSnapshot { .blob_oid .as_deref() .ok_or(SourceSnapshotError::InvalidTree)?; - let bytes = execute_git( + let size_bytes = execute_git( + &repository, + &["cat-file", "-s", oid], + GIT_CAPTURE_LIMIT, + supervisor, + cancellation, + generation, + identity, + )?; + let size = std::str::from_utf8(&size_bytes) + .map_err(|_| SourceSnapshotError::InvalidTree)? + .trim() + .parse::() + .map_err(|_| SourceSnapshotError::InvalidTree)?; + if size > BLOB_CAPTURE_LIMIT as u64 { + return Err(SourceSnapshotError::BlobTooLarge { + path: entry.path.clone(), + size, + limit: BLOB_CAPTURE_LIMIT as u64, + }); + } + let bytes = execute_git_blob( &repository, &["cat-file", "blob", oid], - BLOB_CAPTURE_LIMIT, supervisor, cancellation, generation, @@ -528,6 +545,37 @@ fn execute_git( Ok(result.stdout.bytes) } +fn execute_git_blob( + repository: &Path, + argv: &[&str], + supervisor: &dyn SupervisorPort, + cancellation: &CancellationToken, + generation: &GenerationGuard, + identity: &RunIdentity, +) -> Result, SourceSnapshotError> { + let request = ProcessRequest { + identity: identity.clone(), + program: OsString::from("git"), + argv: argv.iter().map(OsString::from).collect(), + current_dir: repository.to_path_buf(), + environment: git_environment(), + timeout: GIT_TIMEOUT, + max_capture_bytes: BLOB_CAPTURE_LIMIT, + }; + let result = supervisor + .execute_source_blob(&request, cancellation, generation) + .map_err(|_| SourceSnapshotError::GitFailure)?; + if result.termination != ProcessTermination::Completed + || result.cleanup != CleanupStatus::Verified + || result.exit.map(|status| status.success) != Some(true) + || result.stdout.truncated + || result.stderr.truncated + { + return Err(SourceSnapshotError::GitFailure); + } + Ok(result.stdout.bytes) +} + fn git_environment() -> BTreeMap { [ "PATH", @@ -581,6 +629,7 @@ pub enum SourceSnapshotError { TooManyEntries, DestinationExists, GitFailure, + BlobTooLarge { path: String, size: u64, limit: u64 }, UnsupportedMode(String), UnsupportedSubmodule(String), UnsupportedSymlink(String), @@ -602,6 +651,12 @@ impl fmt::Display for SourceSnapshotError { Self::TooManyEntries => formatter.write_str("source tree exceeds the entry limit"), Self::DestinationExists => formatter.write_str("source snapshot destination exists"), Self::GitFailure => formatter.write_str("bounded Git snapshot command failed"), + Self::BlobTooLarge { path, size, limit } => { + write!( + formatter, + "source blob exceeds the {limit} byte limit: {path} is {size} bytes" + ) + } Self::UnsupportedMode(mode) => write!(formatter, "unsupported Git mode: {mode}"), Self::UnsupportedSubmodule(path) => { write!(formatter, "submodule is unsupported: {path}") @@ -632,10 +687,13 @@ impl std::error::Error for SourceSnapshotError {} #[cfg(test)] mod tests { use super::*; + use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; use crate::config::ConfigV1; - use crate::process::{CapturedStream, ExitOutcome, ProcessError, ProcessResult}; + use crate::process::{ + CapturedStream, ExitOutcome, ProcessError, ProcessResult, ProcessSupervisor, + }; static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0); const COMMIT: &str = "1111111111111111111111111111111111111111"; @@ -657,6 +715,10 @@ mod tests { .collect(); let stdout = if argv.first().is_some_and(|part| part == "ls-tree") { format!("100644 blob {OID}\tREADME.md\0").into_bytes() + } else if argv.first().is_some_and(|part| part == "cat-file") + && argv.get(1).is_some_and(|part| part == "-s") + { + b"6\n".to_vec() } else if argv.first().is_some_and(|part| part == "cat-file") { b"hello\n".to_vec() } else if argv.first().is_some_and(|part| part == "hash-object") { @@ -681,6 +743,49 @@ mod tests { } } + struct OversizedGit; + + impl SupervisorPort for OversizedGit { + fn execute( + &self, + request: &ProcessRequest, + _cancellation: &CancellationToken, + _generation: &GenerationGuard, + ) -> Result { + let argv: Vec<_> = request + .argv + .iter() + .map(|part| part.to_string_lossy()) + .collect(); + let (stdout, truncated) = if argv.first().is_some_and(|part| part == "ls-tree") { + ( + format!("100644 blob {OID}\tlarge.bin\0").into_bytes(), + false, + ) + } else if argv.first().is_some_and(|part| part == "cat-file") + && argv.get(1).is_some_and(|part| part == "-s") + { + (format!("{}\n", BLOB_CAPTURE_LIMIT + 1).into_bytes(), false) + } else if argv.first().is_some_and(|part| part == "cat-file") { + (vec![b'x'; 16], true) + } else { + (Vec::new(), false) + }; + Ok(ProcessResult { + identity: request.identity.clone(), + termination: ProcessTermination::Completed, + cleanup: CleanupStatus::Verified, + exit: Some(ExitOutcome { + success: true, + code: Some(0), + }), + stdout: CapturedStream::from_captured(stdout, truncated), + stderr: CapturedStream::from_captured(Vec::new(), false), + elapsed_millis: 1, + }) + } + } + fn fixture_root(label: &str) -> PathBuf { std::env::temp_dir().join(format!( "ccp-source-snapshot-{label}-{}-{}", @@ -698,6 +803,103 @@ mod tests { } } + fn run_git(repository: &Path, argv: &[&str]) -> Vec { + let output = Command::new("git") + .args(argv) + .current_dir(repository) + .output() + .expect("run git fixture command"); + assert!( + output.status.success(), + "git fixture command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout + } + + #[test] + fn materialization_accepts_regular_git_blobs_larger_than_one_mib() { + let root = fixture_root("large-blob"); + let repository = root.join("repository"); + fs::create_dir_all(&repository).expect("fixture repository"); + run_git(&repository, &["init", "--quiet"]); + let expected = vec![b'x'; 1_200_000]; + fs::write(repository.join("large.bin"), &expected).expect("large fixture blob"); + run_git(&repository, &["add", "large.bin"]); + run_git( + &repository, + &[ + "-c", + "user.name=CCP fixture", + "-c", + "user.email=ccp-fixture@example.invalid", + "commit", + "--quiet", + "-m", + "large blob fixture", + ], + ); + let commit = String::from_utf8(run_git(&repository, &["rev-parse", "HEAD"])) + .expect("commit is UTF-8") + .trim() + .to_owned(); + let identity = RunIdentity { + commit: Some(commit.clone()), + ..identity() + }; + let generation = GenerationGuard::new(identity.clone()); + let resource = root.join("resource"); + + let mut snapshot = SourceSnapshot::materialize( + &repository, + &commit, + &resource, + &ProcessSupervisor::standard(), + &CancellationToken::default(), + &generation, + &identity, + ) + .expect("large tracked blobs below the source snapshot limit should materialize"); + + assert_eq!( + fs::read(snapshot.root().join("large.bin")).unwrap(), + expected + ); + snapshot.cleanup().expect("snapshot cleanup"); + fs::remove_dir_all(root).expect("fixture cleanup"); + } + + #[test] + fn oversized_blob_reports_path_size_and_limit_before_materialization() { + let root = fixture_root("oversized-blob"); + fs::create_dir(&root).expect("fixture root"); + let resource = root.join("resource"); + let identity = identity(); + let generation = GenerationGuard::new(identity.clone()); + + let error = SourceSnapshot::materialize( + &root, + COMMIT, + &resource, + &OversizedGit, + &CancellationToken::default(), + &generation, + &identity, + ) + .expect_err("oversized source blob must fail closed"); + + assert_eq!( + error.to_string(), + format!( + "source blob exceeds the {} byte limit: large.bin is {} bytes", + BLOB_CAPTURE_LIMIT, + BLOB_CAPTURE_LIMIT + 1 + ) + ); + assert!(!resource.exists(), "failed snapshot must be cleaned up"); + fs::remove_dir(root).expect("fixture cleanup"); + } + #[test] fn materialization_is_manifest_bound_and_revalidates() { let root = fixture_root("pass");