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 dee13c1..eb54cdf 100644 --- a/docs/ADOPTION_GUIDE.md +++ b/docs/ADOPTION_GUIDE.md @@ -329,3 +329,13 @@ 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 + +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 +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..9beaa6e 100644 --- a/docs/CACHE_AND_WORKSPACE.md +++ b/docs/CACHE_AND_WORKSPACE.md @@ -204,3 +204,11 @@ 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 + +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 +producer suffix are evidence fields, not policy inference or general trust. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cb5694b..d3e1160 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..752f7ea 100644 --- a/docs/GITHUB_GATE.md +++ b/docs/GITHUB_GATE.md @@ -170,3 +170,12 @@ 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. +Rollback target is `current-v2`. diff --git a/docs/INVARIANT_EVIDENCE_MATRIX.md b/docs/INVARIANT_EVIDENCE_MATRIX.md index 5575917..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 | @@ -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 | `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/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/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` 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/LOCAL_RUN.md b/docs/LOCAL_RUN.md index 5dd7bc8..2b57803 100644 --- a/docs/LOCAL_RUN.md +++ b/docs/LOCAL_RUN.md @@ -213,3 +213,21 @@ 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 + +```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 +`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..138817f 100644 --- a/docs/MULTI_RUNTIME_RECEIPTS.md +++ b/docs/MULTI_RUNTIME_RECEIPTS.md @@ -164,3 +164,22 @@ 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 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 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 +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 +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..ffad927 100644 --- a/docs/RECEIPT_SPEC.md +++ b/docs/RECEIPT_SPEC.md @@ -202,3 +202,14 @@ 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 + +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`, +`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..173dc0c 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -147,3 +147,24 @@ 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 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 +`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::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 +`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/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e97d802..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 @@ -228,3 +249,13 @@ 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 + +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 +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/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..b1ca1ca --- /dev/null +++ b/docs/adr/0005-matrix-v2-legacy-plan-profile.md @@ -0,0 +1,113 @@ +# ADR 0005: Explicit Matrix V2 legacy plan profile + +- Status: Accepted +- 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. +- 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 + +- **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/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 new file mode 100644 index 0000000..c884625 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-matrix-v2-legacy-plan-profile-design.md @@ -0,0 +1,430 @@ +# Matrix V2 legacy plan profile design + +Status: approved for implementation after official-contract reconciliation +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. + +## 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 +`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`; +- 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. + +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. 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/main.rs b/src/main.rs index 7a80c4f..37d42ea 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; @@ -40,7 +41,9 @@ 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, + 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, @@ -116,6 +119,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 +130,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 +143,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 +162,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 +314,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 +482,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 +1001,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() { @@ -1066,17 +1124,30 @@ 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) + } + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => { + lifecycle.cleanup_pending_after_admission_acquired() + } + }, + )?; if let Err(error) = resource_pre_start(supervisor.clone(), &cancellation) { return finalize_run_terminal( Err::<(), _>(error), @@ -1087,6 +1158,9 @@ fn print_run( RunTerminalJournalEvent::ReleaseFailure => { lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) } + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => { + lifecycle.cleanup_pending_after_admission_acquired() + } }, ); } @@ -1103,6 +1177,9 @@ fn print_run( RunTerminalJournalEvent::ReleaseFailure => { lifecycle.transition_state(RunJournalStateV1::CleanupPending, None) } + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => { + lifecycle.cleanup_pending_after_admission_acquired() + } }, ); } @@ -1124,6 +1201,7 @@ fn print_run( envelope: &envelope, repository: &location.repository, cache: &cache, + producer_version: env!("CARGO_PKG_VERSION"), generation, source_snapshot: Some(&source_snapshot), }, @@ -1147,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() { @@ -1191,21 +1272,30 @@ 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, 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 root = resolve_cache_root(location)?; + let envelope = load_matrix_plan(path, profile)?; + 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( @@ -1213,110 +1303,230 @@ 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)?; - let admission = - AdmissionCoordinator::platform_for(&location.repository).map_err(CliError::Admission)?; - let guard = match admission.acquire( - Duration::from_secs(admission_timeout_seconds), + 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(guard) => guard, + Ok(commit) => commit, Err(error) => { - lifecycle.fail(RunFailureKindV1::AdmissionRejected)?; - return Err(CliError::Admission(error)); + lifecycle + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::Run(RunError::SourceSnapshot(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, - 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) - } - }, - ); - } - }; - Some(ResourceWatchdog::start( - ResourceProbe::new(SupervisorResourceRunner::new( - supervisor.clone(), - current_dir, - cancellation.clone(), - )), - cancellation.clone(), - )) - } else { - None + let source_identity = RunIdentity { + commit: Some(commit.clone()), + ..source_identity }; - 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_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 + .borrow_mut() + .fail(RunFailureKindV1::PreparationFailed)?; + return Err(CliError::RunJournal(error)); + } + }; + let mut source_snapshot = match SourceSnapshot::materialize( + &location.repository, + &commit, + &source_resource, 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))) + &source_generation, + &source_identity, + ) { + Ok(snapshot) => snapshot, + Err(error) => { + 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 + .borrow_mut() + .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 + .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)?; + 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: || { + 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), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), + }, + ) + }, + 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), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), + }, + 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(()) }, - )?; - 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 { @@ -1326,6 +1536,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: {}", @@ -1397,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<'_> { @@ -1447,16 +1668,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 +1759,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 +1805,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 +1836,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); @@ -1840,6 +2124,7 @@ fn finalize_benchmark_terminal( enum RunTerminalJournalEvent { PrimaryFailure(RunFailureKindV1), ReleaseFailure, + ReleaseFailureAfterAdmissionAcquired, } fn finalize_run_terminal( @@ -1863,6 +2148,129 @@ 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| match event { + RunTerminalJournalEvent::ReleaseFailure => journal( + lifecycle, + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired, + ), + 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. +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, +{ + 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 { admission: Option, watchdog: WatchdogCompletionBarrier, @@ -2256,9 +2664,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 +2732,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 +2761,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); @@ -2608,10 +3022,12 @@ impl std::error::Error for CliMessageError {} #[cfg(test)] 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, + Cli, CliError, GuardCommand, GuardExecArgs, GuardExecError, JournalLifecycleObserver, + MatrixTerminalLifecyclePorts, ResourceCacheStateArg, ResourceExecutionModeArg, + ResourceExecutorArg, RunTerminalJournalEvent, WatchdogCompletionBarrier, + 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}; @@ -2620,6 +3036,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, @@ -2629,13 +3048,26 @@ mod tests { ResourceCommand, ResourceCommandRunner, ResourceProbe, ResourceProbeError, ResourceWatchdog, }; use commit_ci_preflight::resource_history::{ResourceExecutorV2, ResourceTerminalDetailV2}; - 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::cell::RefCell; 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) @@ -2842,6 +3274,720 @@ 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 matrix_terminal_orchestration_orders_finalization_before_publication() { + use std::cell::RefCell; + + #[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(()) + }, + 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"); + 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(()), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => 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(()) + }, + 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(()), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => 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!( + result, + Err(CliError::Admission(AdmissionError::Clock)) + )); + assert_eq!( + &*events.borrow(), + &[ + "validate", + "acquire", + "complete/join", + "release", + "journal cleanup-pending" + ] + ); + } + + #[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), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), + }, + ); + + 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 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; + + #[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 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), + RunTerminalJournalEvent::ReleaseFailureAfterAdmissionAcquired => lifecycle + .borrow_mut() + .cleanup_pending_after_admission_acquired(), + }, + 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([ diff --git a/src/matrix.rs b/src/matrix.rs index e6cb341..7bd7742 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, @@ -44,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, @@ -148,6 +150,26 @@ 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, +} + +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)] @@ -186,6 +208,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 +302,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,21 +310,111 @@ 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 canonical_bytes(&self) -> Result, MatrixError> { - let expected = canonical_digest(&self.plan).map_err(MatrixError::Receipt)?; - if expected != self.plan_digest { - return Err(MatrixError::PlanDigestMismatch); + pub fn profile(&self) -> MatrixPlanProfile { + self.profile + } + + 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> { + 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() + .find(|runtime| runtime.id == id) + .map(|runtime| runtime.configuration_digest.as_str()) + .ok_or_else(|| MatrixError::UnknownRuntime(id.to_owned())), + } + } + + 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), + } + } + + pub 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> { + 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 { @@ -299,10 +427,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 { @@ -314,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)] @@ -322,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. @@ -330,23 +504,35 @@ 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 -/// 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(); 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; @@ -356,8 +542,9 @@ 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, + source_snapshot: Some(request.source_snapshot), }, runtime.as_ref(), supervisor, @@ -377,7 +564,8 @@ 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(); let repository_evidence = first.receipt.receipt.repository.clone(); let redaction_policy_version = first.receipt.receipt.redaction_policy_version.clone(); let statuses: Vec<_> = checks @@ -391,14 +579,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 { + 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, @@ -406,24 +594,63 @@ 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) .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( + 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 { @@ -896,6 +1123,542 @@ fn equal( } } +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + + use crate::cache::{CacheRootOptions, PlatformFamily, ResolvedCacheRoot}; + 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"; + 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!( + 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") + } + } + + 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") + 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(), + 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 = matrix_test_root("source-snapshot"); + 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.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]); + 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 = matrix_test_root("provenance-test"); + 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); + 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; + + let result = execute_matrix_run_v2( + &MatrixRunRequestV2 { + envelope: &envelope, + repository: &repository, + cache: &cache, + generation: 7, + source_snapshot: &snapshot, + }, + &supervisor, + &CancellationToken::default(), + &SystemClock, + &mut barrier, + ); + + 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"); + } +} + #[derive(Debug)] pub enum MatrixError { Io(std::io::Error), @@ -914,6 +1677,7 @@ pub enum MatrixError { UnknownRuntime(String), RuntimeWithoutRequiredCheck(String), CrossRuntimeDependency { check: String, dependency: String }, + LegacyPlanNotRepresentable(&'static str), PlanDigestMismatch, ReceiptIdMismatch, InvalidReceipt, @@ -951,6 +1715,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..7bdf7c3 --- /dev/null +++ b/src/matrix_legacy.rs @@ -0,0 +1,287 @@ +// 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::{ + NormalizedArtifactContract, NormalizedCache, NormalizedCheck, NormalizedEnvironment, + NormalizedFixedEnvironment, NormalizedReceipt, NormalizedRuntime, + NormalizedRuntimeInternalEnvironment, +}; +use crate::matrix::{MatrixError, MatrixPlanV2, MatrixRuntimePlanV2}; +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())) + } + + pub(crate) fn report_value(&self) -> Result { + serde_json::to_value(&self.plan).map_err(MatrixError::Json) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyExecutionPlanV1 { + schema_version: String, + project: String, + runtime: LegacyNormalizedRuntime, + receipt: LegacyNormalizedReceipt, + environment_allow: Vec, + caches: Vec, + checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct LegacyMatrixPlanV2 { + schema_version: String, + project: String, + receipt: LegacyNormalizedReceipt, + 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 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, + 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 { + let MatrixPlanV2 { + schema_version, + project, + receipt, + environment, + caches, + runtimes, + } = plan; + // 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_plan in runtimes { + let MatrixRuntimePlanV2 { + id, + configuration_digest: _, + runtime, + checks, + } = runtime_plan; + let checks = checks + .iter() + .map(legacy_check) + .collect::, MatrixError>>()?; + 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: legacy_receipt.clone(), + environment_allow: environment_allow.clone(), + caches: legacy_caches.clone(), + checks: checks.clone(), + }) + .map_err(MatrixError::Receipt)?; + runtime_digests.insert(id.clone(), configuration_digest.clone()); + legacy_runtimes.push(LegacyMatrixRuntimePlanV2 { + id: id.clone(), + configuration_digest, + runtime: legacy_runtime, + checks, + }); + } + + Ok(LegacyMatrixDigestBasisV1 { + plan: LegacyMatrixPlanV2 { + schema_version: schema_version.clone(), + project: project.clone(), + receipt: legacy_receipt, + environment_allow, + caches: legacy_caches, + runtimes: legacy_runtimes, + }, + runtime_digests, + }) +} + +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 swap_mode.is_some() { + return Err(MatrixError::LegacyPlanNotRepresentable("runtime.swap_mode")); + } + Ok(LegacyNormalizedRuntime { + kind: *kind, + image: image.clone(), + cpu_count: *cpu_count, + memory_mib: *memory_mib, + pids_limit: *pids_limit, + network: *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/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 507fdcf..159cbac 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(), @@ -1237,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") { @@ -1650,6 +1655,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 +1676,7 @@ depends_on = ["first"] envelope: &self.envelope, repository: &self.repository, cache: &self.cache, + producer_version: env!("CARGO_PKG_VERSION"), generation: 7, source_snapshot: None, }, @@ -1730,6 +1737,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"); @@ -1762,6 +1800,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 +1892,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 +1960,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 +1994,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 +2165,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 +2216,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/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) ) } 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"); 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/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/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..6c0c03a --- /dev/null +++ b/tests/fixtures/matrix-v2-legacy-plan-044697.provenance.json @@ -0,0 +1,15 @@ +{ + "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", + "plan_digest": "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13", + "outer_digest": "sha256:3248c763ccc37fecac1e29727007232d274f561e0943fc2e5a1996a38526fe13", + "runtime_digests": { + "python311": "sha256:755f77f6815b1ed7b4415b3312c48a6528e2d752270775916efa6c10f1ffe192", + "python312": "sha256:be2eb7d200946e9f1dc84cebd8c0cca8739e424163f91c86063bbe4caed936f9" + } +} 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/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 4e7800a..dc02410 100644 --- a/tests/matrix_contract.rs +++ b/tests/matrix_contract.rs @@ -3,12 +3,16 @@ // 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, MatrixReceiptEnvelopeV2, MatrixReceiptV2, MatrixRequiredCheckV2, - MatrixRuntimePolicyV2, MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, - matrix_config_schema_json, matrix_policy_schema_json, matrix_receipt_schema_json, - verify_matrix_receipt_document, + 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, }; use commit_ci_preflight::receipt::{ CheckEvidence, EvidenceStatus, PlatformEvidence, ProducerEvidence, ReceiptEnvelopeV1, @@ -18,6 +22,7 @@ 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"; @@ -26,6 +31,8 @@ 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"); +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(); @@ -270,6 +277,414 @@ 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().expect("legacy 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_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!( + "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"]); + 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"); + } +} + +#[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"); + 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().expect("first digest"), + second.plan_digest().expect("second 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 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) + )); + assert!(matches!( + envelope.canonical_bytes(), + Err(MatrixError::PlanDigestMismatch) + )); +} + +#[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( + 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, MatrixEnvelopeMutator); 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, MatrixEnvelopeMutator); 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 = [ + "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!( @@ -319,3 +734,72 @@ 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!( + 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()) + ); +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/tests/plan_cli.rs b/tests/plan_cli.rs index ffad5f6..7f02da7 100644 --- a/tests/plan_cli.rs +++ b/tests/plan_cli.rs @@ -15,10 +15,31 @@ 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 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() { @@ -93,6 +114,122 @@ 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!("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 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 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/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!( diff --git a/tests/repository_hygiene_contract.rs b/tests/repository_hygiene_contract.rs index f7483ad..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}; @@ -24,6 +27,185 @@ 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")); + 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", + "tests/verification_contract.rs::historical_matrix_verifier_accepts_legacy_profile_receipt_and_rejects_mutations", + "#[ignore]", + "--ignored", + "CCP_HISTORICAL_VERIFIER_044697", + "provenance-pinned", + ], + ), + ( + "docs/GITHUB_GATE.md", + &["append-once", "`verify` has no profile flag", "current-v2"], + ), + ] { + let text = fs::read_to_string(root.join(path)).expect("read contract docs"); + for snippet in snippets { + 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_reproduces_historical_plan", + "current-v2", + ], + ), + ( + "docs/TESTING_AND_FAULT_INJECTION.md", + &[ + "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", + ], + ), + ] { + let text = fs::read_to_string(root.join(path)).expect("read boundary docs"); + for snippet in snippets { + 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 { + 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 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(); + if path.is_dir() { + collect_rust_sources(&path, output); + } else if path.extension().and_then(|v| v.to_str()) == Some("rs") { + output.push(path); + } + } +} + #[test] fn issue_template_yaml_is_present_and_safe() { assert_yaml_mapping("issue template config", ISSUE_CONFIG, true); diff --git a/tests/runtime_cli.rs b/tests/runtime_cli.rs index efe516b..8dbc843 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,232 @@ 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 +} + +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> \"{}\"\r\n@echo {RUNTIME_SENTINEL} 1>&2\r\n@exit /b 97\r\n", + marker.display() + ), + ) + .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( + 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())); @@ -143,6 +371,91 @@ timeout_seconds = 1 fs::remove_dir_all(root).expect("remove owned fixture root"); } +#[test] +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 = 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", + ); + } +} + +#[test] +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", + ), + ]; + + for (name, config, field) in cases { + assert_legacy_run_rejected_before_shared_state( + &format!("ccp-legacy-profile-{name}"), + &config, + field, + ); + } +} + #[test] fn dry_run_human_output_states_that_argv_is_not_a_shell_and_was_not_run() { let output = Command::new(binary()) diff --git a/tests/verification_contract.rs b/tests/verification_contract.rs index 15cc74e..74aac06 100644 --- a/tests/verification_contract.rs +++ b/tests/verification_contract.rs @@ -12,9 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::path::Path; +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::receipt::{EvidenceStatus, ReceiptEnvelopeV1, ReceiptEnvelopeV2}; +use commit_ci_preflight::matrix::{ + MatrixConfigV2, MatrixError, MatrixPlanEnvelopeV2, MatrixPlanProfile, MatrixReceiptEnvelopeV2, + MatrixReceiptV2, MatrixRuntimeReceiptV2, MatrixVerificationPolicyV2, build_matrix_plan, + 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 +48,14 @@ 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 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"; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); fn policy() -> VerificationPolicyV1 { VerificationPolicyV1::parse(POLICY).expect("policy") @@ -79,6 +99,263 @@ 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_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 = + 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"]); +} + +#[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, +) -> 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 + .to_owned(); + ReceiptEnvelopeV1::seal(ReceiptV1 { + schema_version: "1.0".to_owned(), + producer: ProducerEvidence { + name: env!("CARGO_PKG_NAME").to_owned(), + version: plan.profile().producer_version().to_owned(), + }, + repository: RepositoryEvidence { + repository: plan.plan.project.clone(), + 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: runtime.runtime.image.clone(), + image_digest, + }, + 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(), + }) + .map_err(MatrixError::Receipt) +} + +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: env!("CARGO_PKG_NAME").to_owned(), + version: plan.profile().producer_version().to_owned(), + }, + repository: RepositoryEvidence { + repository: plan.plan.project.clone(), + 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: plan.plan_digest()?.to_owned(), + runtime_receipts, + overall_status: EvidenceStatus::Pass, + incomplete_reason: None, + redaction_policy_version: "1.0".to_owned(), + }) +} + +fn legacy_matrix_receipt() -> MatrixReceiptEnvelopeV2 { + legacy_matrix_receipt_from_production_plan(&legacy_production_plan()) + .expect("production-derived 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::(HISTORICAL_VERIFIER_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 +515,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"); +}