From 2e3704ae16e5f780d6f0f77fcac825f308c93efd Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 17:04:09 +0200 Subject: [PATCH 1/5] feat: add transactional adoption materialization --- docs/proofkit-contract-map.md | 3 +- .../proofkit-spec-proof-core/overview.md | 10 + .../requirements.v1.json | 41 +- go.mod | 1 + go.sum | 2 + .../adoption_front_door_version_edge_test.go | 40 +- .../app/adoption_materialization_command.go | 256 ++++++++ .../adoption_materialization_command_test.go | 227 +++++++ ...ption_materialization_version_edge_test.go | 308 +++++++++ internal/app/app.go | 2 + internal/app/cli_contract_test.go | 5 +- internal/app/command_contract_generated.go | 15 +- internal/app/command_coverage_routes.go | 3 + internal/app/command_coverage_test.go | 13 +- internal/app/command_descriptors.go | 19 +- .../app/command_family_catalog_generated.go | 3 +- internal/app/command_help.go | 2 + .../compact_contract_source_closure_test.go | 60 +- .../compact-current-production-consumers.json | 56 ++ .../v0.7-release-change-record.v2.json | 64 ++ .../app/testdata/v0.7-wire-observations.json | 2 +- .../app/testdata/v0.8-wire-observations.json | 55 ++ .../adoptionmaterialization/admission.go | 136 ++++ .../adoptionmaterialization_test.go | 405 ++++++++++++ .../command/adoptionmaterialization/build.go | 245 +++++++ .../adoptionmaterialization/closure.go | 137 ++++ .../adoptionmaterialization/closure_test.go | 135 ++++ .../adoptionmaterialization/manifest.go | 222 +++++++ .../command/adoptionmaterialization/model.go | 186 ++++++ .../adoptionmaterialization/path_roles.go | 68 ++ .../command/adoptionmaterialization/text.go | 58 ++ .../requirementcoverageinput.go | 9 +- .../stackpreset/preset_ids_generated.go | 2 +- .../testevidenceinventory.go | 33 +- .../testevidenceinventory_test.go | 42 ++ internal/kernel/pathidentity/pathidentity.go | 93 +++ .../kernel/pathidentity/pathidentity_test.go | 46 ++ .../kernel/repositorytransaction/cleanup.go | 148 +++++ .../repositorytransaction/control_state.go | 249 ++++++++ .../directory_ownership.go | 211 +++++++ .../kernel/repositorytransaction/execution.go | 133 ++++ .../repositorytransaction/filesystem.go | 370 +++++++++++ .../repositorytransaction/invariant_test.go | 221 +++++++ .../kernel/repositorytransaction/journal.go | 131 ++++ .../journal_admission.go | 212 +++++++ internal/kernel/repositorytransaction/lock.go | 55 ++ .../repositorytransaction/mode_unix_test.go | 39 ++ .../kernel/repositorytransaction/model.go | 167 +++++ internal/kernel/repositorytransaction/plan.go | 217 +++++++ .../kernel/repositorytransaction/plan_test.go | 241 +++++++ .../repositorytransaction/platform_other.go | 28 + .../repositorytransaction/platform_unix.go | 49 ++ .../kernel/repositorytransaction/recovery.go | 251 ++++++++ .../kernel/repositorytransaction/state.go | 126 ++++ .../state_machine_test.go | 179 ++++++ .../repositorytransaction/terminal_receipt.go | 101 +++ .../repositorytransaction/transaction.go | 167 +++++ .../repositorytransaction/transaction_test.go | 597 ++++++++++++++++++ internal/tools/commandfamilygen/main_test.go | 3 +- internal/tools/coveragemetrics/main.go | 38 ++ internal/tools/releasechange/record_test.go | 38 +- package-lock.json | 4 +- package.json | 2 +- proofkit/cli-contract.v2.json | 500 ++++++++++++++- proofkit/command-families.v1.json | 10 + proofkit/requirement-bindings.json | 169 +++++ release/change-record.v2.json | 50 +- 67 files changed, 7576 insertions(+), 134 deletions(-) create mode 100644 internal/app/adoption_materialization_command.go create mode 100644 internal/app/adoption_materialization_command_test.go create mode 100644 internal/app/adoption_materialization_version_edge_test.go create mode 100644 internal/app/testdata/compact-current-production-consumers.json create mode 100644 internal/app/testdata/v0.7-release-change-record.v2.json create mode 100644 internal/app/testdata/v0.8-wire-observations.json create mode 100644 internal/command/adoptionmaterialization/admission.go create mode 100644 internal/command/adoptionmaterialization/adoptionmaterialization_test.go create mode 100644 internal/command/adoptionmaterialization/build.go create mode 100644 internal/command/adoptionmaterialization/closure.go create mode 100644 internal/command/adoptionmaterialization/closure_test.go create mode 100644 internal/command/adoptionmaterialization/manifest.go create mode 100644 internal/command/adoptionmaterialization/model.go create mode 100644 internal/command/adoptionmaterialization/path_roles.go create mode 100644 internal/command/adoptionmaterialization/text.go create mode 100644 internal/kernel/pathidentity/pathidentity.go create mode 100644 internal/kernel/pathidentity/pathidentity_test.go create mode 100644 internal/kernel/repositorytransaction/cleanup.go create mode 100644 internal/kernel/repositorytransaction/control_state.go create mode 100644 internal/kernel/repositorytransaction/directory_ownership.go create mode 100644 internal/kernel/repositorytransaction/execution.go create mode 100644 internal/kernel/repositorytransaction/filesystem.go create mode 100644 internal/kernel/repositorytransaction/invariant_test.go create mode 100644 internal/kernel/repositorytransaction/journal.go create mode 100644 internal/kernel/repositorytransaction/journal_admission.go create mode 100644 internal/kernel/repositorytransaction/lock.go create mode 100644 internal/kernel/repositorytransaction/mode_unix_test.go create mode 100644 internal/kernel/repositorytransaction/model.go create mode 100644 internal/kernel/repositorytransaction/plan.go create mode 100644 internal/kernel/repositorytransaction/plan_test.go create mode 100644 internal/kernel/repositorytransaction/platform_other.go create mode 100644 internal/kernel/repositorytransaction/platform_unix.go create mode 100644 internal/kernel/repositorytransaction/recovery.go create mode 100644 internal/kernel/repositorytransaction/state.go create mode 100644 internal/kernel/repositorytransaction/state_machine_test.go create mode 100644 internal/kernel/repositorytransaction/terminal_receipt.go create mode 100644 internal/kernel/repositorytransaction/transaction.go create mode 100644 internal/kernel/repositorytransaction/transaction_test.go diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index bde46c9..ef9b59b 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -40,7 +40,7 @@ owner boundaries. It is not a second command-family inventory. | Family | Main commands | Caller provides | Proofkit owns | Consumer owns | Output authority | |---|---|---|---|---|---| | Agent workflow planning | `change-workflow-plan`, `native-evidence-guidance` | explicit checkpoint, completed stage ids, bounded context refs, governing authority ref, and required context ref ids | optional built-in `proofkit.reviewed-change.v1` checkpoint relation, reference-closed next-stage context, deterministic agent prompts, bounded text/JSON/envelope projections, and repository-neutral native-evidence guidance with closed applicability classes | custom workflow topology, repository state discovery, stage execution, native witness semantics, evidence collection, review conclusions, merge, release, deployment, and rollout authority | next-action plan, terminal workflow report, bounded agent envelope, or guidance catalog | -| Adoption and scaffolding | `adopt plan`, `repository-inventory`, `adoption-contract-envelope`, `adoption-workflow-plan`, `adoption-checklist`, `adoption-doctor`, `gradual-adoption`, `gradual-adoption-bootstrap`, `gradual-adoption-guidance`, `capability-map-admission`, `pilot-admission`, `scaffold-profile-plan`, `scaffold-project-structure`, `stack-preset` | explicit repository root, explicit fresh/code-baseline/audit-from-code intent, optional stack hint, aggregate adoption contract envelope, checklist facts, target paths, owner routes, caller-extracted stale authority vocabulary facts, explicit pre-spec capability observations, and pilot records | bounded fixed-catalog root inventory, candidate-only front-door tasks, aggregate contract-envelope admission, deterministic starter plans, checklist/report admission, bounded guidance envelopes, dry-run manifests, pre-spec trust-mode admission, adoption gap and stale-authority classification, and pilot shape admission | stack selection, arbitrary source inspection, final files, final requirements, rollout policy, text extraction, code observation extraction, and pilot truth | inventory, candidate-only plan, selected child output, report, seed packet, or agent envelope | +| Adoption and scaffolding | `adopt plan`, `adopt materialize plan`, `adopt materialize apply`, `adopt materialize recover`, `repository-inventory`, `adoption-contract-envelope`, `adoption-workflow-plan`, `adoption-checklist`, `adoption-doctor`, `gradual-adoption`, `gradual-adoption-bootstrap`, `gradual-adoption-guidance`, `capability-map-admission`, `pilot-admission`, `scaffold-profile-plan`, `scaffold-project-structure`, `stack-preset` | explicit repository root, explicit fresh/code-baseline/audit-from-code intent, optional stack hint, owner-reviewed candidate packet, expected transaction and desired-state identities, recovery action, aggregate adoption contract envelope, checklist facts, target paths, owner routes, caller-extracted stale authority vocabulary facts, explicit pre-spec capability observations, and pilot records | bounded fixed-catalog root inventory, candidate-only front-door tasks, owner-closed read-only materialization plans, confined transactional apply and recovery receipts, aggregate contract-envelope admission, deterministic starter plans, checklist/report admission, bounded guidance envelopes, dry-run manifests, pre-spec trust-mode admission, adoption gap and stale-authority classification, and pilot shape admission | stack selection, arbitrary source inspection, candidate review, final requirement meaning, proof adequacy, rollout policy, text extraction, code observation extraction, and pilot truth | inventory, candidate-only plan, transaction-bound materialization plan or receipt, selected child output, report, seed packet, or agent envelope | | Requirement source | `capability-map-admission`, `requirement-authoring-plan`, `requirement-source-admission`, `requirement-source-transition`, `spec-overview-claims`, `requirement-spec-tree`, `requirement-spec-tree-view`, `requirement-source-view`, `requirement-browser-server` | `requirements.v1.json`, caller-owned capability maps, caller-owned authoring facts, overview claim extraction, explicit spec hierarchy, view options | candidate seed admission, candidate-only authoring packets, source-shape admission, lifecycle checks, explicit tree topology/source-ref admission, shared safe renderer fragments, presentation-only views | requirement meaning, extraction completeness, Markdown extraction completeness, hierarchy ownership, proof adequacy, file materialization | capability map report, authoring packet, source report, spec-tree report, rendered view, or browser presentation | | Requirement proof binding | `requirement-bindings`, `binding-partition`, `proof-slice`, `evidence-graph`, `requirement-proof-resolver`, `requirement-proof-source-set`, `requirement-proof-view`, `spec-proof-bundle-admission` | requirement records, bindings, witness commands, source-set facts, receipt reports, partition policy | graph validation, binding partition projection, compact slices, declaration-only compact route projection with full binding identity and role-qualified witness routes, resolver projection, bundle linkage checks | selector resolution, oracle quality, witness execution, mutation adequacy, finding completeness, proof freshness, trust, assurance, merge policy | proof report, partition report, slice, declaration lookup graph, or view | | Test inventory and coverage | `test-evidence-inventory`, `test-evidence-inventory --projection discovery-draft`, `test-evidence-inventory --normalized-inventory`, `requirement-coverage-input-compose`, `requirement-coverage-view`, `requirement-browser-server --view coverage` | caller-owned direct or source-set test inventory, caller-owned explicit test discovery facts, declared quality findings, requirement source, proof binding or compact proof contract, coverage universe, optional owner-invariant registry, aggregate coverage compose input | strict inventory/source-set admission, candidate-only discovery draft projection, fail-closed normalized inventory projection, deterministic coverage-view input composition from explicit facts, missing declared assertion-signal and declared-quality classification, bounded agent action guidance, requirement/test/command/owner-invariant joins, nonsemantic command-evidence classification, stable coverage failure/warning classifications, presentation-only coverage view | inventory completeness, oracle quality, test quality, test discovery extraction, native test execution, receipt freshness, producer trust, merge policy | candidate inventory guidance, inventory report, normalized inventory data product, coverage-view input, coverage view, or browser presentation | @@ -132,6 +132,7 @@ Semantic context routes are `requirement-context-compose`, | An agent needs a bounded, deterministic stage transition for an engineering change. | `change-workflow-plan` selects the optional built-in `proofkit.reviewed-change.v1` profile; use `--agent-envelope` for the compact work packet and `native-evidence-guidance` when the consuming repository has not yet materialized repository-specific evidence instructions. | Supply only explicit current checkpoint, completed stages, and admitted context references. Apply conditional guidance slots only when their applicability class matches a declared consumer mechanism. Stop before treating the profile, plan, or guidance as repository policy or as proof that a stage ran, evidence exists, review passed, or merge/release is authorized. | | No admitted spec/profile exists and the caller has explicit capability observations. | `capability-map-admission`; use `trustMode: "code_baseline"` only when maintainers intentionally freeze current code, otherwise use `trustMode: "audit_from_code"`. | Stop before treating seeds as stable requirements. The consumer owns observation extraction, materialization, requirement meaning, and proof adequacy. | | No admitted spec/profile exists and no capability observations exist. | Start with `adopt plan --mode fresh --repo-root `; use `scaffold-project-structure`, `adoption-workflow-plan`, or `stack-preset` only as later specialist routes when an owner has selected them. | Treat front-door tasks as candidate-only. Stop before writing files; the consumer owns materialization, overwrite policy, and final requirement text. | +| Owner-reviewed candidate requirement sources, proof bindings, and test inventory are ready for repository materialization. | Use command ID `adopt-materialize-plan` through route `adopt materialize plan --input --repo-root `, review the exact transaction and desired-state identities, then use command ID `adopt-materialize-apply` through route `adopt materialize apply` with both expected identities. Use `adopt materialize recover` only for the exact observed transaction and state-compatible `resume` or `rollback` action. | Stop on stale state, unknown ownership, path-role collision, pending transaction, identity mismatch, or recovery-required output. A plan or receipt does not prove requirement meaning, witness truth, proof adequacy, merge approval, rollout, or production readiness. | | Candidate boundary is uncertain. | `adoption-doctor` or `gradual-adoption-guidance --agent-envelope` | Escalate to owner review when the boundary is advisory, ambiguous, or missing native witnesses. | | Temporary external design, implementation-plan, PR, code, or test observations may contain durable requirements. | `requirement-authoring-plan` | Treat output as candidate-only; stop before writing `requirements.v1.json`, retaining temporary documents, or claiming requirement meaning. | | Requirement records exist. | `requirement-source-admission`; use `requirement-source-transition` for lifecycle changes. | Escalate when blocking requirements lack proof routes or lifecycle replacement ids are incomplete. | diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index fd5ea2f..a1a8042 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -190,6 +190,16 @@ execution receipts, and merge policy. - `REQ-PROOFKIT-SPEC-031`: the adoption version edge binds exact ABI, command, contract, generated-artifact, and release-change inventories while proving that the retired `init` route has no remaining public owner. +- `REQ-PROOFKIT-SPEC-032`: adoption materialization admits candidate artifacts + through their existing owners, proves cross-record closure, and binds a + read-only plan plus apply and recovery receipts to complete state identities. +- `REQ-PROOFKIT-SPEC-033`: repository transactions confine bounded immutable + plans, private journals, atomic target replacement, exact rollback, and + action-stable replay recovery without claiming repository-wide atomicity. +- `REQ-PROOFKIT-SPEC-034`: the pre-materialization-to-transactional- + materialization public version edge binds all three transactional + materialization routes and their exact public contracts to a compatible + release record without reinterpreting the frozen prior edge. ## Non-Claims diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index beb1ae7..295064b 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -650,7 +650,7 @@ { "requirementId": "REQ-PROOFKIT-SPEC-031", "ownerId": "proofkit.spec-proof-core", - "invariant": "The adoption-front-door public version edge binds the exact previous and current public ABI digests, the exact removal of init, the exact addition of adopt plan and repository-inventory, one explicit selection policy covering every command whose declared input-contract identifier changed while leaving source-bound digest churn to the enclosing ABI digests, the live current input-contract identifier and wire schema of every selected command, every changed generated artifact identity, and the ordered breaking and additive change inventories to one digest-bound release change record; the superseded init route has no active descriptor, dispatcher, CLI contract, command family, or package owner.", + "invariant": "The frozen 0.6.0-to-0.7.0 adoption-front-door public version edge binds the exact previous and released public ABI digests, the exact removal of init, the exact addition of adopt plan and repository-inventory, one explicit selection policy covering every command whose declared input-contract identifier changed while leaving source-bound digest churn to the enclosing ABI digests, the released input-contract identifier and wire schema of every selected command, every changed generated artifact identity, and the ordered breaking and additive change inventories to one digest-bound frozen release change record; the superseded init route has no active descriptor, dispatcher, CLI contract, command family, or package owner, and later releases cannot reinterpret this historical edge through live contract metadata.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], @@ -659,6 +659,45 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-032", + "ownerId": "proofkit.spec-proof-core", + "invariant": "Adoption materialization fully admits one candidate packet through the existing requirement-source, requirement-binding, test-inventory, repository-inventory, and adoption-plan owners; proves exact cross-record identity and reference closure; derives canonical child-owned bytes plus one routing-only manifest without mirroring child field semantics; and emits a deterministic read-only plan whose transaction and desired-state identities bind the complete admitted before and after states. Apply recomputes that complete plan and requires both expected identities before any mutation, recovery accepts only one observed transaction identity and one state-compatible action, every plan and receipt is closed under its own output admission, and caller review declarations remain declarations rather than requirement, proof, execution, or approval evidence.", + "claimLevel": "blocking", + "riskClass": "critical", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-032"], + "nonClaims": ["Materialization does not infer requirement meaning, authenticate witness truth or freshness, execute native witnesses, approve merge or release, establish rollout or production readiness, or make its routing manifest a second semantic owner."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The repository-transaction owner confines every effect to one explicit repository root; freezes a bounded canonical execution plan before effects; rejects non-canonical, reserved, symlinked, case-folding, Unicode-folding, and prefix-alias target identities; keeps staged payloads, backups, ownership records, and publish temporaries in one private transaction namespace; records each created directory by exact filesystem identity; and performs each target replacement by same-filesystem atomic rename. A bounded durable journal identifies the exact before-state prefix, applied-target count, and terminal result; apply fails closed on stale state, unknown control records, and cooperative concurrency; rollback removes only transaction-owned artifacts and restores exact bytes and modes; resume and rollback are state-compatible, action-stable, cancellation-aware, and replay-idempotent; terminal receipts preserve the complete observable result until a later valid transaction replaces them. These guarantees cover process interruption at every injected mutation boundary but do not claim filesystem-wide atomic visibility, power-loss durability beyond successful synchronization, protection from arbitrary readers, or safety against a non-cooperative same-user process mutating the private namespace.", + "claimLevel": "blocking", + "riskClass": "critical", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-033"], + "nonClaims": ["Repository transactions do not provide multi-file atomic visibility to concurrent arbitrary readers, distributed transactions, protection from a hostile same-user process, or stronger power-loss durability than the admitted filesystem synchronization operations."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-034", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The 0.7.0-to-0.8.0 public version edge binds the exact previous and current public ABI digests; the exact addition of adopt materialize plan, apply, and recover with their public routes and input/output contract identities and digests; an explicit added-command selection policy; and the complete ordered additive inventory to one digest-bound current release change record. The edge is compatible, contains no breaking changes or migration steps, and does not mutate or reinterpret the frozen 0.6.0-to-0.7.0 edge.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-034"], + "nonClaims": ["A source-bound version edge does not authenticate registry publication, provider ingestion, consumer adoption, native witness truth, rollout, or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ], "nonClaims": [ diff --git a/go.mod b/go.mod index 5082d09..5de85b1 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/telemetry v0.0.0-20260902144106-3ef544be8421 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/vuln v1.7.0 // indirect honnef.co/go/tools v0.8.1 // indirect ) diff --git a/go.sum b/go.sum index a5966a4..aa6d227 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260902144106-3ef544be8421 h1:o5Q1WWgqIjOriF3xZyV3Y32kRr4lbgdHDbg2Wdw8/i0= golang.org/x/telemetry v0.0.0-20260902144106-3ef544be8421/go.mod h1:/KSYFnLndIrA1A+Rs5r6vSifQhuFz75BPNN3J/hvzN0= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= diff --git a/internal/app/adoption_front_door_version_edge_test.go b/internal/app/adoption_front_door_version_edge_test.go index 89232b0..dc25e7c 100644 --- a/internal/app/adoption_front_door_version_edge_test.go +++ b/internal/app/adoption_front_door_version_edge_test.go @@ -11,8 +11,6 @@ import ( "strings" "testing" - "github.com/research-engineering/agentic-proofkit/internal/command/agentroute" - "github.com/research-engineering/agentic-proofkit/internal/command/jsonreportcliadaptersource" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" ) @@ -71,8 +69,7 @@ type adoptionChangedCommandContract struct { func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { record := readAdoptionFrontDoorVersionEdge(t) - currentPublicABI := "sha256:" + currentCLIContractPublicABISHA256(t) - if err := validateAdoptionFrontDoorVersionEdge(record, repoRoot(t), currentPublicABI); err != nil { + if err := validateAdoptionFrontDoorVersionEdge(record, repoRoot(t)); err != nil { t.Fatal(err) } @@ -109,7 +106,7 @@ func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { t.Run(fmt.Sprintf("mutant-%d", index), func(t *testing.T) { value := cloneAdoptionFrontDoorVersionEdge(record) mutate(&value) - if err := validateAdoptionFrontDoorVersionEdge(value, repoRoot(t), currentPublicABI); err == nil { + if err := validateAdoptionFrontDoorVersionEdge(value, repoRoot(t)); err == nil { t.Fatal("version-edge mutant was admitted") } }) @@ -145,8 +142,7 @@ func TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction(t mutant := cloneAdoptionFrontDoorVersionEdge(record) digest := sha256.Sum256(mutantContent) mutant.ChangeRecordSHA256 = fmt.Sprintf("sha256:%x", digest) - currentPublicABI := "sha256:" + currentCLIContractPublicABISHA256(t) - if err := validateAdoptionFrontDoorVersionEdge(mutant, mutantRoot, currentPublicABI); err == nil || !strings.Contains(err.Error(), "contradicts") { + if err := validateAdoptionFrontDoorVersionEdge(mutant, mutantRoot); err == nil || !strings.Contains(err.Error(), "contradicts") { t.Fatalf("coordinated change-record mutant error=%v, want inventory contradiction", err) } } @@ -221,7 +217,7 @@ func readAdoptionFrontDoorVersionEdge(t *testing.T) adoptionFrontDoorVersionEdge return record } -func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, root string, currentPublicABI string) error { +func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, root string) error { if record.SchemaVersion != 1 || record.EdgeID != "proofkit.public-wire.0.6.0-to-0.7.0" || record.EvidenceClass != "owner_authored_frozen_version_edge_observation" { return fmt.Errorf("adoption front-door version-edge identity is invalid") } @@ -231,7 +227,7 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if record.CommandContractSelection != "declared_input_contract_id_change" { return fmt.Errorf("adoption front-door command-contract selection policy is invalid") } - if record.PreviousPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.CurrentPublicABISHA256 != currentPublicABI || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { + if record.PreviousPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.CurrentPublicABISHA256 != "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7" || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { return fmt.Errorf("adoption front-door version-edge ABI identity is invalid") } wantRemoved := adoptionRemovedCommandContract{Command: "init", DefaultInvocationPreset: "all", OutputContractSHA256: "sha256:3e59a3002327c759e5e747f8baacaa63a4d6784e1a1c520f0a54e01af3f2faa0"} @@ -239,8 +235,8 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r return fmt.Errorf("adoption front-door removed command contract is not exact") } wantAdded := []adoptionFrontDoorCommandContract{ - {Command: "adopt-plan", Route: []string{"adopt", "plan"}, OutputContractSHA256: generatedCommandContractMetadataByName["adopt-plan"].OutputContractSHA256}, - {Command: "repository-inventory", Route: []string{"repository-inventory"}, OutputContractSHA256: generatedCommandContractMetadataByName["repository-inventory"].OutputContractSHA256}, + {Command: "adopt-plan", Route: []string{"adopt", "plan"}, OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320"}, + {Command: "repository-inventory", Route: []string{"repository-inventory"}, OutputContractSHA256: "sha256:4a6fc5b5ef55090854e70927494d220afdee0ae234de4f61a720a6018865f02f"}, } if !slices.EqualFunc(record.AddedCommandContracts, wantAdded, equalAdoptionCommandContract) { return fmt.Errorf("adoption front-door added command contracts are not exact") @@ -248,27 +244,21 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r wantGeneratedArtifacts := []adoptionChangedGeneratedArtifact{{ ArtifactKind: "proofkit.json-report-cli-adapter-source", CurrentSourceSHA256: "sha256:62c34f1b920466f157d32d982fd5dd8355cbfb023eeda342e8cdbe5c15d731a0", - GeneratorID: jsonreportcliadaptersource.TypeScriptGeneratorID, + GeneratorID: "proofkit.json-report-cli-adapter-source.typescript.v2", PreviousSourceSHA256: "sha256:a171cc1b95c6078b7190ac50fc9fd298db8f42bfc9b65bbb67fa77d63dc04a93", }} if !slices.Equal(record.ChangedGeneratedArtifacts, wantGeneratedArtifacts) { return fmt.Errorf("adoption front-door changed generated artifacts are not exact") } - currentAgentRouteInputContract := agentroute.InputContract() - currentAgentRouteInputContractID, contractIDOK := currentAgentRouteInputContract["contractId"].(string) - currentAgentRouteWireSchemaVersion, schemaVersionOK := currentAgentRouteInputContract["schemaVersion"].(int) - if !contractIDOK || currentAgentRouteInputContractID == "" || !schemaVersionOK || currentAgentRouteWireSchemaVersion < 1 { - return fmt.Errorf("current agent-route input contract identity is invalid") - } wantChangedContracts := []adoptionChangedCommandContract{{ Command: "agent-route", - CurrentInputContractID: currentAgentRouteInputContractID, - CurrentInputContractSHA256: generatedCommandContractMetadataByName["agent-route"].InputContractSHA256, - CurrentOutputContractSHA256: generatedCommandContractMetadataByName["agent-route"].OutputContractSHA256, + CurrentInputContractID: "proofkit.agent-route.input.v2", + CurrentInputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", + CurrentOutputContractSHA256: "sha256:a972f7178986b89334262db8047aabbb5a7b67025b30a009a96af47722b707c6", PreviousInputContractID: "proofkit.agent-route.input.v1", PreviousInputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", PreviousOutputContractSHA256: "sha256:485d62afc2e5ed07c28f557b0d1069f167b3838abe0aed248e9ff94f3e25c0ad", - WireSchemaVersion: currentAgentRouteWireSchemaVersion, + WireSchemaVersion: 1, }} if !slices.Equal(record.ChangedCommandContracts, wantChangedContracts) { return fmt.Errorf("adoption front-door changed command contracts are not exact") @@ -278,14 +268,10 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r return fmt.Errorf("adoption front-door changed command contract %s did not change input identity", contract.Command) } } - currentSourceDigest := sha256.Sum256([]byte(jsonreportcliadaptersource.TypeScriptSource())) - if record.ChangedGeneratedArtifacts[0].CurrentSourceSHA256 != fmt.Sprintf("sha256:%x", currentSourceDigest) { - return fmt.Errorf("adoption front-door generated adapter source identity is stale") - } if !slices.Equal(record.BreakingChangeIDs, []string{"proofkit.adoption.init-retired", "proofkit.agent-route.input-contract-v2"}) || !slices.Equal(record.AdditionChangeIDs, []string{"proofkit.adoption.front-door", "proofkit.adoption.repository-inventory", "proofkit.cli.generated-adapter-command-routes", "proofkit.cli.hierarchical-command-routes", "proofkit.python-wheel.embedded-cli-contract"}) { return fmt.Errorf("adoption front-door change inventory is not exact") } - if record.ChangeRecordRef != "release/change-record.v2.json" { + if record.ChangeRecordRef != "internal/app/testdata/v0.7-release-change-record.v2.json" { return fmt.Errorf("adoption front-door change record reference is not exact") } changeRecordContent, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(record.ChangeRecordRef))) diff --git a/internal/app/adoption_materialization_command.go b/internal/app/adoption_materialization_command.go new file mode 100644 index 0000000..4410aae --- /dev/null +++ b/internal/app/adoption_materialization_command.go @@ -0,0 +1,256 @@ +package app + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +type adoptionMaterializationArgs struct { + action string + color string + colorExplicit bool + expectedDesiredStateID string + expectedTransactionID string + format string + inputPath string + inputPointer jsonpointer.Pointer + pointerPresent bool + repositoryRoot string + transactionID string +} + +func runAdoptionMaterialization(ctx context.Context, command string, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, capabilities PresentationCapabilities) int { + options, err := parseAdoptionMaterializationArgs(command, args) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + if command == "adopt-materialize-recover" { + receipt, exitCode, err := adoptionmaterialization.Recover(ctx, options.repositoryRoot, options.transactionID, options.action) + return writeAdoptionMaterializationReceipt(receipt, exitCode, err, options, stdout, stderr, capabilities) + } + input, err := readInput(options.inputPath, stdin) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + if options.pointerPresent { + input, err = jsonpointer.SelectParsed(input, options.inputPointer) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + } + if command == "adopt-materialize-plan" { + materialization, err := adoptionmaterialization.BuildPlan(ctx, input, options.repositoryRoot) + if options.format == "json" { + return writeJSON(materialization.Plan.JSONValue(), 0, err, stdout, stderr) + } + if err != nil { + return writeText("", 1, err, stdout, stderr) + } + plain, err := adoptionmaterialization.RenderPlanText(materialization.Plan) + return writeAdoptionMaterializationText(plain, 0, err, options, stdout, stderr, capabilities) + } + receipt, exitCode, err := adoptionmaterialization.Apply( + ctx, + input, + options.repositoryRoot, + options.expectedTransactionID, + options.expectedDesiredStateID, + ) + return writeAdoptionMaterializationReceipt(receipt, exitCode, err, options, stdout, stderr, capabilities) +} + +func writeAdoptionMaterializationReceipt(receipt adoptionmaterialization.Receipt, exitCode int, err error, options adoptionMaterializationArgs, stdout io.Writer, stderr io.Writer, capabilities PresentationCapabilities) int { + if options.format == "json" { + return writeJSON(receipt.JSONValue(), exitCode, err, stdout, stderr) + } + if err != nil { + return writeText("", 1, err, stdout, stderr) + } + plain, err := adoptionmaterialization.RenderReceiptText(receipt) + return writeAdoptionMaterializationText(plain, exitCode, err, options, stdout, stderr, capabilities) +} + +func writeAdoptionMaterializationText(plain string, exitCode int, err error, options adoptionMaterializationArgs, stdout io.Writer, stderr io.Writer, capabilities PresentationCapabilities) int { + if err != nil { + return writeText("", 1, err, stdout, stderr) + } + output, err := renderTerminalText(adoptionMaterializationTerminalText(plain), options.color, capabilities) + if err == nil && options.color == "never" && output != plain { + err = fmt.Errorf("adoption materialization text projection drifted") + } + return writeText(output, exitCode, err, stdout, stderr) +} + +func adoptionMaterializationTerminalText(plain string) terminalText { + lines := strings.SplitAfter(plain, "\n") + tokens := make([]terminalTextToken, 0, len(lines)*2) + for _, line := range lines { + if line == "" { + continue + } + content := strings.TrimSuffix(line, "\n") + newline := strings.TrimPrefix(line, content) + if strings.HasPrefix(content, "- ") { + tokens = append(tokens, terminalTextToken{kind: terminalTokenPlain, text: line}) + continue + } + separator := strings.IndexByte(content, ':') + if separator < 0 { + tokens = append(tokens, + terminalTextToken{kind: terminalTokenLabel, text: content}, + terminalTextToken{kind: terminalTokenPlain, text: newline}, + ) + continue + } + tokens = append(tokens, + terminalTextToken{kind: terminalTokenLabel, text: content[:separator]}, + terminalTextToken{kind: terminalTokenPlain, text: content[separator:] + newline}, + ) + } + return newTerminalText(tokens...) +} + +func parseAdoptionMaterializationArgs(command string, args []string) (adoptionMaterializationArgs, error) { + options := adoptionMaterializationArgs{color: "never", format: "json"} + seen := map[string]bool{} + for index := 0; index < len(args); index++ { + flag := args[index] + if !adoptionMaterializationFlagAllowed(command, flag) { + return adoptionMaterializationArgs{}, fmt.Errorf("unsupported argument for %s: %s", commandRouteForDiagnostic(command), flag) + } + if seen[flag] { + return adoptionMaterializationArgs{}, fmt.Errorf("%s may be specified only once", flag) + } + seen[flag] = true + if index+1 >= len(args) || args[index+1] == "" && flag != "--input-pointer" { + return adoptionMaterializationArgs{}, missingAdoptionMaterializationValue(flag) + } + value := args[index+1] + index++ + switch flag { + case "--action": + if value != repositorytransaction.RecoveryResume && value != repositorytransaction.RecoveryRollback { + return adoptionMaterializationArgs{}, fmt.Errorf("--action requires one of: resume, rollback") + } + options.action = value + case "--color": + if value != "auto" && value != "never" { + return adoptionMaterializationArgs{}, fmt.Errorf("--color requires one of: auto, never") + } + options.color = value + options.colorExplicit = true + case "--expect-desired-state": + admitted, err := admit.SHA256Ref(value, "adoption materialization expected desired state") + if err != nil { + return adoptionMaterializationArgs{}, err + } + options.expectedDesiredStateID = admitted + case "--expect-transaction": + admitted, err := admit.SHA256Ref(value, "adoption materialization expected transaction") + if err != nil { + return adoptionMaterializationArgs{}, err + } + options.expectedTransactionID = admitted + case "--format": + if value != "json" && value != "text" { + return adoptionMaterializationArgs{}, fmt.Errorf("--format requires one of: json, text") + } + options.format = value + case "--input": + options.inputPath = value + case "--input-pointer": + pointer, err := jsonpointer.Parse(value) + if err != nil { + return adoptionMaterializationArgs{}, err + } + options.inputPointer = pointer + options.pointerPresent = true + case "--repo-root": + options.repositoryRoot = value + case "--transaction": + admitted, err := admit.SHA256Ref(value, "adoption materialization transaction") + if err != nil { + return adoptionMaterializationArgs{}, err + } + options.transactionID = admitted + } + } + if command != "adopt-materialize-recover" && options.inputPath == "" { + return adoptionMaterializationArgs{}, fmt.Errorf("%s requires --input ", commandRouteForDiagnostic(command)) + } + if options.repositoryRoot == "" { + return adoptionMaterializationArgs{}, fmt.Errorf("%s requires --repo-root ", commandRouteForDiagnostic(command)) + } + if command == "adopt-materialize-apply" && options.expectedTransactionID == "" { + return adoptionMaterializationArgs{}, fmt.Errorf("adopt materialize apply requires --expect-transaction ") + } + if command == "adopt-materialize-apply" && options.expectedDesiredStateID == "" { + return adoptionMaterializationArgs{}, fmt.Errorf("adopt materialize apply requires --expect-desired-state ") + } + if command == "adopt-materialize-recover" && options.transactionID == "" { + return adoptionMaterializationArgs{}, fmt.Errorf("adopt materialize recover requires --transaction ") + } + if command == "adopt-materialize-recover" && options.action == "" { + return adoptionMaterializationArgs{}, fmt.Errorf("adopt materialize recover requires --action ") + } + if options.colorExplicit && options.format != "text" { + return adoptionMaterializationArgs{}, fmt.Errorf("--color is valid only with --format text") + } + return options, nil +} + +func adoptionMaterializationFlagAllowed(command, flag string) bool { + switch command { + case "adopt-materialize-plan": + switch flag { + case "--color", "--format", "--input", "--input-pointer", "--repo-root": + return true + } + case "adopt-materialize-apply": + switch flag { + case "--color", "--expect-desired-state", "--expect-transaction", "--format", "--input", "--input-pointer", "--repo-root": + return true + } + case "adopt-materialize-recover": + switch flag { + case "--action", "--color", "--format", "--repo-root", "--transaction": + return true + } + } + return false +} + +func missingAdoptionMaterializationValue(flag string) error { + switch flag { + case "--action": + return fmt.Errorf("--action requires one of: resume, rollback") + case "--color": + return fmt.Errorf("--color requires one of: auto, never") + case "--expect-desired-state": + return fmt.Errorf("--expect-desired-state requires a sha256 ref") + case "--expect-transaction": + return fmt.Errorf("--expect-transaction requires a sha256 ref") + case "--format": + return fmt.Errorf("--format requires one of: json, text") + case "--input": + return fmt.Errorf("--input requires a path or -") + case "--input-pointer": + return fmt.Errorf("--input-pointer requires a JSON pointer") + case "--repo-root": + return fmt.Errorf("--repo-root requires a path") + case "--transaction": + return fmt.Errorf("--transaction requires a sha256 ref") + default: + return fmt.Errorf("unsupported adoption materialization argument") + } +} diff --git a/internal/app/adoption_materialization_command_test.go b/internal/app/adoption_materialization_command_test.go new file mode 100644 index 0000000..2430b02 --- /dev/null +++ b/internal/app/adoption_materialization_command_test.go @@ -0,0 +1,227 @@ +package app + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionplan" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestAdoptionMaterializationCLI(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.103035476538948218041788724536099147997319815968356867564973644428335343301159") + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.058152805399859601021037338453799485862969628463250390378105158011563433477370") + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.024071864329639467033814284200388059833882159297164197606479032099605203702808") + repositoryRoot := t.TempDir() + input := adoptionMaterializationCLIInput(t, repositoryRoot) + payload, err := stablejson.Marshal(input) + if err != nil { + t.Fatal(err) + } + + planArgs := []string{"adopt", "materialize", "plan", "--input", "-", "--repo-root", repositoryRoot} + status, stdout, stderr := executeAgentWorkflowCLI(t, planArgs, bytes.NewReader(payload), PresentationCapabilities{}) + if status != 0 || stderr != "" || strings.Contains(stdout, repositoryRoot) || strings.Contains(stdout, "\x1b[") { + t.Fatalf("plan status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + plan := decodeCLIJSON(t, stdout).(map[string]any) + if plan["planKind"] != adoptionmaterialization.PlanKind || plan["state"] != "ready" { + t.Fatalf("unexpected plan identity: %#v", plan) + } + transaction := plan["transaction"].(map[string]any) + transactionID := transaction["transactionId"].(string) + desiredStateID := transaction["desiredStateId"].(string) + if transactionID == desiredStateID { + t.Fatal("transaction and desired-state identities unexpectedly alias") + } + + applyArgs := []string{ + "adopt", "materialize", "apply", + "--input", "-", + "--repo-root", repositoryRoot, + "--expect-transaction", transactionID, + "--expect-desired-state", desiredStateID, + } + status, stdout, stderr = executeAgentWorkflowCLI(t, applyArgs, bytes.NewReader(payload), PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("apply status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + receipt := decodeCLIJSON(t, stdout).(map[string]any) + if receipt["receiptKind"] != adoptionmaterialization.ReceiptKind || receipt["state"] != adoptionmaterialization.ReceiptStatePassed { + t.Fatalf("unexpected apply receipt: %#v", receipt) + } + for _, path := range []string{ + "docs/specs/pilot/requirements.v1.json", + "proofkit/project.v1.json", + "proofkit/requirement-bindings.json", + "proofkit/test-evidence-inventory.json", + } { + info, err := os.Stat(filepath.Join(repositoryRoot, filepath.FromSlash(path))) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("materialized %s: info=%v err=%v", path, info, err) + } + } + + status, stdout, stderr = executeAgentWorkflowCLI(t, applyArgs, bytes.NewReader(payload), PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("idempotent apply status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + retry := decodeCLIJSON(t, stdout).(map[string]any) + result := retry["transactionResult"].(map[string]any) + if retry["state"] != adoptionmaterialization.ReceiptStatePassed || result["state"] != "already_satisfied" { + t.Fatalf("unexpected retry receipt: %#v", retry) + } + + recoverArgs := []string{ + "adopt", "materialize", "recover", + "--repo-root", repositoryRoot, + "--transaction", transactionID, + "--action", "resume", + } + status, stdout, stderr = executeAgentWorkflowCLI(t, recoverArgs, strings.NewReader(""), PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("recover status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + recovered := decodeCLIJSON(t, stdout).(map[string]any) + if recovered["operation"] != adoptionmaterialization.OperationRecover || recovered["state"] != adoptionmaterialization.ReceiptStatePassed { + t.Fatalf("unexpected recovery receipt: %#v", recovered) + } + + t.Run("human presentation is opt-in and capability-bound", func(t *testing.T) { + textArgs := append(cloneStrings(planArgs), "--format", "text") + plainStatus, plain, plainErr := executeAgentWorkflowCLI(t, textArgs, bytes.NewReader(payload), PresentationCapabilities{StdoutIsTTY: true}) + if plainStatus != 0 || plainErr != "" || strings.Contains(plain, "\x1b[") { + t.Fatalf("plain status=%d stderr=%q stdout=%q", plainStatus, plainErr, plain) + } + colorArgs := append(cloneStrings(textArgs), "--color", "auto") + colorStatus, colored, colorErr := executeAgentWorkflowCLI(t, colorArgs, bytes.NewReader(payload), PresentationCapabilities{StdoutIsTTY: true}) + if colorStatus != 0 || colorErr != "" || !strings.Contains(colored, "\x1b[") { + t.Fatalf("color status=%d stderr=%q stdout=%q", colorStatus, colorErr, colored) + } + disabledStatus, disabled, disabledErr := executeAgentWorkflowCLI(t, colorArgs, bytes.NewReader(payload), PresentationCapabilities{StdoutIsTTY: true, NoColorPresent: true}) + if disabledStatus != 0 || disabledErr != "" || disabled != plain { + t.Fatalf("NO_COLOR status=%d stderr=%q stdout=%q want=%q", disabledStatus, disabledErr, disabled, plain) + } + }) + + t.Run("argument admission precedes input and repository access", func(t *testing.T) { + missingRoot := filepath.Join(t.TempDir(), "missing") + for _, item := range []struct { + args []string + want string + }{ + {args: []string{"adopt", "materialize", "plan", "--input", "-", "--repo-root", missingRoot, "--input-pointer", "bad"}, want: "RFC 6901"}, + {args: []string{"adopt", "materialize", "apply", "--input", "-", "--repo-root", missingRoot, "--expect-transaction", "invalid", "--expect-desired-state", desiredStateID}, want: "sha256"}, + {args: []string{"adopt", "materialize", "recover", "--repo-root", missingRoot, "--transaction", transactionID, "--action", "force"}, want: "resume, rollback"}, + {args: []string{"adopt", "materialize", "recover", "--repo-root", missingRoot, "--transaction", transactionID, "--action", "resume", "--input", "-"}, want: "unsupported argument"}, + } { + status, stdout, stderr := executeAgentWorkflowCLI(t, item.args, panicReader{}, PresentationCapabilities{}) + if status != 1 || stdout != "" || !strings.Contains(stderr, item.want) { + t.Fatalf("args=%v status=%d stdout=%q stderr=%q want=%q", item.args, status, stdout, stderr, item.want) + } + } + }) + + t.Run("hierarchical routes and mutation scope are explicit", func(t *testing.T) { + for _, command := range []string{"adopt-materialize-apply", "adopt-materialize-plan", "adopt-materialize-recover"} { + descriptor, ok := commandDescriptorFor(command) + if !ok { + t.Fatalf("descriptor %s is unavailable", command) + } + if command == "adopt-materialize-plan" { + if descriptor.scopeClass != commandScopeExplicitFileSystemScan { + t.Fatalf("plan scope=%s", descriptor.scopeClass) + } + } else if descriptor.scopeClass != commandScopeExplicitFileSystemMutation { + t.Fatalf("%s scope=%s", command, descriptor.scopeClass) + } + status, stdout, stderr := executeAgentWorkflowCLI(t, append(cloneStrings(descriptor.routeTokens), "--help"), panicReader{}, PresentationCapabilities{}) + if status != 0 || stderr != "" || !strings.Contains(stdout, "agentic-proofkit "+strings.Join(descriptor.routeTokens, " ")) { + t.Fatalf("help %s status=%d stderr=%q stdout=%q", command, status, stderr, stdout) + } + } + }) +} + +func adoptionMaterializationCLIInput(t *testing.T, root string) map[string]any { + t.Helper() + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Pilot\n"), 0o600); err != nil { + t.Fatal(err) + } + inventory, err := repositoryinventory.Scan(t.Context(), root) + if err != nil { + t.Fatal(err) + } + sourcePlan, err := adoptionplan.Build(adoptionplan.IntentFresh, inventory, "") + if err != nil { + t.Fatal(err) + } + requirementNonClaims := []any{"Pilot requirement fixture does not prove rollout."} + return map[string]any{ + "schemaVersion": json.Number("1"), "requestKind": adoptionmaterialization.RequestKind, + "requestId": "pilot.materialization.cli-request", "projectId": "pilot.project", "sourcePlan": sourcePlan.JSONValue(), + "requirementSources": []any{map[string]any{ + "schemaVersion": json.Number("1"), "sourceId": "pilot.requirements", "specPackagePath": "docs/specs/pilot", + "overviewPath": "docs/specs/pilot/overview.md", "requirementsPath": "docs/specs/pilot/requirements.v1.json", + "nonClaims": []any{"Pilot source fixture does not prove production readiness."}, + "requirements": []any{map[string]any{ + "claimLevel": "blocking", "deferral": nil, "invariant": "Pilot materialization preserves admitted requirement meaning.", + "lifecycle": map[string]any{"evidenceRefs": []any{}, "replacementRequirementIds": []any{}, "state": "active"}, + "nonClaimRefs": []any{}, "nonClaims": requirementNonClaims, "ownerId": "pilot.owner", + "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "requirementId": "REQ-PILOT-001", "riskClass": "high", + "updatePolicy": map[string]any{"requiresImpactDeclaration": true, "requiresProofBindingReview": true, "reviewOwnerId": "pilot.owner"}, + }}, + }}, + "requirementProofBinding": map[string]any{ + "path": "proofkit/requirement-bindings.json", + "record": map[string]any{ + "schemaVersion": json.Number("1"), "bindingId": "pilot.bindings", + "requirements": []any{map[string]any{ + "claimLevel": "blocking", "nonClaims": requirementNonClaims, "ownerId": "pilot.owner", + "proofState": "witness_backed", "requirementId": "REQ-PILOT-001", "specPath": "docs/specs/pilot/requirements.v1.json", + }}, + "bindings": []any{map[string]any{ + "commandIds": []any{"pilot.command.test"}, "environmentClasses": []any{"local-go"}, "requirementId": "REQ-PILOT-001", + "scenarioId": "pilot.scenario.materialization", "witnessId": "pilot.witness.materialization", + "witnessKind": "contract", "witnessPath": "internal/pilot/materialization_test.go", + }}, + "witnessCommands": []any{map[string]any{ + "command": "go test ./internal/pilot", "commandId": "pilot.command.test", "environmentClasses": []any{"local-go"}, + }}, + "selection": map[string]any{"changedPaths": []any{}, "ownerIds": []any{}, "requirementIds": []any{}}, + "nonClaims": []any{"Pilot binding fixture does not execute witnesses."}, + }, + }, + "testEvidenceInventory": map[string]any{ + "path": "proofkit/test-evidence-inventory.json", + "record": map[string]any{ + "schemaVersion": json.Number("1"), "inventoryId": "pilot.inventory", "authority": "caller_owned_inventory", + "entries": []any{map[string]any{ + "testId": "pilot.test.materialization", "selector": "go test ./internal/pilot -run TestMaterialization", + "sourcePath": "internal/pilot/materialization_test.go", "ownerId": "pilot.owner", + "evidenceClass": "declared_semantic_falsifier_route", "requirementRefs": []any{"REQ-PILOT-001"}, + "ownerInvariantRefs": []any{}, "commandRefs": []any{"pilot.command.test"}, "witnessRefs": []any{"pilot.witness.materialization"}, + "falsifier": map[string]any{ + "falsifierId": "pilot.falsifier.materialization", "negativeCaseId": "pilot.case.materialization", + "wrongImplementationClassId": "pilot.wrong.materialization", "dominanceGroup": "pilot.materialization", "supersedes": []any{}, + }, + "oracle": map[string]any{ + "oracleId": "pilot.oracle.materialization", "oracleKind": "negative_exit_and_diagnostic", + "expectedPublicOutcome": "invalid materialization fails closed", + "assertionSummary": "A contradictory materialization request is rejected before mutation.", + }, + "nonClaims": []any{}, + }}, + "nonClaims": []any{"Pilot inventory fixture does not execute native tests."}, + }, + }, + "nonClaims": []any{"Pilot materialization request is test-only."}, + } +} diff --git a/internal/app/adoption_materialization_version_edge_test.go b/internal/app/adoption_materialization_version_edge_test.go new file mode 100644 index 0000000..209589b --- /dev/null +++ b/internal/app/adoption_materialization_version_edge_test.go @@ -0,0 +1,308 @@ +package app + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" +) + +const adoptionMaterializationVersionEdgePath = "internal/app/testdata/v0.8-wire-observations.json" + +type adoptionMaterializationVersionEdge struct { + AddedCommandContracts []materializationCommandContract `json:"addedCommandContracts"` + AdditionChangeIDs []string `json:"additionChangeIds"` + BreakingChangeIDs []string `json:"breakingChangeIds"` + ChangeClass string `json:"changeClass"` + ChangeRecordRef string `json:"changeRecordRef"` + ChangeRecordSHA256 string `json:"changeRecordSha256"` + CommandContractSelection string `json:"commandContractSelection"` + CurrentPublicABISHA256 string `json:"currentPublicAbiSha256"` + EdgeID string `json:"edgeId"` + EvidenceClass string `json:"evidenceClass"` + NonClaims []string `json:"nonClaims"` + PreviousPublicABISHA256 string `json:"previousPublicAbiSha256"` + PreviousVersion string `json:"previousVersion"` + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` +} + +type materializationCommandContract struct { + Command string `json:"command"` + InputContract *versionEdgeContractIdentity `json:"inputContract,omitempty"` + OutputContract versionEdgeContractIdentity `json:"outputContract"` + Route []string `json:"route"` +} + +type versionEdgeContractIdentity struct { + ContractID string `json:"contractId"` + ContractSHA256 string `json:"contractSha256"` +} + +func TestAdoptionMaterializationVersionEdgeClosesPublicCommands(t *testing.T) { + record := readAdoptionMaterializationVersionEdge(t) + currentABI := "sha256:" + currentCLIContractPublicABISHA256(t) + currentCommands, err := currentMaterializationCommandContracts(repoRoot(t)) + if err != nil { + t.Fatal(err) + } + if err := validateAdoptionMaterializationVersionEdge(record, repoRoot(t), currentABI, currentCommands); err != nil { + t.Fatal(err) + } + + mutants := []func(*adoptionMaterializationVersionEdge){ + func(value *adoptionMaterializationVersionEdge) { value.CurrentPublicABISHA256 += "0" }, + func(value *adoptionMaterializationVersionEdge) { + value.PreviousPublicABISHA256 = value.CurrentPublicABISHA256 + }, + func(value *adoptionMaterializationVersionEdge) { + value.AddedCommandContracts = value.AddedCommandContracts[1:] + }, + func(value *adoptionMaterializationVersionEdge) { + value.AddedCommandContracts[0].Route = []string{"adopt-materialize-apply"} + }, + func(value *adoptionMaterializationVersionEdge) { + value.AddedCommandContracts[0].InputContract.ContractID += ".drift" + }, + func(value *adoptionMaterializationVersionEdge) { + value.AddedCommandContracts[1].OutputContract.ContractSHA256 += "0" + }, + func(value *adoptionMaterializationVersionEdge) { + value.AddedCommandContracts[2].InputContract = &versionEdgeContractIdentity{} + }, + func(value *adoptionMaterializationVersionEdge) { value.AdditionChangeIDs = value.AdditionChangeIDs[1:] }, + func(value *adoptionMaterializationVersionEdge) { + value.BreakingChangeIDs = []string{"proofkit.unreported.breaking-change"} + }, + func(value *adoptionMaterializationVersionEdge) { value.ChangeRecordSHA256 += "0" }, + func(value *adoptionMaterializationVersionEdge) { value.CommandContractSelection = "all_digest_changes" }, + func(value *adoptionMaterializationVersionEdge) { value.NonClaims = nil }, + } + for index, mutate := range mutants { + t.Run(fmt.Sprintf("mutant-%d", index), func(t *testing.T) { + value := cloneAdoptionMaterializationVersionEdge(record) + mutate(&value) + if err := validateAdoptionMaterializationVersionEdge(value, repoRoot(t), currentABI, currentCommands); err == nil { + t.Fatal("materialization version-edge mutant was admitted") + } + }) + } +} + +func TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift(t *testing.T) { + record := readAdoptionMaterializationVersionEdge(t) + currentABI := "sha256:" + currentCLIContractPublicABISHA256(t) + currentCommands, err := currentMaterializationCommandContracts(repoRoot(t)) + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(repoRoot(t), record.ChangeRecordRef)) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + root := value.(map[string]any) + root["additions"].([]any)[0].(map[string]any)["changeId"] = "proofkit.adoption.transactional-materialization.drift" + mutantContent, err := json.MarshalIndent(root, "", " ") + if err != nil { + t.Fatal(err) + } + mutantContent = append(mutantContent, '\n') + mutantRoot := t.TempDir() + path := filepath.Join(mutantRoot, filepath.FromSlash(record.ChangeRecordRef)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, mutantContent, 0o600); err != nil { + t.Fatal(err) + } + mutant := cloneAdoptionMaterializationVersionEdge(record) + digest := sha256.Sum256(mutantContent) + mutant.ChangeRecordSHA256 = fmt.Sprintf("sha256:%x", digest) + if err := validateAdoptionMaterializationVersionEdge(mutant, mutantRoot, currentABI, currentCommands); err == nil || !strings.Contains(err.Error(), "contradicts") { + t.Fatalf("coordinated change-record mutant error=%v, want inventory contradiction", err) + } +} + +func readAdoptionMaterializationVersionEdge(t *testing.T) adoptionMaterializationVersionEdge { + t.Helper() + content, err := os.ReadFile(filepath.Join(repoRoot(t), adoptionMaterializationVersionEdgePath)) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + root, ok := value.(map[string]any) + if !ok { + t.Fatal("adoption materialization version edge must be an object") + } + assertExactObjectKeys(t, root, []string{"addedCommandContracts", "additionChangeIds", "breakingChangeIds", "changeClass", "changeRecordRef", "changeRecordSha256", "commandContractSelection", "currentPublicAbiSha256", "edgeId", "evidenceClass", "nonClaims", "previousPublicAbiSha256", "previousVersion", "schemaVersion", "version"}, "adoption materialization version edge") + added, ok := root["addedCommandContracts"].([]any) + if !ok { + t.Fatal("added command contracts must be an array") + } + for index, raw := range added { + item, ok := raw.(map[string]any) + if !ok { + t.Fatalf("added command contract %d must be an object", index) + } + keys := []string{"command", "outputContract", "route"} + if _, hasInput := item["inputContract"]; hasInput { + keys = append(keys, "inputContract") + slices.Sort(keys) + } + assertExactObjectKeys(t, item, keys, fmt.Sprintf("added command contract %d", index)) + for _, field := range []string{"inputContract", "outputContract"} { + identity, present := item[field] + if !present { + continue + } + record, ok := identity.(map[string]any) + if !ok { + t.Fatalf("added command contract %d %s must be an object", index, field) + } + assertExactObjectKeys(t, record, []string{"contractId", "contractSha256"}, fmt.Sprintf("added command contract %d %s", index, field)) + } + } + var record adoptionMaterializationVersionEdge + if err := json.Unmarshal(content, &record); err != nil { + t.Fatal(err) + } + return record +} + +func validateAdoptionMaterializationVersionEdge(record adoptionMaterializationVersionEdge, changeRecordRoot, currentPublicABI string, currentCommands []materializationCommandContract) error { + if record.SchemaVersion != 1 || record.EdgeID != "proofkit.public-wire.0.7.0-to-0.8.0" || record.EvidenceClass != "owner_authored_current_version_edge_observation" { + return fmt.Errorf("adoption materialization version-edge identity is invalid") + } + if record.PreviousVersion != "0.7.0" || record.Version != "0.8.0" || record.ChangeClass != "compatible" { + return fmt.Errorf("adoption materialization version-edge release identity is invalid") + } + if record.CommandContractSelection != "added_public_commands" { + return fmt.Errorf("adoption materialization command-contract selection policy is invalid") + } + if record.PreviousPublicABISHA256 != "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7" || record.CurrentPublicABISHA256 != currentPublicABI || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { + return fmt.Errorf("adoption materialization version-edge ABI identity is invalid") + } + if !slices.EqualFunc(record.AddedCommandContracts, currentCommands, equalMaterializationCommandContract) { + return fmt.Errorf("adoption materialization added command contracts are not exact") + } + if !slices.Equal(record.BreakingChangeIDs, []string{}) || !slices.Equal(record.AdditionChangeIDs, []string{"proofkit.adoption.transactional-materialization", "proofkit.repository.transaction-protocol"}) { + return fmt.Errorf("adoption materialization change inventory is not exact") + } + if record.ChangeRecordRef != releasechange.RecordPath { + return fmt.Errorf("adoption materialization change record reference is not exact") + } + changeRecordPath := filepath.Join(changeRecordRoot, filepath.FromSlash(record.ChangeRecordRef)) + changeRecordContent, err := os.ReadFile(changeRecordPath) + if err != nil { + return fmt.Errorf("read adoption materialization change record: %w", err) + } + digest := sha256.Sum256(changeRecordContent) + if record.ChangeRecordSHA256 != fmt.Sprintf("sha256:%x", digest) { + return fmt.Errorf("adoption materialization change record digest is not exact") + } + changeRecord, err := releasechange.Read(changeRecordPath) + if err != nil { + return fmt.Errorf("admit adoption materialization change record: %w", err) + } + if changeRecord.PreviousVersion != record.PreviousVersion || changeRecord.Version != record.Version || changeRecord.ChangeClass != record.ChangeClass || changeRecord.Migration.Required || len(changeRecord.Migration.Steps) != 0 { + return fmt.Errorf("adoption materialization change record identity is inconsistent") + } + if !slices.Equal(record.BreakingChangeIDs, releaseChangeIDs(changeRecord.BreakingChanges)) || !slices.Equal(record.AdditionChangeIDs, releaseChangeIDs(changeRecord.Additions)) { + return fmt.Errorf("adoption materialization change inventory contradicts the bound change record") + } + if !slices.Equal(record.NonClaims, []string{"This owner-authored version-edge observation binds source and contract identities; it does not authenticate registry publication, provider ingestion, consumer adoption, native witness truth, rollout, or production readiness."}) { + return fmt.Errorf("adoption materialization version-edge non-claims are not exact") + } + return nil +} + +func currentMaterializationCommandContracts(root string) ([]materializationCommandContract, error) { + content, err := os.ReadFile(filepath.Join(root, "proofkit", "cli-contract.v2.json")) + if err != nil { + return nil, fmt.Errorf("read current CLI contract: %w", err) + } + contract, err := admission.DecodeTypedJSON[cliContract](bytes.NewReader(content), int64(len(content))) + if err != nil { + return nil, fmt.Errorf("admit current CLI contract: %w", err) + } + result := make([]materializationCommandContract, 0, 3) + for _, name := range []string{"adopt-materialize-apply", "adopt-materialize-plan", "adopt-materialize-recover"} { + var command *cliContractCommand + for index := range contract.Commands { + if contract.Commands[index].Command == name { + command = &contract.Commands[index] + break + } + } + if command == nil { + return nil, fmt.Errorf("current CLI contract is missing %s", name) + } + metadata := generatedCommandContractMetadataByName[name] + if metadata.OutputContractSHA256 == "" || (command.InputContract != nil && metadata.InputContractSHA256 == "") { + return nil, fmt.Errorf("generated command contract metadata is incomplete for %s", name) + } + item := materializationCommandContract{ + Command: name, + OutputContract: versionEdgeContractIdentity{ + ContractID: contractIDFromRaw(command.OutputContract), + ContractSHA256: metadata.OutputContractSHA256, + }, + Route: effectiveContractRoute(*command), + } + if command.InputContract != nil { + item.InputContract = &versionEdgeContractIdentity{ + ContractID: contractIDFromRaw(command.InputContract), + ContractSHA256: metadata.InputContractSHA256, + } + } + result = append(result, item) + } + return result, nil +} + +func contractIDFromRaw(raw any) string { + record, _ := raw.(map[string]any) + contractID, _ := record["contractId"].(string) + return contractID +} + +func equalMaterializationCommandContract(left, right materializationCommandContract) bool { + return left.Command == right.Command && slices.Equal(left.Route, right.Route) && equalOptionalContractIdentity(left.InputContract, right.InputContract) && left.OutputContract == right.OutputContract +} + +func equalOptionalContractIdentity(left, right *versionEdgeContractIdentity) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + +func cloneAdoptionMaterializationVersionEdge(record adoptionMaterializationVersionEdge) adoptionMaterializationVersionEdge { + record.AddedCommandContracts = append([]materializationCommandContract(nil), record.AddedCommandContracts...) + for index := range record.AddedCommandContracts { + record.AddedCommandContracts[index].Route = append([]string(nil), record.AddedCommandContracts[index].Route...) + if record.AddedCommandContracts[index].InputContract != nil { + value := *record.AddedCommandContracts[index].InputContract + record.AddedCommandContracts[index].InputContract = &value + } + } + record.AdditionChangeIDs = append([]string(nil), record.AdditionChangeIDs...) + record.BreakingChangeIDs = append([]string(nil), record.BreakingChangeIDs...) + record.NonClaims = append([]string(nil), record.NonClaims...) + return record +} diff --git a/internal/app/app.go b/internal/app/app.go index 18afc7d..2e21bae 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -112,6 +112,8 @@ func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io return writeText(usageWithRenderer(renderer), 0, nil, stdout, stderr) case commandRunnerAdoptionFrontDoor: return runAdoptionFrontDoor(ctx, descriptor.name, args[1:], stdout, stderr, capabilities) + case commandRunnerAdoptionMaterialization: + return runAdoptionMaterialization(ctx, descriptor.name, args[1:], stdin, stdout, stderr, capabilities) case commandRunnerAdoptionDoctor: return runAgentEnvelopeCommand(args[0], args[1:], stdin, stdout, stderr, agentEnvelopeBuilders{ build: adoptiondoctor.Build, diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index b646412..7d02ede 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7" + cliContractPublicABISHA256 = "47311b441bb2f68f7485c54c15daad275340c27f8f7a68cfcd3fb4d92e9b976e" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -1518,6 +1518,9 @@ func TestDescriptorFlagConstraintsMatchCommandParsers(t *testing.T) { func TestDescriptorFlagConstraintsAreRenderedTruthfully(t *testing.T) { expectedConstrainedUsage := map[string]string{ + "adopt-materialize-apply": "agentic-proofkit adopt materialize apply --input [--color ] --expect-desired-state --expect-transaction [--format ] [--input-pointer ] --repo-root ", + "adopt-materialize-plan": "agentic-proofkit adopt materialize plan --input [--color ] [--format ] [--input-pointer ] --repo-root ", + "adopt-materialize-recover": "agentic-proofkit adopt materialize recover --action [--color ] [--format ] --repo-root --transaction ", "adopt-plan": "agentic-proofkit adopt plan [--color ] [--format ] --mode --repo-root [--stack ]", "adoption-contract-envelope": "agentic-proofkit adoption-contract-envelope --input [--agent-envelope] [--checked-scope ] [--guidance-mode ] [--materialization-manifest] --mode [--pilot ] [--touched-rule-id ]", "conformance-profile": "agentic-proofkit conformance-profile --input [--format ] [--input-pointer ] (--list | --profile | --verify)", diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index ea8abbe..02b9483 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "e0c7484b588a119947ee6f2568ecc44fdb5e93c3a6fa1d61e677bde45df682fe" +const commandContractSourceSHA256 = "6703b4a1a4ca3499477ffb6f694a230138db40810f86e38f1950d3d4088f02b0" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,12 +12,15 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ + "adopt-materialize-apply": {InputContractSHA256: "sha256:72c303d1a9d586b0e5d8e3bf33ae2e1e0aa78b8d62e496fa55b53b36c22d3079", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:8b532d1d0887c30a0dc1194553cda987f8379e0a55cf35dda24714a6f2ca90c7", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:4b16fc73012d3ffc049c7fdc0103951f081ce9b5d08e86814a59c41cd08ea55b", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:aca3230f658bfd98e1f8319b78ea99abb984c7fcb572e38f2fe41c515097eb54", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, + "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:fa97d129e1a920a814e852bfa97528e85fb952c8fde710bb52cd749672becb18", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "adoption-checklist": {InputContractSHA256: "sha256:4e6c4c9b369279837a5894c0b3f842a411dce529b91c91cb2d4ec63eb5ee4c2c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-checklist.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9d0d0e60f0935407fd31007d8502459663eb4c7228dc5e3c7727ae2c9907bdc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, "adoption-doctor": {InputContractSHA256: "sha256:efa9acfe32bff07f56d9dc9902530df2979794289bc2f7f547f7a108a7dd0f35", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-doctor.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8fdfc6608f197e633f042f20031ae1014872a90aa3daa66885ffcaddca994766", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-doctor"}}, "adoption-workflow-plan": {InputContractSHA256: "sha256:b32ae67179d7b6dcf1ea66cb6b2b2691c8367ce2e2be367619b65973166da55c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-workflow-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8d64cb53ebd0307e3cebc3435286a3d2a1ee8a0ad6f7514fc0fb3285db0f565b", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-workflow-plan"}}, - "agent-route": {InputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a972f7178986b89334262db8047aabbb5a7b67025b30a009a96af47722b707c6", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, + "agent-route": {InputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8e7b4f4847171df5d3a628ab9c806c497553fd2713d1afcb67079342d2ef2111", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, "binding-partition": {InputContractSHA256: "sha256:366ad082045af52b2ac6604f18626d0f285b2db73b45d9a82687b8d3b0d2b3fd", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.binding-partition.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:52840879e13a00ef9a4abaad6cdb33000511674d5f9003fb56f387fdf58fadc8", FlagChoices: map[string][]string{}, RouteTokens: []string{"binding-partition"}}, "branch-authority": {InputContractSHA256: "sha256:8a3ed74978898593fbdbf1f7fa684dae450fbd9019edcd60d07f818d63363ed4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.branch-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3c7dc74842299b92cd5baf57cc8666e9415963091359e5faf654e28da89561f1", FlagChoices: map[string][]string{}, RouteTokens: []string{"branch-authority"}}, "capability-map-admission": {InputContractSHA256: "sha256:e49433f295c43c34d5d660ac9d656b117ed87208406b57723d25165ffec5d486", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.capability-map-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bfa35fe1be210ab98f3620694ab63b52a9724f7b92cd9dcbfd7b01b2c6a3555e", FlagChoices: map[string][]string{}, RouteTokens: []string{"capability-map-admission"}}, @@ -41,7 +44,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "native-evidence-guidance": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c9306d800668ecea18aaced6a21334036a935570f267baed356e6a4888025c8d", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"native-evidence-guidance"}}, "obligation-decision": {InputContractSHA256: "sha256:1dea2ed5c5066451d6d49b815cea99df2cdae2ef05d42fed16c8aeb45eb7f445", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.obligation-decision.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:96dc074f611bcc12e511bc803c548e4df623e2de869d3add29a3ea6386e04330", FlagChoices: map[string][]string{}, RouteTokens: []string{"obligation-decision"}}, "package-runtime-dependency-admission": {InputContractSHA256: "sha256:fc85887af9b8fcd899d245f0db30b2f2f68609822fc268126bf999082bb4115f", InputSchemaSummary: []string{"schemaVersion=1", "reportId", "expectedDependencySpec", "expectedLockfileIntegrity", "expectedPackageName", "expectedPackageVersion", "admissibleLocations{}", "packageResolution{}", "nonClaims[]", "root-shape-only definition proofkit.package-runtime-dependency-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c012032e8c8212fd50bc2e85669cc610609ca2124ebc992c9e88f44a1ad2d5fc", FlagChoices: map[string][]string{}, RouteTokens: []string{"package-runtime-dependency-admission"}}, - "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f684b389f8da6a2f8f4a6a7dc9748dccf28dd5e30ae037850fa2487d21428df6", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, + "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:5e05ba3035eb3f269223abff071f64800b5cea0253442ce5923ecc2512a4bd7c", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, "producer-policy-self-proof": {InputContractSHA256: "sha256:d48e18826000c8d415f3c44b6c686e1da6ed962ef7ca36c9f705de8c68d034f9", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.producer-policy-self-proof.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e82a3989a743f8babc6069f7af82b1dd1ea62bad8dbb18d95e105b36f74e4276", FlagChoices: map[string][]string{}, RouteTokens: []string{"producer-policy-self-proof"}}, "proof-obligation-algebra": {InputContractSHA256: "sha256:4f176b6bc9bdbd0d96d65c071d66447d246665bda7a23269e7927f1d0b80b043", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-obligation-algebra.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f9ee9e56b349756c55856a2dab198e1ad85db70a468c38e3aeca73cfe2ed66f6", FlagChoices: map[string][]string{}, RouteTokens: []string{"proof-obligation-algebra"}}, "proof-receipt-admission": {InputContractSHA256: "sha256:7cb4c4fb60c8b5a37109bbd8c00d567749f7d181bbc905d8bc58155f139c44cb", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-receipt-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3f802ac3fac6762ede51f0e0a151f16dc10b4a20344a3887b3ee8bae43ce94f2", FlagChoices: map[string][]string{}, RouteTokens: []string{"proof-receipt-admission"}}, @@ -61,7 +64,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "requirement-browser-server": {InputContractSHA256: "sha256:acb36856dc08ae8efa68986bb4c419f480951b1ad9ee9a43fc1d6c53d30ee71b", InputSchemaSummary: []string{"workspace mode: schemaVersion=2", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input schemaVersion=2 (optional)", "workspace mode: graphInput=proofkit.requirement-traceability-graph-input schemaVersion=2 (optional)", "--session-mode values: browse|one-shot-question", "one-shot-question requires --view workspace --serve --open", "--session-timeout-seconds is 1..7200 and requires one-shot-question", "source|proof|coverage|spec-tree modes retain their owner input contracts", "root-shape-only definition proofkit.requirement-browser-server.input.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:16e741d88e5ede4271c5e769c724e72164fcba461f0c6c17d285f318f8e03005", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, "requirement-context-compose": {InputContractSHA256: "sha256:3b06ebca2a07d01b34005d915918b8a7743ef8175901ee8dd0a37ecfb6ee80fe", InputSchemaSummary: []string{"schemaVersion=1", "catalogId", "specTree.path", "requirementSources[] (non-empty)", "requirementSources[].nodeId", "requirementSources[].path", "expectedSourceDigest (optional sha256 ref)", "proofBinding.path (optional)", "coverage.path (optional)", "exact catalog paths only; no discovery", "root-shape-only definition proofkit.requirement-context-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a2b8bf00d2308628e1835fe890dc6e9c4f402c531726f0bdaffad1d7100a7466", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-compose"}}, "requirement-context-slice": {InputContractSHA256: "sha256:883e864e44944270f7b85c013635835e5e1f1a49a7fd7ca3ebe860c7b56da601", InputSchemaSummary: []string{"schemaVersion=1", "sliceId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", "query.maxNodes=1..4096", "query.maxRequirements=1..16384", "root-shape-only definition proofkit.requirement-context-slice.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:971f78ec94bb29a24057343ccfb3d1192134d2ac88968aab5193e3d64ac87506", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-slice"}}, - "requirement-coverage-input-compose": {InputContractSHA256: "sha256:c18294cc0dc76949ea7df200d45d3eca7a1d0e48ff086778502f6b88eaeda614", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:fd38161adcb4f676d58135cdfd2b01d99806fcdce85df97a993258650118bde4", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-input-compose"}}, + "requirement-coverage-input-compose": {InputContractSHA256: "sha256:1980d1fc5c3c3cfe08f557e3c55f7128de5bae0d9b25bd2a46d7c6e47db73faa", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3a8686e91f3a229b273531d9c03cb6ce86ec76b66f8c35c6ca7f361b6e5eeaff", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-input-compose"}}, "requirement-coverage-view": {InputContractSHA256: "sha256:40b06176f12dc9ec92226d7c01be53df5aa2b0e949a7791a894c8cf148e44b21", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-view.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4ec6c0bb6d616d23b777e59721a3ff045e6b447401333300031d641218c926bf", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-view"}}, "requirement-impact-input-compose": {InputContractSHA256: "sha256:c80c57489205004f92603fec541ce3d36dd0d9b65109dcfefb97bcfb07b90679", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-impact-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b0b689f4d0b5bafc52c6041a2aa3583c9c8628aa0f13a0f0d7c42a610ed1a0d6", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-impact-input-compose"}}, "requirement-proof-resolver": {InputContractSHA256: "sha256:7ffedf651fbeda57f11f780373ae8f2b15dd587ce3aaddf739a092bc4835f2c5", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-proof-resolver.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d6b1fbbf4a7fe3624c64f88c8e316fd927df8f35355198962110dab41113bc94", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-proof-resolver"}}, @@ -80,11 +83,11 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "selective-gate-evidence": {InputContractSHA256: "sha256:8aa178ab7ca7c475c23707bc4e15fd3f9f8d57acf6f6dcf279677e7769a45586", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-evidence.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:723569262bb85d9674b2a78d3bcb6e9f4cab229b71e8c784ff1b804a7fcade71", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-evidence"}}, "selective-gate-obligation-decision-input": {InputContractSHA256: "sha256:85761fcbc0ea94239d55bf379d0592a6ca814e6612a2d609a651f6cdaf8ca10a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-obligation-decision-input.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:ab9dddabe975238d7019266c43350afa2df1a61d4c2eb7bc23afd520b588a2da", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-obligation-decision-input"}}, "selective-gate-plan": {InputContractSHA256: "sha256:5293a5a4c7d8426cf637e6f8d252095ca0eb1714365bb89bec83307b778c678a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d7bffed853af5595af08b03859be01c283a3bdff1b3502d94ddc190889977647", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-plan"}}, - "self-check": {InputContractSHA256: "sha256:ec13b324d3276ba82a58b683ba053db12e9a29b4747eb1f498972882ce84722b", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a40ca0b4ad6b0f58ba927679a7380556e81c8420a4cba78fffa1a05b92d4e418", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, + "self-check": {InputContractSHA256: "sha256:f33a8575835a9b67c00ded036fdde467eaab650a7d509e1b7af5ca825a0fc671", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f0c4211fd71c4dd042bd1862a0e31d89265ae56707d78930471d0f7cb3fcd279", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, "spec-overview-claims": {InputContractSHA256: "sha256:2490dcd34ba7485e13f8f33e8a288a0463c4c52cc6b0d82c57777466927e49a4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-overview-claims.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:554f3a7020e9820ccb90672629fd769c52b2f298f356040aa3b0a817666cbfbf", FlagChoices: map[string][]string{}, RouteTokens: []string{"spec-overview-claims"}}, "spec-proof-bundle-admission": {InputContractSHA256: "sha256:6b6c2875b6476e63a1911e7d6112d9999df2babbee969f84abc4c9e4b470c933", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-proof-bundle-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e9e0eb66cebca3b99fe5036fb2e7327a9284934ed76f58818d18094d0546fc52", FlagChoices: map[string][]string{}, RouteTokens: []string{"spec-proof-bundle-admission"}}, "stack-preset": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:ef5920f363a4a96dcac308ea8412260a06e64ba4876460a369aefb8983130a9d", FlagChoices: map[string][]string{"--preset": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"stack-preset"}}, - "test-evidence-inventory": {InputContractSHA256: "sha256:8d07bc37143833d4332106ca332bbc06abf24adddccf6d7448e5faf42771c19e", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.test-evidence-inventory.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4e91925c8c135fa8f95121ebb2e7824f5a68ba41fa9b0171c07ac0501b14ce59", FlagChoices: map[string][]string{}, RouteTokens: []string{"test-evidence-inventory"}}, + "test-evidence-inventory": {InputContractSHA256: "sha256:c2e26a02e127eec069275c9afa902a20cb9e062f4008db35e042d8fe0e3fdd60", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.test-evidence-inventory.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:06fd4be6a34e73708f8539fed04ec41b2a4c9c79cf0e0dcc311496c0e6aafcbf", FlagChoices: map[string][]string{}, RouteTokens: []string{"test-evidence-inventory"}}, "text-policy": {InputContractSHA256: "sha256:afc366b0bcdcb33d7b85d3347f832cb647de68cb737cfa81a52e55f5b4901038", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.text-policy.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:94031bd5ee75115e80ebe10cb8c3c7b77806dc349721ba550cacaa99ff8184e4", FlagChoices: map[string][]string{}, RouteTokens: []string{"text-policy"}}, "typescript-public-api-surfaces": {InputContractSHA256: "sha256:11ddef5ae86c8f5ff9fcb8cfac540c8ea680cd61233e54e9ba74563b28020e2a", InputSchemaSummary: []string{"schemaVersion=1", "machineContract=public_api_surfaces", "entries[].packageManifestPath", "entries[].packageName", "entries[].exportKey", "entries[].exportConditions[] (non-empty, sorted unique by condition)", "entries[].exportConditions[].condition", "entries[].exportConditions[].path", "entries[].exportConditions[].sourcePath (declared and canonical target .ts/.mts/.cts)", "entries[].runtimeExports[]", "entries[].typeExports[]", "entries[].deniedExportKeys[] (optional)", "sourceGrammar=fail_closed_restricted_typescript_exports_v1", "maxSourceFileBytes=8388608", "maxPackageManifestBytes=262144", "maxAggregateFileReadBytes=67108864", "root-shape-only definition proofkit.typescript-public-api-surfaces.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f26372435db16de436cd5483089d06d3723496574b28d80de97ee36ccddf1587", FlagChoices: map[string][]string{}, RouteTokens: []string{"typescript-public-api-surfaces"}}, "witness-plan": {InputContractSHA256: "sha256:7814c5d27487a361bac77045afe32c6449c24881f04d5496da7182b3f2c0c1ee", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.witness-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:722c577bf5cf1dab8c9da8d18fc0634e1b9a6471aa5ca0b05f55b730cdd0d303", FlagChoices: map[string][]string{}, RouteTokens: []string{"witness-plan"}}, diff --git a/internal/app/command_coverage_routes.go b/internal/app/command_coverage_routes.go index 90601d0..a2f5716 100644 --- a/internal/app/command_coverage_routes.go +++ b/internal/app/command_coverage_routes.go @@ -59,6 +59,9 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ "adoption-checklist": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/adoptionchecklist/adoptionchecklist_test.go", "TestBuildClassifiesRequiredChecklistItemsAndPreservesOptionalNonFailures", semanticRouteProof("adoptionchecklist.build_classifies_required_checklist_items_and_preserves_optional_non_failures"), "Adoption checklist reports must fail missing, blocked, and not-applicable required items while preserving optional non-failures.")}, "adoption-contract-envelope": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/adoptioncontract/adoptioncontract_test.go", "TestBuildDelegatesModesWithParity", semanticRouteProof("adoptioncontract.build_delegates_modes_with_parity"), "Adoption contract envelope admission must prove aggregate-root admission while delegating selected modes to existing child command outputs without drift.")}, "adoption-doctor": {requiredInputAdmissionRoute, directCLIRoute("internal/app/cli_abi_test.go", "TestAdoptionDoctorCLIABI", semanticRouteProof("cli_abi.adoption_doctor_cliabi"), "Adoption doctor CLI ABI must emit stable report and agent-envelope JSON for admitted caller records."), packageFalsifierRoute("internal/command/adoptiondoctor/adoptiondoctor_test.go", "TestBuildFailsEnforcementForCandidateBoundaryAndMissingRoutes", semanticRouteProof("adoptiondoctor.build_fails_enforcement_for_candidate_boundary_and_missing_routes"), "Adoption doctor reports must fail closed for enforcement modes when caller-provided owner routes or candidate boundaries are not admitted.")}, + "adopt-materialize-apply": {requiredInputAdmissionRoute, directCLIRoute("internal/app/adoption_materialization_command_test.go", "TestAdoptionMaterializationCLI", semanticRouteProof("adoption_materialization.apply_whole_cli"), "Adoption materialization apply must recompute the complete plan, require both expected state identities, and emit an owner-admitted receipt for each terminal outcome.")}, + "adopt-materialize-plan": {requiredInputAdmissionRoute, directCLIRoute("internal/app/adoption_materialization_command_test.go", "TestAdoptionMaterializationCLI", semanticRouteProof("adoption_materialization.plan_whole_cli"), "Adoption materialization planning must remain read-only and emit a deterministic owner-admitted plan from fully admitted candidate records.")}, + "adopt-materialize-recover": {directCLIRoute("internal/app/adoption_materialization_command_test.go", "TestAdoptionMaterializationCLI", semanticRouteProof("adoption_materialization.recover_whole_cli"), "Adoption materialization recovery must admit one exact transaction and state-compatible action before repository access and emit a replay-stable receipt.")}, "adoption-workflow-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/adoptionworkflow/adoptionworkflow_test.go", "TestBuildGeneratesBoundedCommandArgv", semanticRouteProof("adoptionworkflow.build_generates_bounded_command_argv"), "Adoption workflow plans must generate bounded argv commands from admitted route refs.")}, "agent-route": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/agentroute/agentroute_test.go", "TestBuildRoutesRequirementSourceAndBlocksUnknownGoal", semanticRouteProof("agentroute.build_routes_requirement_source_and_blocks_unknown_goal"), "Agent route reports must select a deterministic command family from explicit caller-owned input and fail closed for unknown goals."), packageFalsifierRoute("internal/command/agentroute/agentroute_test.go", "TestBuildEnvelopeKeepsBlockedRoutesAsStopSignals", semanticRouteProof("agentroute.build_envelope_keeps_blocked_routes_as_stop_signals"), "Agent route envelopes must preserve missing-input route states as stop signals instead of executable guidance."), packageFalsifierRoute("internal/command/agentroute/agentroute_test.go", "TestBuildEnvelopeCarriesBlockedObservedReportPreconditions", semanticRouteProof("agentroute.build_envelope_carries_blocked_observed_report_preconditions"), "Agent route envelopes must preserve non-passed observed reports as blocked preconditions instead of executable guidance.")}, "binding-partition": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/bindingpartition/bindingpartition_test.go", "TestBuildRejectsCrossSurfaceRouteReferenceWithoutDelegation", semanticRouteProof("bindingpartition.build_rejects_cross_surface_route_reference_without_delegation"), "Binding partition admission must reject undelegated cross-surface proof route references.")}, diff --git a/internal/app/command_coverage_test.go b/internal/app/command_coverage_test.go index 3fe0caa..d7b54c2 100644 --- a/internal/app/command_coverage_test.go +++ b/internal/app/command_coverage_test.go @@ -445,7 +445,8 @@ func TestRequiredInputCommandsRejectMalformedCallerRecords(t *testing.T) { continue } t.Run(command.Command, func(t *testing.T) { - args := append([]string{command.Command, "--input", "-"}, malformedInputExtraArgs(command.Command)...) + args := append(effectiveContractRoute(command), "--input", "-") + args = append(args, malformedInputExtraArgs(command.Command)...) var stdout bytes.Buffer var stderr bytes.Buffer status := Run(t.Context(), args, strings.NewReader(`{"schemaVersion":1,"unexpected":true}`), &stdout, &stderr) @@ -521,6 +522,8 @@ func TestNoInputCommandDescriptorsHaveRuntimeSmoke(t *testing.T) { func noInputRuntimeSmokeArgs(t *testing.T, descriptor commandDescriptor) ([]string, bool) { t.Helper() switch descriptor.name { + case "adopt-materialize-recover": + return append(cloneStrings(descriptor.routeTokens), "--help"), false case "adopt-plan": return append(cloneStrings(descriptor.routeTokens), "--mode", "fresh", "--repo-root", t.TempDir()), true case "help": @@ -599,6 +602,14 @@ func writeGoTestFixture(t *testing.T, source string) string { func malformedInputExtraArgs(command string) []string { switch command { + case "adopt-materialize-apply": + return []string{ + "--expect-desired-state", "sha256:" + strings.Repeat("0", 64), + "--expect-transaction", "sha256:" + strings.Repeat("1", 64), + "--repo-root", ".", + } + case "adopt-materialize-plan": + return []string{"--repo-root", "."} case "adoption-contract-envelope": return []string{"--mode", "workflow"} case "conformance-profile": diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index 42804d4..231a190 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -20,6 +20,7 @@ type commandRunner string const ( commandRunnerGenericInput commandRunner = "generic_input" commandRunnerAdoptionFrontDoor commandRunner = "adoption_front_door" + commandRunnerAdoptionMaterialization commandRunner = "adoption_materialization" commandRunnerAdoptionContractEnvelope commandRunner = "adoption_contract_envelope" commandRunnerAdoptionDoctor commandRunner = "adoption_doctor" commandRunnerAdoptionWorkflow commandRunner = "adoption_workflow" @@ -46,9 +47,10 @@ const ( type commandScopeClass string const ( - commandScopeBuiltInPackageCatalog commandScopeClass = "built_in_package_catalog" - commandScopeExplicitCallerInput commandScopeClass = "explicit_caller_input" - commandScopeExplicitFileSystemScan commandScopeClass = "explicit_filesystem_scan" + commandScopeBuiltInPackageCatalog commandScopeClass = "built_in_package_catalog" + commandScopeExplicitCallerInput commandScopeClass = "explicit_caller_input" + commandScopeExplicitFileSystemScan commandScopeClass = "explicit_filesystem_scan" + commandScopeExplicitFileSystemMutation commandScopeClass = "explicit_filesystem_mutation" ) type commandDescriptor struct { @@ -92,6 +94,9 @@ type requiredFlagValue struct { } var commandDescriptors = []commandDescriptor{ + command("adopt-materialize-apply", commandInputRequired, flags("--color", "--expect-desired-state", "--expect-transaction", "--format", "--input", "--input-pointer", "--repo-root"), modes("json", "text"), ownerDirs("adoptionmaterialization"), withRunner(commandRunnerAdoptionMaterialization), withSemanticAppTests("TestAdoptionMaterializationCLI"), withScopeClass(commandScopeExplicitFileSystemMutation), withRequiredFlags("--expect-desired-state", "--expect-transaction", "--repo-root"), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--color", "--expect-desired-state", "--expect-transaction", "--input", "--input-pointer", "--repo-root")), + command("adopt-materialize-plan", commandInputRequired, flags("--color", "--format", "--input", "--input-pointer", "--repo-root"), modes("json", "text"), ownerDirs("adoptionmaterialization"), withRunner(commandRunnerAdoptionMaterialization), withSemanticAppTests("TestAdoptionMaterializationCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root"), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--color", "--input", "--input-pointer", "--repo-root")), + command("adopt-materialize-recover", commandInputNone, flags("--action", "--color", "--format", "--repo-root", "--transaction"), modes("json", "text"), ownerDirs("adoptionmaterialization"), withRunner(commandRunnerAdoptionMaterialization), withSemanticAppTests("TestAdoptionMaterializationCLI"), withScopeClass(commandScopeExplicitFileSystemMutation), withRequiredFlags("--action", "--repo-root", "--transaction"), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--action", "--color", "--repo-root", "--transaction")), command("adopt-plan", commandInputNone, flags("--color", "--format", "--mode", "--repo-root", "--stack"), modes("json", "text"), ownerDirs("adoptionplan", "repositoryinventory"), withRunner(commandRunnerAdoptionFrontDoor), withSemanticAppTests("TestAdoptionFrontDoorCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--mode", "--repo-root"), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--color", "--mode", "--repo-root", "--stack")), command("adoption-checklist", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("adoptionchecklist")), command("adoption-contract-envelope", commandInputRequired, flags("--agent-envelope", "--checked-scope", "--guidance-mode", "--input", "--materialization-manifest", "--mode", "--pilot", "--touched-rule-id"), modes("json"), ownerDirs("adoptioncontract"), withRunner(commandRunnerAdoptionContractEnvelope), withAgentEnvelope(), withRequiredFlags("--mode")), @@ -178,6 +183,7 @@ var commandDescriptors = []commandDescriptor{ var knownCommandRunners = map[commandRunner]struct{}{ commandRunnerGenericInput: {}, commandRunnerAdoptionFrontDoor: {}, + commandRunnerAdoptionMaterialization: {}, commandRunnerAdoptionContractEnvelope: {}, commandRunnerAdoptionDoctor: {}, commandRunnerAdoptionWorkflow: {}, @@ -202,9 +208,10 @@ var knownCommandRunners = map[commandRunner]struct{}{ } var knownCommandScopeClasses = map[commandScopeClass]struct{}{ - commandScopeBuiltInPackageCatalog: {}, - commandScopeExplicitCallerInput: {}, - commandScopeExplicitFileSystemScan: {}, + commandScopeBuiltInPackageCatalog: {}, + commandScopeExplicitCallerInput: {}, + commandScopeExplicitFileSystemScan: {}, + commandScopeExplicitFileSystemMutation: {}, } var commandDescriptorByName = buildCommandDescriptorIndex(commandDescriptors) diff --git a/internal/app/command_family_catalog_generated.go b/internal/app/command_family_catalog_generated.go index c6b0253..2c54238 100644 --- a/internal/app/command_family_catalog_generated.go +++ b/internal/app/command_family_catalog_generated.go @@ -1,13 +1,14 @@ // Code generated by internal/tools/commandfamilygen; DO NOT EDIT. package app -const commandFamilyCatalogSourceSHA256 = "4372ad45898c54cf4389819dea2c024462044f1a2c8df2a4d81e28b0423f6489" +const commandFamilyCatalogSourceSHA256 = "43f2fd15005e84d9e999de262d6a5f6ec2534f2fb0b94975fc2fb4f78ed9b82f" func generatedCommandFamilyCatalog() commandFamilyCatalog { return commandFamilyCatalog{ CatalogID: "proofkit.command-families.v1", Families: []commandFamily{ {ID: "adoption-lifecycle", Label: "Adoption lifecycle", Purpose: "Select and assess repository adoption stages.", Commands: []string{"adopt-plan", "adoption-checklist", "adoption-doctor", "adoption-workflow-plan", "gradual-adoption", "gradual-adoption-bootstrap", "gradual-adoption-guidance", "pilot-admission"}}, + {ID: "adoption-materialization", Label: "Adoption materialization", Purpose: "Plan, apply, and recover confined candidate adoption artifacts.", Commands: []string{"adopt-materialize-apply", "adopt-materialize-plan", "adopt-materialize-recover"}}, {ID: "agent-workflow-planning", Label: "Agent workflow planning", Purpose: "Plan bounded engineering-change stages and expose repository-neutral native-evidence guidance.", Commands: []string{"change-workflow-plan", "native-evidence-guidance"}}, {ID: "cli-metadata-and-conformance", Label: "CLI metadata and conformance", Purpose: "Expose, route, generate, and self-check CLI contract surfaces.", Commands: []string{"agent-route", "conformance-profile", "help", "json-report-cli-adapter-source", "self-check"}}, {ID: "deployment-and-readiness", Label: "Deployment and readiness", Purpose: "Admit deployment evidence and bounded closeout decisions.", Commands: []string{"branch-authority", "completion-criteria", "deployment-evidence-admission", "readiness-closeout"}}, diff --git a/internal/app/command_help.go b/internal/app/command_help.go index 0a15930..0e2a4a9 100644 --- a/internal/app/command_help.go +++ b/internal/app/command_help.go @@ -146,6 +146,8 @@ func commandUsageLine(descriptor commandDescriptor) string { segments = append(segments, "[--format ]") case "--repo-root": segments = append(segments, optionalUsageSegment(flag+" ", required)) + case "--expect-desired-state", "--expect-transaction", "--transaction": + segments = append(segments, optionalUsageSegment(flag+" ", required)) case "--host": segments = append(segments, "[--host 127.0.0.1|::1]") case "--port": diff --git a/internal/app/compact_contract_source_closure_test.go b/internal/app/compact_contract_source_closure_test.go index f2a1a07..7d36a6e 100644 --- a/internal/app/compact_contract_source_closure_test.go +++ b/internal/app/compact_contract_source_closure_test.go @@ -1,16 +1,22 @@ package app import ( + "bytes" + "encoding/json" "os" "path/filepath" "slices" "sort" "strings" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" ) const compactOwnerImportPath = "github.com/research-engineering/agentic-proofkit/internal/kernel/compactproofcontract" +const compactCurrentProductionConsumersPath = "internal/app/testdata/compact-current-production-consumers.json" + const proofkitModuleImportPath = "github.com/research-engineering/agentic-proofkit" var compactConsumerRoots = []string{"."} @@ -19,6 +25,14 @@ const compactProductionConsumerEvidenceClass = "conservative_static_candidate_in const compactProductionConsumerNonClaim = "Candidate inclusion proves a bounded static dependency or schema signal only; it does not prove runtime invocation, semantic ownership, or an absence of consumers that violate the declared static signal policy." +type compactProductionConsumerInventory struct { + EvidenceClass string `json:"evidenceClass"` + InventoryID string `json:"inventoryId"` + NonClaims []string `json:"nonClaims"` + Paths []string `json:"paths"` + SchemaVersion int `json:"schemaVersion"` +} + var compactDistinctiveKeys = map[string]struct{}{ "authority_state": {}, "bindingRecordId": {}, @@ -82,20 +96,20 @@ var compactSemanticSinks = []compactSemanticSink{{ }, }} -func TestCompactV2ProductionConsumerCandidateInventoryIsClosed(t *testing.T) { - manifest := readCompactWireManifest(t) +func TestCompactProductionConsumerCandidateInventoryIsClosed(t *testing.T) { + inventory := readCompactProductionConsumerInventory(t) actual, err := discoverCompactSymbolConsumersAcrossReleaseBuilds(repoRoot(t), compactConsumerRoots, compactReviewedWrappers, compactSemanticSinks) if err != nil { t.Fatal(err) } - if !slices.Equal(actual, manifest.ProductionConsumerCandidates) { - t.Fatalf("compact production consumer candidates=%v want literal manifest %v", actual, manifest.ProductionConsumerCandidates) + if !slices.Equal(actual, inventory.Paths) { + t.Fatalf("compact production consumer candidates=%v want literal current inventory %v", actual, inventory.Paths) } } func TestCompactV2ProductionSurfacesContainNoLegacyVocabulary(t *testing.T) { - manifest := readCompactWireManifest(t) - paths := append([]string{}, manifest.ProductionConsumerCandidates...) + inventory := readCompactProductionConsumerInventory(t) + paths := append([]string{}, inventory.Paths...) paths = append(paths, "docs/proofkit-contract-map.md", "docs/specs/proofkit-spec-proof-core/overview.md", @@ -135,6 +149,40 @@ func TestCompactV2ProductionSurfacesContainNoLegacyVocabulary(t *testing.T) { } } +func readCompactProductionConsumerInventory(t *testing.T) compactProductionConsumerInventory { + t.Helper() + content, err := os.ReadFile(filepath.Join(repoRoot(t), compactCurrentProductionConsumersPath)) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + record, ok := value.(map[string]any) + if !ok { + t.Fatal("compact production consumer inventory must be an object") + } + assertExactObjectKeys(t, record, []string{"evidenceClass", "inventoryId", "nonClaims", "paths", "schemaVersion"}, "compact production consumer inventory") + if number, ok := record["schemaVersion"].(json.Number); !ok || number.String() != "1" { + t.Fatalf("compact production consumer inventory schemaVersion=%v want 1", record["schemaVersion"]) + } + inventory, err := admission.DecodeTypedJSON[compactProductionConsumerInventory](bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + if inventory.InventoryID != "proofkit.compact.production-consumer-candidates.current" || inventory.EvidenceClass != compactProductionConsumerEvidenceClass { + t.Fatal("compact production consumer inventory identity is invalid") + } + if !slices.Equal(inventory.NonClaims, []string{compactProductionConsumerNonClaim}) { + t.Fatal("compact production consumer inventory non-claims are not exact") + } + if !sort.StringsAreSorted(inventory.Paths) || hasAdjacentDuplicate(inventory.Paths) { + t.Fatal("compact production consumer inventory paths must be sorted and unique") + } + return inventory +} + func TestCompactReviewedWrappersResolveToCompactConsumerCandidates(t *testing.T) { if _, err := discoverCompactSymbolConsumersAcrossReleaseBuilds(repoRoot(t), compactConsumerRoots, compactReviewedWrappers, nil); err != nil { t.Fatal(err) diff --git a/internal/app/testdata/compact-current-production-consumers.json b/internal/app/testdata/compact-current-production-consumers.json new file mode 100644 index 0000000..3236d93 --- /dev/null +++ b/internal/app/testdata/compact-current-production-consumers.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "inventoryId": "proofkit.compact.production-consumer-candidates.current", + "evidenceClass": "conservative_static_candidate_inventory", + "paths": [ + "cmd/agentic-proofkit/main.go", + "internal/app/adoption_commands.go", + "internal/app/adoption_materialization_command.go", + "internal/app/app.go", + "internal/app/command_registry.go", + "internal/app/conformance_command.go", + "internal/app/requirement_browser_command.go", + "internal/app/requirement_commands.go", + "internal/app/requirement_context_command.go", + "internal/app/requirement_proof_resolver_command.go", + "internal/app/test_evidence_inventory_command.go", + "internal/command/adoptioncontract/adoptioncontract.go", + "internal/command/adoptionmaterialization/admission.go", + "internal/command/adoptionmaterialization/build.go", + "internal/command/conformanceprofile/conformanceprofile.go", + "internal/command/impact/impact.go", + "internal/command/pilotadmission/pilotadmission.go", + "internal/command/proofbindingtestinventory/proofbindingtestinventory.go", + "internal/command/requirementbinding/requirementbinding.go", + "internal/command/requirementbrowser/requirementbrowser.go", + "internal/command/requirementbrowser/server.go", + "internal/command/requirementbrowser/workspace.go", + "internal/command/requirementcontext/compose.go", + "internal/command/requirementcontext/model.go", + "internal/command/requirementcontext/slice.go", + "internal/command/requirementcontext/v1_adapter.go", + "internal/command/requirementcoverageinput/requirementcoverageinput.go", + "internal/command/requirementcoverageview/admission.go", + "internal/command/requirementcoverageview/build.go", + "internal/command/requirementcoverageview/output_admission.go", + "internal/command/requirementcoverageview/output_diagnostics.go", + "internal/command/requirementcoverageview/output_proof_projection.go", + "internal/command/requirementcoverageview/output_semantics.go", + "internal/command/requirementcoverageview/projection.go", + "internal/command/requirementcoverageview/requirementcoverageview.go", + "internal/command/requirementcoverageview/types.go", + "internal/command/requirementdiff/requirementdiff.go", + "internal/command/requirementgraph/requirementgraph.go", + "internal/command/requirementimpactinput/requirementimpactinput.go", + "internal/command/requirementproofsourceset/requirementproofsourceset.go", + "internal/command/requirementproofview/requirementproofview.go", + "internal/command/testevidenceinventory/normalized_projection.go", + "internal/command/testevidenceinventory/source_set.go", + "internal/command/testevidenceinventory/testevidenceinventory.go", + "internal/tools/browsertestserver/main.go", + "internal/tools/coveragemetrics/main.go" + ], + "nonClaims": [ + "Candidate inclusion proves a bounded static dependency or schema signal only; it does not prove runtime invocation, semantic ownership, or an absence of consumers that violate the declared static signal policy." + ] +} diff --git a/internal/app/testdata/v0.7-release-change-record.v2.json b/internal/app/testdata/v0.7-release-change-record.v2.json new file mode 100644 index 0000000..cb498ec --- /dev/null +++ b/internal/app/testdata/v0.7-release-change-record.v2.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 2, + "previousVersion": "0.6.0", + "version": "0.7.0", + "changeClass": "breaking", + "breakingChanges": [ + { + "changeId": "proofkit.adoption.init-retired", + "summary": "Remove the overloaded init command and its route presets in favor of the explicit read-only adopt plan trust-mode route and the existing bounded specialist commands." + }, + { + "changeId": "proofkit.agent-route.input-contract-v2", + "summary": "Replace the agent-route input contract identity with proofkit.agent-route.input.v2 so the materialized-reference rule that rejects the stdin sentinel is machine-distinguishable from earlier v1 semantics; the wire schema remains version 1." + } + ], + "additions": [ + { + "changeId": "proofkit.adoption.front-door", + "summary": "Add adopt plan as a read-only candidate-authoring front door with explicit fresh, code-baseline, and audit-from-code intent plus an optional orthogonal stack hint." + }, + { + "changeId": "proofkit.adoption.repository-inventory", + "summary": "Add a bounded explicit repository-inventory command that observes only a fixed root-file catalog without stack or source-semantic inference." + }, + { + "changeId": "proofkit.cli.generated-adapter-command-routes", + "summary": "Extend the generated TypeScript CLI adapter to consume the exact public contract-projected one-to-four-token command-route grammar and pass each admitted route token as a separate process argument while preserving one-token calls." + }, + { + "changeId": "proofkit.cli.hierarchical-command-routes", + "summary": "Publish one exact bounded command-route grammar in the CLI process contract and add owner-generated multi-token routes while retaining stable internal command IDs for contract and implementation ownership." + }, + { + "changeId": "proofkit.python-wheel.embedded-cli-contract", + "summary": "Embed the exact public CLI contract in every Python wheel and use the installed record to prove command-family route closure." + } + ], + "migration": { + "required": true, + "steps": [ + "Replace explicit init --preset fresh with adopt plan --mode fresh --repo-root .", + "Replace init --preset code-baseline with adopt plan --mode code-baseline --repo-root , and replace init --preset code-audit with adopt plan --mode audit-from-code --repo-root .", + "Replace init --preset legacy with migration-parity-admission followed by migration-plan over explicit caller-owned records; run requirement-source-transition when the migration changes requirement lifecycle state.", + "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", + "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", + "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", + "Replace persisted proofkit.agent-route.input.v1 contract identity with proofkit.agent-route.input.v2; the admitted wire schemaVersion remains 1." + ] + }, + "platformRequirements": [ + "Published Darwin package binaries require macOS 13.0 or later on arm64 and x86_64." + ], + "knownLimitations": [ + "Adopt plan inventories only a fixed root-file catalog; it does not infer stack identity, inspect arbitrary source semantics, generate requirements, write files, or execute native evidence.", + "Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", + "Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", + "Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", + "The selected requirement-source v2 codec remains internal; current requirement sources are not migrated and no source cutover is claimed.", + "TSX source parsing remains unsupported." + ], + "rollback": { + "strategy": "previous_admitted_version" + } +} diff --git a/internal/app/testdata/v0.7-wire-observations.json b/internal/app/testdata/v0.7-wire-observations.json index f5166af..b3fb0ab 100644 --- a/internal/app/testdata/v0.7-wire-observations.json +++ b/internal/app/testdata/v0.7-wire-observations.json @@ -5,7 +5,7 @@ "version": "0.7.0", "evidenceClass": "owner_authored_frozen_version_edge_observation", "commandContractSelection": "declared_input_contract_id_change", - "changeRecordRef": "release/change-record.v2.json", + "changeRecordRef": "internal/app/testdata/v0.7-release-change-record.v2.json", "changeRecordSha256": "sha256:25dfeccb631449f0c1eb0d1bc6d42d483c3100b34d7ede7a0d16390f3bef3d49", "changedGeneratedArtifacts": [ { diff --git a/internal/app/testdata/v0.8-wire-observations.json b/internal/app/testdata/v0.8-wire-observations.json new file mode 100644 index 0000000..021f6ae --- /dev/null +++ b/internal/app/testdata/v0.8-wire-observations.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "edgeId": "proofkit.public-wire.0.7.0-to-0.8.0", + "previousVersion": "0.7.0", + "version": "0.8.0", + "evidenceClass": "owner_authored_current_version_edge_observation", + "changeClass": "compatible", + "commandContractSelection": "added_public_commands", + "changeRecordRef": "release/change-record.v2.json", + "changeRecordSha256": "sha256:4c8434f7b77a5c623441b021a37b50786d56aba7f65091c38be5ed902231318d", + "previousPublicAbiSha256": "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7", + "currentPublicAbiSha256": "sha256:47311b441bb2f68f7485c54c15daad275340c27f8f7a68cfcd3fb4d92e9b976e", + "addedCommandContracts": [ + { + "command": "adopt-materialize-apply", + "route": ["adopt", "materialize", "apply"], + "inputContract": { + "contractId": "proofkit.adopt-materialize-apply.input.v1", + "contractSha256": "sha256:72c303d1a9d586b0e5d8e3bf33ae2e1e0aa78b8d62e496fa55b53b36c22d3079" + }, + "outputContract": { + "contractId": "proofkit.adopt-materialize-apply.output.v1", + "contractSha256": "sha256:8b532d1d0887c30a0dc1194553cda987f8379e0a55cf35dda24714a6f2ca90c7" + } + }, + { + "command": "adopt-materialize-plan", + "route": ["adopt", "materialize", "plan"], + "inputContract": { + "contractId": "proofkit.adopt-materialize-plan.input.v1", + "contractSha256": "sha256:4b16fc73012d3ffc049c7fdc0103951f081ce9b5d08e86814a59c41cd08ea55b" + }, + "outputContract": { + "contractId": "proofkit.adopt-materialize-plan.output.v1", + "contractSha256": "sha256:aca3230f658bfd98e1f8319b78ea99abb984c7fcb572e38f2fe41c515097eb54" + } + }, + { + "command": "adopt-materialize-recover", + "route": ["adopt", "materialize", "recover"], + "outputContract": { + "contractId": "proofkit.adopt-materialize-recover.output.v1", + "contractSha256": "sha256:fa97d129e1a920a814e852bfa97528e85fb952c8fde710bb52cd749672becb18" + } + } + ], + "breakingChangeIds": [], + "additionChangeIds": [ + "proofkit.adoption.transactional-materialization", + "proofkit.repository.transaction-protocol" + ], + "nonClaims": [ + "This owner-authored version-edge observation binds source and contract identities; it does not authenticate registry publication, provider ingestion, consumer adoption, native witness truth, rollout, or production readiness." + ] +} diff --git a/internal/command/adoptionmaterialization/admission.go b/internal/command/adoptionmaterialization/admission.go new file mode 100644 index 0000000..501af39 --- /dev/null +++ b/internal/command/adoptionmaterialization/admission.go @@ -0,0 +1,136 @@ +package adoptionmaterialization + +import ( + "fmt" + "sort" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionplan" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +func admitRequest(raw any) (Request, error) { + record, ok := raw.(map[string]any) + if !ok { + return Request{}, fmt.Errorf("adoption materialization request must be an object") + } + if err := admit.KnownKeys(record, []string{"nonClaims", "projectId", "requestId", "requestKind", "requirementProofBinding", "requirementSources", "schemaVersion", "sourcePlan", "testEvidenceInventory"}, "adoption materialization request"); err != nil { + return Request{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], SchemaVersion) || record["requestKind"] != RequestKind { + return Request{}, fmt.Errorf("adoption materialization request identity is invalid") + } + requestID, err := admit.RuleID(record["requestId"], "adoption materialization requestId") + if err != nil { + return Request{}, err + } + projectID, err := admit.RuleID(record["projectId"], "adoption materialization projectId") + if err != nil { + return Request{}, err + } + sourcePlan, err := adoptionplan.AdmitOutput(record["sourcePlan"]) + if err != nil { + return Request{}, err + } + sourcePlanID, sourceIntent := sourcePlanCoordinates(sourcePlan) + sources, err := admitSources(record["requirementSources"]) + if err != nil { + return Request{}, err + } + bindingPath, binding, err := admitBindingArtifact(record["requirementProofBinding"]) + if err != nil { + return Request{}, err + } + inventoryPath, inventory, err := admitInventoryArtifact(record["testEvidenceInventory"]) + if err != nil { + return Request{}, err + } + nonClaims, err := admit.PreserveSortedTextArray(record["nonClaims"], "adoption materialization nonClaims", true) + if err != nil { + return Request{}, err + } + request := Request{ + Binding: binding, BindingPath: bindingPath, Inventory: inventory, InventoryPath: inventoryPath, + NonClaims: nonClaims, ProjectID: projectID, RequestID: requestID, SourceIntent: sourceIntent, + SourcePlanID: sourcePlanID, Sources: sources, + } + if err := validateClosure(request); err != nil { + return Request{}, err + } + return request, nil +} + +func admitSources(raw any) ([]requirementsourceadmission.Source, error) { + values, ok := raw.([]any) + if !ok || len(values) == 0 || len(values) > MaximumRequirementSources { + return nil, fmt.Errorf("adoption materialization requirementSources count is invalid") + } + sources := make([]requirementsourceadmission.Source, 0, len(values)) + for _, value := range values { + result, err := requirementsourceadmission.Evaluate(value) + if err != nil { + return nil, err + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("adoption materialization requires passed requirement source admission") + } + sources = append(sources, result.Source) + } + sort.Slice(sources, func(left, right int) bool { return sources[left].RequirementsPath < sources[right].RequirementsPath }) + return sources, nil +} + +func admitBindingArtifact(raw any) (string, requirementbinding.Input, error) { + path, child, err := admitArtifactWrapper(raw, "requirementProofBinding") + if err != nil { + return "", requirementbinding.Input{}, err + } + result, err := requirementbinding.Build(child) + if err != nil { + return "", requirementbinding.Input{}, err + } + if result.Record.State != "passed" { + return "", requirementbinding.Input{}, fmt.Errorf("adoption materialization requires passed requirement proof binding admission") + } + return path, result.Input, nil +} + +func admitInventoryArtifact(raw any) (string, testevidenceinventory.Inventory, error) { + path, child, err := admitArtifactWrapper(raw, "testEvidenceInventory") + if err != nil { + return "", testevidenceinventory.Inventory{}, err + } + result, err := testevidenceinventory.EvaluateDirect(child) + if err != nil { + return "", testevidenceinventory.Inventory{}, err + } + if result.ExitCode != 0 { + return "", testevidenceinventory.Inventory{}, fmt.Errorf("adoption materialization requires passed direct test evidence inventory admission") + } + return path, result.Inventory, nil +} + +func admitArtifactWrapper(raw any, context string) (string, any, error) { + record, ok := raw.(map[string]any) + if !ok { + return "", nil, fmt.Errorf("adoption materialization %s must be an object", context) + } + if err := admit.KnownKeys(record, []string{"path", "record"}, "adoption materialization "+context); err != nil { + return "", nil, err + } + pathText, err := admit.NonEmptyText(record["path"], "adoption materialization "+context+" path") + if err != nil { + return "", nil, err + } + path, err := admit.SafeRepoRelativePath(pathText, "adoption materialization "+context+" path") + if err != nil { + return "", nil, err + } + child, ok := record["record"] + if !ok { + return "", nil, fmt.Errorf("adoption materialization %s record is required", context) + } + return path, child, nil +} diff --git a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go new file mode 100644 index 0000000..3df473f --- /dev/null +++ b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go @@ -0,0 +1,405 @@ +package adoptionmaterialization + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionplan" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func TestMaterializationWholeChainIsCanonicalAndOwnerClosed(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + + materialization, err := BuildPlan(context.Background(), request, root) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + planBytes, err := stablejson.Marshal(materialization.Plan.JSONValue()) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"Pilot materialization preserves admitted requirement meaning", "go test ./internal/pilot"} { + if bytes.Contains(planBytes, []byte(forbidden)) { + t.Fatalf("plan disclosed payload %q: %s", forbidden, planBytes) + } + } + if _, err := os.Stat(filepath.Join(root, ".agentic-proofkit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read-only plan created transaction state: %v", err) + } + + receipt, exitCode, err := Apply(context.Background(), request, root, materialization.Transaction.TransactionID, materialization.Transaction.DesiredStateID) + if err != nil || exitCode != 0 || receipt.State != ReceiptStatePassed || receipt.TransactionResult == nil || receipt.TransactionResult.State != repositorytransaction.StateApplied { + t.Fatalf("Apply() receipt=%#v exit=%d err=%v", receipt, exitCode, err) + } + + sourceRaw := readJSON(t, filepath.Join(root, "docs/specs/pilot/requirements.v1.json")) + source, err := requirementsourceadmission.Evaluate(sourceRaw) + if err != nil || source.ExitCode != 0 { + t.Fatalf("materialized source admission=%#v err=%v", source, err) + } + bindingRaw := readJSON(t, filepath.Join(root, "proofkit/requirement-bindings.json")) + binding, err := requirementbinding.Build(bindingRaw) + if err != nil || binding.Record.State != "passed" { + t.Fatalf("materialized binding admission=%#v err=%v", binding, err) + } + inventoryRaw := readJSON(t, filepath.Join(root, "proofkit/test-evidence-inventory.json")) + inventory, err := testevidenceinventory.EvaluateDirect(inventoryRaw) + if err != nil || inventory.ExitCode != 0 { + t.Fatalf("materialized inventory admission=%#v err=%v", inventory, err) + } + manifestRaw := readJSON(t, filepath.Join(root, ProjectManifestPath)) + manifest, err := AdmitManifest(manifestRaw) + if err != nil || manifest.ProjectID != "pilot.project" || len(manifest.Routes) != 3 { + t.Fatalf("materialized manifest=%#v err=%v", manifest, err) + } +} + +func TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + initial, err := BuildPlan(context.Background(), request, root) + if err != nil { + t.Fatal(err) + } + + changed := cloneRequest(t, request) + source := changed["requirementSources"].([]any)[0].(map[string]any) + requirement := source["requirements"].([]any)[0].(map[string]any) + requirement["invariant"] = "Pilot materialization preserves revised admitted requirement meaning." + blocked, exitCode, err := Apply(context.Background(), changed, root, initial.Transaction.TransactionID, initial.Transaction.DesiredStateID) + if err != nil || exitCode != 1 || blocked.State != ReceiptStateBlocked || blocked.FailureClass != "desired_state_identity_mismatch" || blocked.TransactionResult != nil { + t.Fatalf("stale Apply() receipt=%#v exit=%d err=%v", blocked, exitCode, err) + } + if _, err := os.Stat(filepath.Join(root, "docs")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale apply mutated repository: %v", err) + } + + first, exitCode, err := Apply(context.Background(), request, root, initial.Transaction.TransactionID, initial.Transaction.DesiredStateID) + if err != nil || exitCode != 0 || first.State != ReceiptStatePassed { + t.Fatalf("first Apply() receipt=%#v exit=%d err=%v", first, exitCode, err) + } + retry, exitCode, err := Apply(context.Background(), request, root, initial.Transaction.TransactionID, initial.Transaction.DesiredStateID) + if err != nil || exitCode != 0 || retry.State != ReceiptStatePassed || retry.TransactionResult == nil || retry.TransactionResult.State != repositorytransaction.StateAlreadySatisfied { + t.Fatalf("retry Apply() receipt=%#v exit=%d err=%v", retry, exitCode, err) + } + if retry.ExpectedTransactionID != initial.Transaction.TransactionID || retry.ExpectedDesiredStateID != initial.Transaction.DesiredStateID || retry.TransactionResult.TransactionID == initial.Transaction.TransactionID { + t.Fatalf("retry did not distinguish expected and observed transactions: %#v", retry) + } + wrongDesired := "sha256:" + strings.Repeat("0", 64) + blocked, exitCode, err = Apply(context.Background(), request, root, initial.Transaction.TransactionID, wrongDesired) + if err != nil || exitCode != 1 || blocked.FailureClass != "desired_state_identity_mismatch" { + t.Fatalf("wrong desired-state Apply() receipt=%#v exit=%d err=%v", blocked, exitCode, err) + } +} + +func TestApplyDistinguishesStaleBeforeSnapshotFromDesiredState(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + initial, err := BuildPlan(context.Background(), request, root) + if err != nil { + t.Fatal(err) + } + existing := cloneRequest(t, request)["requirementSources"].([]any)[0].(map[string]any) + existing["requirements"].([]any)[0].(map[string]any)["invariant"] = "A different but owner-valid current invariant." + content, err := stablejson.Marshal(existing) + if err != nil { + t.Fatal(err) + } + mustWrite(t, root, "docs/specs/pilot/requirements.v1.json", content) + receipt, exitCode, err := Apply(context.Background(), request, root, initial.Transaction.TransactionID, initial.Transaction.DesiredStateID) + if err != nil || exitCode != 1 || receipt.State != ReceiptStateBlocked || receipt.FailureClass != "transaction_identity_mismatch" { + t.Fatalf("stale-before Apply() receipt=%#v exit=%d err=%v", receipt, exitCode, err) + } +} + +func TestApplyReportsObservedPendingTransaction(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + initial, err := BuildPlan(context.Background(), request, root) + if err != nil { + t.Fatal(err) + } + observedID := "sha256:" + strings.Repeat("a", 64) + tombstone := filepath.Join(root, ".agentic-proofkit", "transactions", "gc-"+strings.TrimPrefix(observedID, "sha256:")+"-applied") + if err := os.MkdirAll(tombstone, 0o700); err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Join(root, ".agentic-proofkit"), filepath.Join(root, ".agentic-proofkit", "transactions"), tombstone} { + if err := os.Chmod(path, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(tombstone, "ready"), nil, 0o600); err != nil { + t.Fatal(err) + } + receipt, exitCode, err := Apply(context.Background(), request, root, initial.Transaction.TransactionID, initial.Transaction.DesiredStateID) + if err != nil || exitCode != 1 || receipt.State != ReceiptStateRecoveryRequired || receipt.TransactionResult == nil || receipt.TransactionResult.TransactionID != observedID { + t.Fatalf("pending Apply() receipt=%#v exit=%d err=%v", receipt, exitCode, err) + } +} + +func TestMaterializationReplacesOnlyCompatibleOwnerRecords(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + initial, err := BuildPlan(context.Background(), request, root) + if err != nil { + t.Fatal(err) + } + if _, exitCode, err := Apply(context.Background(), request, root, initial.Transaction.TransactionID, initial.Transaction.DesiredStateID); err != nil || exitCode != 0 { + t.Fatalf("initial Apply() exit=%d err=%v", exitCode, err) + } + + changed := cloneRequest(t, request) + requirement := changed["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any) + requirement["invariant"] = "Pilot materialization preserves a reviewed replacement invariant." + replacement, err := BuildPlan(context.Background(), changed, root) + if err != nil { + t.Fatalf("replacement BuildPlan() error = %v", err) + } + if _, exitCode, err := Apply(context.Background(), changed, root, replacement.Transaction.TransactionID, replacement.Transaction.DesiredStateID); err != nil || exitCode != 0 { + t.Fatalf("replacement Apply() exit=%d err=%v", exitCode, err) + } + got := readJSON(t, filepath.Join(root, "docs/specs/pilot/requirements.v1.json")).(map[string]any) + gotInvariant := got["requirements"].([]any)[0].(map[string]any)["invariant"] + if gotInvariant != requirement["invariant"] { + t.Fatalf("materialized invariant=%q, want %q", gotInvariant, requirement["invariant"]) + } + + unknownRoot := t.TempDir() + unknown := validRequest(t, unknownRoot) + mustWrite(t, unknownRoot, "proofkit/requirement-bindings.json", []byte("{}\n")) + if _, err := BuildPlan(context.Background(), unknown, unknownRoot); err == nil || !strings.Contains(err.Error(), "incompatible ownership") { + t.Fatalf("BuildPlan(unknown owner) error=%v", err) + } + if _, err := os.Stat(filepath.Join(unknownRoot, ".agentic-proofkit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("owner rejection created transaction state: %v", err) + } +} + +func TestMaterializationRejectsCrossRecordDriftAndManifestMutation(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + drifted := cloneRequest(t, request) + binding := drifted["requirementProofBinding"].(map[string]any)["record"].(map[string]any) + binding["requirements"].([]any)[0].(map[string]any)["ownerId"] = "pilot.other" + if _, err := BuildPlan(context.Background(), drifted, root); err == nil || !strings.Contains(err.Error(), "projection does not match") { + t.Fatalf("BuildPlan(drifted owner) error=%v", err) + } + + materialization, err := BuildPlan(context.Background(), request, root) + if err != nil { + t.Fatal(err) + } + manifest := cloneValue(t, materialization.Plan.Manifest.JSONValue()).(map[string]any) + manifest["routes"].([]any)[0].(map[string]any)["path"] = "../outside.json" + if _, err := AdmitManifest(manifest); err == nil { + t.Fatal("AdmitManifest() accepted root-escaping route") + } + + colliding := cloneRequest(t, request) + colliding["requirementSources"].([]any)[0].(map[string]any)["sourceId"] = "pilot.bindings" + if _, err := BuildPlan(context.Background(), colliding, root); err == nil || !strings.Contains(err.Error(), "artifactIds must be unique") { + t.Fatalf("BuildPlan(colliding artifact IDs) error=%v", err) + } +} + +func TestReceiptOutcomeIsOperationSpecific(t *testing.T) { + tests := []struct { + operation string + state string + want string + exitCode int + }{ + {OperationApply, repositorytransaction.StateApplied, ReceiptStatePassed, 0}, + {OperationApply, repositorytransaction.StateAlreadySatisfied, ReceiptStatePassed, 0}, + {OperationApply, repositorytransaction.StateRolledBack, ReceiptStateFailed, 1}, + {OperationRecover, repositorytransaction.StateApplied, ReceiptStatePassed, 0}, + {OperationRecover, repositorytransaction.StateRolledBack, ReceiptStatePassed, 0}, + {OperationRecover, repositorytransaction.StateRecoveryRequired, ReceiptStateRecoveryRequired, 1}, + {OperationRecover, repositorytransaction.StateCleanupRequired, ReceiptStateCleanupRequired, 1}, + {OperationRecover, repositorytransaction.StateDurabilityUnknown, ReceiptStateDurabilityUnknown, 1}, + } + for _, test := range tests { + got, exitCode := receiptOutcome(test.operation, repositorytransaction.Result{State: test.state}) + if got != test.want || exitCode != test.exitCode { + t.Fatalf("receiptOutcome(%s, %s)=(%s,%d), want (%s,%d)", test.operation, test.state, got, exitCode, test.want, test.exitCode) + } + } +} + +func validRequest(t *testing.T, root string) map[string]any { + t.Helper() + mustWrite(t, root, "README.md", []byte("# Pilot\n")) + inventory, err := repositoryinventory.Scan(context.Background(), root) + if err != nil { + t.Fatal(err) + } + sourcePlan, err := adoptionplan.Build(adoptionplan.IntentFresh, inventory, "") + if err != nil { + t.Fatal(err) + } + requirementNonClaims := []any{"Pilot requirement fixture does not prove rollout."} + return map[string]any{ + "schemaVersion": json.Number("1"), + "requestKind": RequestKind, + "requestId": "pilot.materialization.request", + "projectId": "pilot.project", + "sourcePlan": sourcePlan.JSONValue(), + "requirementSources": []any{map[string]any{ + "schemaVersion": json.Number("1"), + "sourceId": "pilot.requirements", + "specPackagePath": "docs/specs/pilot", + "overviewPath": "docs/specs/pilot/overview.md", + "requirementsPath": "docs/specs/pilot/requirements.v1.json", + "nonClaims": []any{"Pilot source fixture does not prove production readiness."}, + "requirements": []any{map[string]any{ + "claimLevel": "blocking", + "deferral": nil, + "invariant": "Pilot materialization preserves admitted requirement meaning.", + "lifecycle": map[string]any{ + "evidenceRefs": []any{}, + "replacementRequirementIds": []any{}, + "state": "active", + }, + "nonClaimRefs": []any{}, + "nonClaims": requirementNonClaims, + "ownerId": "pilot.owner", + "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, + "requirementId": "REQ-PILOT-001", + "riskClass": "high", + "updatePolicy": map[string]any{ + "requiresImpactDeclaration": true, + "requiresProofBindingReview": true, + "reviewOwnerId": "pilot.owner", + }, + }}, + }}, + "requirementProofBinding": map[string]any{ + "path": "proofkit/requirement-bindings.json", + "record": map[string]any{ + "schemaVersion": json.Number("1"), + "bindingId": "pilot.bindings", + "requirements": []any{map[string]any{ + "claimLevel": "blocking", + "nonClaims": requirementNonClaims, + "ownerId": "pilot.owner", + "proofState": "witness_backed", + "requirementId": "REQ-PILOT-001", + "specPath": "docs/specs/pilot/requirements.v1.json", + }}, + "bindings": []any{map[string]any{ + "commandIds": []any{"pilot.command.test"}, + "environmentClasses": []any{"local-go"}, + "requirementId": "REQ-PILOT-001", + "scenarioId": "pilot.scenario.materialization", + "witnessId": "pilot.witness.materialization", + "witnessKind": "contract", + "witnessPath": "internal/pilot/materialization_test.go", + }}, + "witnessCommands": []any{map[string]any{ + "command": "go test ./internal/pilot", + "commandId": "pilot.command.test", + "environmentClasses": []any{"local-go"}, + }}, + "selection": map[string]any{ + "changedPaths": []any{}, + "ownerIds": []any{}, + "requirementIds": []any{}, + }, + "nonClaims": []any{"Pilot binding fixture does not execute witnesses."}, + }, + }, + "testEvidenceInventory": map[string]any{ + "path": "proofkit/test-evidence-inventory.json", + "record": map[string]any{ + "schemaVersion": json.Number("1"), + "inventoryId": "pilot.inventory", + "authority": "caller_owned_inventory", + "entries": []any{map[string]any{ + "testId": "pilot.test.materialization", + "selector": "go test ./internal/pilot -run TestMaterialization", + "sourcePath": "internal/pilot/materialization_test.go", + "ownerId": "pilot.owner", + "evidenceClass": "declared_semantic_falsifier_route", + "requirementRefs": []any{"REQ-PILOT-001"}, + "ownerInvariantRefs": []any{}, + "commandRefs": []any{"pilot.command.test"}, + "witnessRefs": []any{"pilot.witness.materialization"}, + "falsifier": map[string]any{ + "falsifierId": "pilot.falsifier.materialization", + "negativeCaseId": "pilot.case.materialization", + "wrongImplementationClassId": "pilot.wrong.materialization", + "dominanceGroup": "pilot.materialization", + "supersedes": []any{}, + }, + "oracle": map[string]any{ + "oracleId": "pilot.oracle.materialization", + "oracleKind": "negative_exit_and_diagnostic", + "expectedPublicOutcome": "invalid materialization fails closed", + "assertionSummary": "A contradictory materialization request is rejected before mutation.", + }, + "nonClaims": []any{}, + }}, + "nonClaims": []any{"Pilot inventory fixture does not execute native tests."}, + }, + }, + "nonClaims": []any{"Pilot materialization request is test-only."}, + } +} + +func cloneRequest(t *testing.T, request map[string]any) map[string]any { + t.Helper() + return cloneValue(t, request).(map[string]any) +} + +func cloneValue(t *testing.T, value any) any { + t.Helper() + content, err := stablejson.Marshal(value) + if err != nil { + t.Fatal(err) + } + clone, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + return clone +} + +func readJSON(t *testing.T, path string) any { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + value, err := admission.DecodeJSON(file, repositorytransaction.MaximumFileBytes) + if err != nil { + t.Fatal(err) + } + return value +} + +func mustWrite(t *testing.T, root, relative string, content []byte) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/command/adoptionmaterialization/build.go b/internal/command/adoptionmaterialization/build.go new file mode 100644 index 0000000..fe87dab --- /dev/null +++ b/internal/command/adoptionmaterialization/build.go @@ -0,0 +1,245 @@ +package adoptionmaterialization + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func BuildPlan(ctx context.Context, raw any, repositoryRoot string) (Materialization, error) { + request, err := admitRequest(raw) + if err != nil { + return Materialization{}, err + } + children, err := childArtifacts(request) + if err != nil { + return Materialization{}, err + } + manifest, err := buildManifest(request, children) + if err != nil { + return Materialization{}, err + } + manifestArtifact, err := encodeArtifact(ArtifactProjectManifest, manifest.ManifestID, ProjectManifestPath, manifest.JSONValue()) + if err != nil { + return Materialization{}, err + } + artifacts := append(children, manifestArtifact) + targets := make([]repositorytransaction.Target, 0, len(artifacts)) + for _, item := range artifacts { + targets = append(targets, repositorytransaction.Target{Content: item.Content, Mode: 0o644, Path: item.Path}) + } + transaction, err := repositorytransaction.BuildPlan(ctx, repositoryRoot, targets) + if err != nil { + return Materialization{}, err + } + if err := validateExistingArtifacts(transaction, artifacts, request.ProjectID); err != nil { + return Materialization{}, err + } + plan := Plan{ + Manifest: manifest, NonClaims: mergedNonClaims(request.NonClaims), ProjectID: request.ProjectID, + RequestID: request.RequestID, SourceIntent: request.SourceIntent, SourcePlanID: request.SourcePlanID, + Transaction: transaction, + } + encoded, err := stablejson.Marshal(plan.JSONValue()) + if err != nil || len(encoded) > MaximumOutputBytes { + return Materialization{}, fmt.Errorf("adoption materialization plan exceeds its output byte limit") + } + return Materialization{Artifacts: artifacts, Plan: plan, Transaction: transaction}, nil +} + +func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, expectedDesiredStateID string) (Receipt, int, error) { + expected, err := admit.SHA256Ref(expectedTransactionID, "adoption materialization expected transaction") + if err != nil { + return Receipt{}, 1, err + } + expectedDesired, err := admit.SHA256Ref(expectedDesiredStateID, "adoption materialization expected desired state") + if err != nil { + return Receipt{}, 1, err + } + materialization, err := BuildPlan(ctx, raw, repositoryRoot) + if err != nil { + if errors.Is(err, repositorytransaction.ErrRecoveryRequired) { + return pendingReceipt(OperationApply, expected, expectedDesired, err, nil) + } + return Receipt{}, 1, err + } + if materialization.Transaction.DesiredStateID != expectedDesired { + return blockedReceipt(OperationApply, expected, expectedDesired, "desired_state_identity_mismatch", materialization.Plan.NonClaims) + } + if materialization.Transaction.TransactionID != expected && transactionHasChanges(materialization.Transaction) { + return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_identity_mismatch", materialization.Plan.NonClaims) + } + result, err := repositorytransaction.Apply(ctx, repositoryRoot, materialization.Transaction) + if err != nil { + if errors.Is(err, repositorytransaction.ErrBusy) || errors.Is(err, repositorytransaction.ErrRecoveryRequired) { + if errors.Is(err, repositorytransaction.ErrRecoveryRequired) { + return pendingReceipt(OperationApply, expected, expectedDesired, err, materialization.Plan.NonClaims) + } + return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_busy", materialization.Plan.NonClaims) + } + return Receipt{}, 1, err + } + return resultReceipt(OperationApply, expected, expectedDesired, result, materialization.Plan.NonClaims) +} + +func Recover(ctx context.Context, repositoryRoot, transactionID, action string) (Receipt, int, error) { + admittedID, err := admit.SHA256Ref(transactionID, "adoption materialization transaction") + if err != nil { + return Receipt{}, 1, err + } + result, err := repositorytransaction.Recover(ctx, repositoryRoot, admittedID, action) + if err != nil { + return Receipt{}, 1, err + } + return resultReceipt(OperationRecover, admittedID, "", result, nil) +} + +func resultReceipt(operation, expectedTransactionID, expectedDesiredStateID string, result repositorytransaction.Result, nonClaims []string) (Receipt, int, error) { + state, exitCode := receiptOutcome(operation, result) + receipt, err := newReceipt(operation, state, result.FailureClass, expectedTransactionID, expectedDesiredStateID, &result, nonClaims) + if err != nil { + return Receipt{}, 1, err + } + return receipt, exitCode, nil +} + +func transactionHasChanges(plan repositorytransaction.Plan) bool { + for _, operation := range plan.Operations { + if operation.Action != repositorytransaction.ActionUnchanged { + return true + } + } + return false +} + +func blockedReceipt(operation, expectedTransactionID, expectedDesiredStateID, failureClass string, nonClaims []string) (Receipt, int, error) { + receipt, err := newReceipt(operation, ReceiptStateBlocked, failureClass, expectedTransactionID, expectedDesiredStateID, nil, nonClaims) + return receipt, 1, err +} + +func pendingReceipt(operation, expectedTransactionID, expectedDesiredStateID string, cause error, nonClaims []string) (Receipt, int, error) { + transactionID, _ := repositorytransaction.RecoveryTransactionID(cause) + result := repositorytransaction.Result{ + FailureClass: "pending_transaction_state", + State: repositorytransaction.StateRecoveryRequired, + TransactionID: transactionID, + } + return resultReceipt(operation, expectedTransactionID, expectedDesiredStateID, result, nonClaims) +} + +func receiptOutcome(operation string, result repositorytransaction.Result) (string, int) { + if operation == OperationApply && (result.State == repositorytransaction.StateApplied || result.State == repositorytransaction.StateAlreadySatisfied) { + return ReceiptStatePassed, 0 + } + if operation == OperationRecover && (result.State == repositorytransaction.StateApplied || result.State == repositorytransaction.StateRolledBack) { + return ReceiptStatePassed, 0 + } + switch result.State { + case repositorytransaction.StateCleanupRequired: + return ReceiptStateCleanupRequired, 1 + case repositorytransaction.StateDurabilityUnknown: + return ReceiptStateDurabilityUnknown, 1 + case repositorytransaction.StateRecoveryRequired: + return ReceiptStateRecoveryRequired, 1 + default: + return ReceiptStateFailed, 1 + } +} + +func childArtifacts(request Request) ([]artifact, error) { + items := make([]artifact, 0, len(request.Sources)+2) + for _, source := range request.Sources { + item, err := encodeArtifact(ArtifactRequirementSource, source.SourceID, source.RequirementsPath, requirementsourceadmission.SourceValue(source)) + if err != nil { + return nil, err + } + items = append(items, item) + } + binding, err := encodeArtifact(ArtifactRequirementBinding, request.Binding.BindingID, request.BindingPath, requirementbinding.InputValue(request.Binding)) + if err != nil { + return nil, err + } + items = append(items, binding) + inventory, err := encodeArtifact(ArtifactTestInventory, request.Inventory.InventoryID, request.InventoryPath, testevidenceinventory.InventoryValue(request.Inventory)) + if err != nil { + return nil, err + } + items = append(items, inventory) + return items, nil +} + +func encodeArtifact(kind, id, path string, value map[string]any) (artifact, error) { + content, err := stablejson.Marshal(value) + if err != nil { + return artifact{}, fmt.Errorf("encode adoption materialization artifact") + } + if len(content) > repositorytransaction.MaximumFileBytes { + return artifact{}, fmt.Errorf("adoption materialization artifact exceeds its file byte limit") + } + return artifact{Content: content, ID: id, Kind: kind, Path: path}, nil +} + +func validateExistingArtifacts(transaction repositorytransaction.Plan, artifacts []artifact, projectID string) error { + byPath := map[string]artifact{} + for _, item := range artifacts { + byPath[item.Path] = item + } + for index, operation := range transaction.Operations { + if !operation.Before.Exists { + continue + } + content, ok := transaction.BeforeContent(index) + if !ok { + return fmt.Errorf("adoption materialization existing artifact bytes are unavailable") + } + item, ok := byPath[operation.Path] + if !ok { + return fmt.Errorf("adoption materialization transaction contains an undeclared artifact") + } + if err := admitExistingArtifact(content, item, projectID); err != nil { + return err + } + } + return nil +} + +func admitExistingArtifact(content []byte, desired artifact, projectID string) error { + raw, err := admission.DecodeJSON(bytes.NewReader(content), repositorytransaction.MaximumFileBytes) + if err != nil { + return fmt.Errorf("adoption materialization refuses to replace an unknown existing artifact") + } + switch desired.Kind { + case ArtifactRequirementSource: + result, err := requirementsourceadmission.Evaluate(raw) + if err != nil || result.ExitCode != 0 || result.Source.SourceID != desired.ID { + return fmt.Errorf("adoption materialization existing requirement source has incompatible ownership") + } + case ArtifactRequirementBinding: + result, err := requirementbinding.Build(raw) + if err != nil || result.Record.State != "passed" || result.Input.BindingID != desired.ID { + return fmt.Errorf("adoption materialization existing requirement binding has incompatible ownership") + } + case ArtifactTestInventory: + result, err := testevidenceinventory.EvaluateDirect(raw) + if err != nil || result.ExitCode != 0 || result.Inventory.InventoryID != desired.ID { + return fmt.Errorf("adoption materialization existing test inventory has incompatible ownership") + } + case ArtifactProjectManifest: + manifest, err := AdmitManifest(raw) + if err != nil || manifest.ProjectID != projectID { + return fmt.Errorf("adoption materialization existing project manifest has incompatible ownership") + } + default: + return fmt.Errorf("adoption materialization artifact kind is unsupported") + } + return nil +} diff --git a/internal/command/adoptionmaterialization/closure.go b/internal/command/adoptionmaterialization/closure.go new file mode 100644 index 0000000..edebe5b --- /dev/null +++ b/internal/command/adoptionmaterialization/closure.go @@ -0,0 +1,137 @@ +package adoptionmaterialization + +import ( + "fmt" + "slices" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" +) + +func validateClosure(request Request) error { + pathUses := []pathUse{ + {Path: ProjectManifestPath, Role: roleManifestTarget, Target: true}, + {Path: request.BindingPath, Role: roleBindingTarget, Target: true}, + {Path: request.InventoryPath, Role: roleInventoryTarget, Target: true}, + } + sourceIDs := map[string]struct{}{} + requirements := map[string]requirementsourceadmission.Requirement{} + requirementPaths := map[string]string{} + for _, source := range request.Sources { + if _, exists := sourceIDs[source.SourceID]; exists { + return fmt.Errorf("adoption materialization requirement sourceIds must be unique") + } + sourceIDs[source.SourceID] = struct{}{} + pathUses = append(pathUses, + pathUse{Path: source.RequirementsPath, Role: roleRequirementSource, Target: true}, + pathUse{Path: source.OverviewPath, Role: roleOverviewReference}, + ) + for _, requirement := range source.Requirements { + if _, exists := requirements[requirement.RequirementID]; exists { + return fmt.Errorf("adoption materialization requirementIds must be unique across sources") + } + for _, proofRef := range requirement.ProofBindingRefs { + if proofRef != request.BindingPath { + return fmt.Errorf("adoption materialization requirement proofBindingRefs must resolve to the materialized binding path") + } + } + requirements[requirement.RequirementID] = requirement + requirementPaths[requirement.RequirementID] = source.RequirementsPath + } + } + if len(request.Binding.Requirements) != len(requirements) { + return fmt.Errorf("adoption materialization binding requirement set must equal the source requirement set") + } + for _, bindingRequirement := range request.Binding.Requirements { + sourceRequirement, ok := requirements[bindingRequirement.RequirementID] + if !ok || !sameRequirementProjection(sourceRequirement, bindingRequirement, requirementPaths[bindingRequirement.RequirementID]) { + return fmt.Errorf("adoption materialization binding requirement projection does not match its source owner") + } + pathUses = append(pathUses, pathUse{Path: bindingRequirement.SpecPath, Role: roleRequirementSpecRef}) + } + for _, binding := range request.Binding.Bindings { + pathUses = append(pathUses, pathUse{Path: binding.WitnessPath, Role: roleWitnessSourceReference}) + } + for _, entry := range request.Inventory.Entries { + pathUses = append(pathUses, pathUse{Path: entry.SourcePath, Role: roleTestSourceReference}) + } + if err := validatePathRoles(pathUses); err != nil { + return err + } + return validateInventoryReferences(request, requirements) +} + +func sameRequirementProjection(source requirementsourceadmission.Requirement, binding requirementbinding.Requirement, specPath string) bool { + return source.RequirementID == binding.RequirementID && + source.OwnerID == binding.OwnerID && + source.ClaimLevel == binding.ClaimLevel && + specPath == binding.SpecPath && + slices.Equal(source.NonClaims, binding.NonClaims) +} + +func validateInventoryReferences(request Request, requirements map[string]requirementsourceadmission.Requirement) error { + for _, entry := range request.Inventory.Entries { + requirementRefs := stringSet(entry.RequirementRefs) + witnessRefs := stringSet(entry.WitnessRefs) + commandRefs := stringSet(entry.CommandRefs) + for _, requirementID := range entry.RequirementRefs { + if _, ok := requirements[requirementID]; !ok { + return fmt.Errorf("adoption materialization test inventory references an unknown requirement") + } + if !hasBindingRoute(request.Binding.Bindings, stringSet([]string{requirementID}), witnessRefs, commandRefs, entry.SourcePath) { + return fmt.Errorf("adoption materialization test inventory requirement reference is not connected to its witness route") + } + } + for _, witnessID := range entry.WitnessRefs { + if !hasBindingRoute(request.Binding.Bindings, requirementRefs, stringSet([]string{witnessID}), commandRefs, entry.SourcePath) { + return fmt.Errorf("adoption materialization test inventory witness reference is not connected to its requirement route") + } + } + for _, commandID := range entry.CommandRefs { + if !hasBindingRoute(request.Binding.Bindings, requirementRefs, witnessRefs, stringSet([]string{commandID}), entry.SourcePath) { + return fmt.Errorf("adoption materialization test inventory command reference is not connected to its requirement route") + } + } + } + return nil +} + +func stringSet(values []string) map[string]struct{} { + result := make(map[string]struct{}, len(values)) + for _, value := range values { + result[value] = struct{}{} + } + return result +} + +func hasBindingRoute(bindings []requirementbinding.Binding, requirementRefs, witnessRefs, commandRefs map[string]struct{}, sourcePath string) bool { + for _, binding := range bindings { + if len(requirementRefs) > 0 { + if _, ok := requirementRefs[binding.RequirementID]; !ok { + continue + } + } + if len(witnessRefs) > 0 { + if _, ok := witnessRefs[binding.WitnessID]; !ok { + continue + } + } + if len(commandRefs) > 0 && !bindingContainsCommand(binding, commandRefs) { + continue + } + if len(witnessRefs) > 0 && sourcePath != "" && binding.WitnessPath != sourcePath { + continue + } + return true + } + return false +} + +func bindingContainsCommand(binding requirementbinding.Binding, commandRefs map[string]struct{}) bool { + for _, commandID := range binding.CommandIDs { + if _, ok := commandRefs[commandID]; ok { + return true + } + } + return false +} diff --git a/internal/command/adoptionmaterialization/closure_test.go b/internal/command/adoptionmaterialization/closure_test.go new file mode 100644 index 0000000..c4724c4 --- /dev/null +++ b/internal/command/adoptionmaterialization/closure_test.go @@ -0,0 +1,135 @@ +package adoptionmaterialization + +import ( + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +func TestPathRoleLedgerRejectsWriteReferenceCollisions(t *testing.T) { + tests := []struct { + name string + uses []pathUse + }{ + { + name: "overview and binding target", + uses: []pathUse{ + {Path: "docs/specs/core/overview.md", Role: roleOverviewReference}, + {Path: "docs/specs/core/overview.md", Role: roleBindingTarget, Target: true}, + }, + }, + { + name: "portable target alias", + uses: []pathUse{ + {Path: "proofkit/Inventory.json", Role: roleInventoryTarget, Target: true}, + {Path: "proofkit/inventory.json", Role: roleBindingTarget, Target: true}, + }, + }, + { + name: "reserved namespace reference", + uses: []pathUse{{Path: ".agentic-proofkit/witness.go", Role: roleWitnessSourceReference}}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := validatePathRoles(test.uses); err == nil { + t.Fatal("validatePathRoles() admitted an incompatible path-role relation") + } + }) + } + if err := validatePathRoles([]pathUse{ + {Path: "docs/specs/core/requirements.v1.json", Role: roleRequirementSource, Target: true}, + {Path: "docs/specs/core/requirements.v1.json", Role: roleRequirementSpecRef}, + {Path: "internal/core/core_test.go", Role: roleWitnessSourceReference}, + {Path: "internal/core/core_test.go", Role: roleTestSourceReference}, + }); err != nil { + t.Fatalf("validatePathRoles() rejected compatible owner paths: %v", err) + } +} + +func TestInventoryReferencesMustResolveThroughBindingEdges(t *testing.T) { + bindings := []requirementbinding.Binding{ + {RequirementID: "REQ-A", WitnessID: "witness.a", WitnessPath: "a_test.go", CommandIDs: []string{"command.a"}}, + {RequirementID: "REQ-B", WitnessID: "witness.b", WitnessPath: "b_test.go", CommandIDs: []string{"command.b"}}, + } + requirements := map[string]requirementsourceadmission.Requirement{ + "REQ-A": {RequirementID: "REQ-A"}, + "REQ-B": {RequirementID: "REQ-B"}, + } + request := Request{ + Binding: requirementbinding.Input{Bindings: bindings}, + Inventory: testevidenceinventory.Inventory{Entries: []testevidenceinventory.Entry{{ + RequirementRefs: []string{"REQ-A"}, + WitnessRefs: []string{"witness.b"}, + CommandRefs: []string{"command.b"}, + SourcePath: "b_test.go", + }}}, + } + if err := validateInventoryReferences(request, requirements); err == nil { + t.Fatal("validateInventoryReferences() admitted globally known but disconnected references") + } + request.Inventory.Entries[0] = testevidenceinventory.Entry{ + RequirementRefs: []string{"REQ-A", "REQ-B"}, + WitnessRefs: []string{"witness.a", "witness.b"}, + CommandRefs: []string{"command.a", "command.b"}, + SourcePath: "a_test.go", + } + if err := validateInventoryReferences(request, requirements); err == nil { + t.Fatal("validateInventoryReferences() admitted witnesses from incompatible source paths") + } + request.Binding.Bindings[0].WitnessPath = "shared_test.go" + request.Binding.Bindings[1].WitnessPath = "shared_test.go" + request.Inventory.Entries[0].SourcePath = "shared_test.go" + if err := validateInventoryReferences(request, requirements); err != nil { + t.Fatalf("validateInventoryReferences() rejected a connected shared witness source: %v", err) + } +} + +func TestManifestAdmissionEqualsProducerImage(t *testing.T) { + tests := []struct { + name string + routes []Route + }{ + { + name: "missing inventory", + routes: []Route{ + {ArtifactID: "source.a", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/a/requirements.v1.json"}, + {ArtifactID: "source.b", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/b/requirements.v1.json"}, + {ArtifactID: "binding", ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, + }, + }, + { + name: "duplicate artifact identity", + routes: []Route{ + {ArtifactID: "duplicate", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/a/requirements.v1.json"}, + {ArtifactID: "duplicate", ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, + {ArtifactID: "inventory", ArtifactKind: ArtifactTestInventory, Path: "proofkit/inventory.json"}, + }, + }, + { + name: "requirement source outside producer route language", + routes: []Route{ + {ArtifactID: "source", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/arbitrary.json"}, + {ArtifactID: "binding", ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, + {ArtifactID: "inventory", ArtifactKind: ArtifactTestInventory, Path: "proofkit/inventory.json"}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manifest := Manifest{ + MaterializationRequestID: "request", + ProjectID: "project", + Routes: test.routes, + SourcePlanID: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + } + manifest.ManifestID, _ = digest.StableJSONSHA256Ref(manifest.identityValue()) + if _, err := AdmitManifest(manifest.JSONValue()); err == nil { + t.Fatal("AdmitManifest() admitted a record outside the producer image") + } + }) + } +} diff --git a/internal/command/adoptionmaterialization/manifest.go b/internal/command/adoptionmaterialization/manifest.go new file mode 100644 index 0000000..797826c --- /dev/null +++ b/internal/command/adoptionmaterialization/manifest.go @@ -0,0 +1,222 @@ +package adoptionmaterialization + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const ManifestKind = "proofkit.project-routing-manifest" + +var manifestNonClaims = []string{ + "Project routing manifests do not duplicate child semantics or prove child admission, freshness, execution, merge, release, rollout, or production readiness.", +} + +type Route struct { + ArtifactID string + ArtifactKind string + Path string +} + +type Manifest struct { + ManifestID string + MaterializationRequestID string + ProjectID string + Routes []Route + SourcePlanID string +} + +func buildManifest(request Request, childArtifacts []artifact) (Manifest, error) { + routes := make([]Route, 0, len(childArtifacts)) + for _, child := range childArtifacts { + routes = append(routes, Route{ArtifactID: child.ID, ArtifactKind: child.Kind, Path: child.Path}) + } + sort.Slice(routes, func(left, right int) bool { return routes[left].Path < routes[right].Path }) + manifest := Manifest{ + MaterializationRequestID: request.RequestID, + ProjectID: request.ProjectID, + Routes: routes, + SourcePlanID: request.SourcePlanID, + } + id, err := digest.StableJSONSHA256Ref(manifest.identityValue()) + if err != nil { + return Manifest{}, fmt.Errorf("derive project routing manifest identity") + } + manifest.ManifestID = id + admitted, err := AdmitManifest(manifest.JSONValue()) + if err != nil { + return Manifest{}, fmt.Errorf("admit generated project routing manifest: %w", err) + } + return admitted, nil +} + +func (manifest Manifest) JSONValue() map[string]any { + value := manifest.identityValue() + value["manifestId"] = manifest.ManifestID + return value +} + +func (manifest Manifest) identityValue() map[string]any { + routes := make([]any, 0, len(manifest.Routes)) + for _, route := range manifest.Routes { + routes = append(routes, map[string]any{ + "artifactId": route.ArtifactID, + "artifactKind": route.ArtifactKind, + "path": route.Path, + }) + } + return map[string]any{ + "authority": "routing_only", + "manifestKind": ManifestKind, + "materializationRequestId": manifest.MaterializationRequestID, + "nonClaims": admit.StringSliceToAny(manifestNonClaims), + "projectId": manifest.ProjectID, + "routes": routes, + "schemaVersion": json.Number("1"), + "sourcePlanId": manifest.SourcePlanID, + } +} + +func AdmitManifest(raw any) (Manifest, error) { + record, ok := raw.(map[string]any) + if !ok { + return Manifest{}, fmt.Errorf("project routing manifest must be an object") + } + if err := admit.KnownKeys(record, []string{"authority", "manifestId", "manifestKind", "materializationRequestId", "nonClaims", "projectId", "routes", "schemaVersion", "sourcePlanId"}, "project routing manifest"); err != nil { + return Manifest{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["manifestKind"] != ManifestKind || record["authority"] != "routing_only" { + return Manifest{}, fmt.Errorf("project routing manifest identity is invalid") + } + manifestID, err := admit.SHA256Ref(record["manifestId"], "project routing manifest manifestId") + if err != nil { + return Manifest{}, err + } + requestID, err := admit.RuleID(record["materializationRequestId"], "project routing manifest materializationRequestId") + if err != nil { + return Manifest{}, err + } + projectID, err := admit.RuleID(record["projectId"], "project routing manifest projectId") + if err != nil { + return Manifest{}, err + } + sourcePlanID, err := admit.SHA256Ref(record["sourcePlanId"], "project routing manifest sourcePlanId") + if err != nil { + return Manifest{}, err + } + routes, err := admitRoutes(record["routes"]) + if err != nil { + return Manifest{}, err + } + nonClaims, err := admit.PreserveSortedTextArray(record["nonClaims"], "project routing manifest nonClaims", false) + if err != nil || !equalStrings(nonClaims, manifestNonClaims) { + return Manifest{}, fmt.Errorf("project routing manifest nonClaims are invalid") + } + manifest := Manifest{ManifestID: manifestID, MaterializationRequestID: requestID, ProjectID: projectID, Routes: routes, SourcePlanID: sourcePlanID} + wantID, err := digest.StableJSONSHA256Ref(manifest.identityValue()) + if err != nil || wantID != manifestID { + return Manifest{}, fmt.Errorf("project routing manifest identity does not match its content") + } + actual, err := stablejson.Marshal(record) + if err != nil { + return Manifest{}, fmt.Errorf("encode project routing manifest") + } + expected, err := stablejson.Marshal(manifest.JSONValue()) + if err != nil || !bytes.Equal(actual, expected) { + return Manifest{}, fmt.Errorf("project routing manifest is not canonical") + } + return manifest, nil +} + +func admitRoutes(raw any) ([]Route, error) { + values, ok := raw.([]any) + if !ok || len(values) < 3 || len(values) > repositoryRouteLimit() { + return nil, fmt.Errorf("project routing manifest route count is invalid") + } + routes := make([]Route, 0, len(values)) + artifactIDs := map[string]struct{}{} + kindCounts := map[string]int{} + pathUses := []pathUse{{Path: ProjectManifestPath, Role: roleManifestTarget, Target: true}} + previous := "" + for _, value := range values { + record, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("project routing manifest route must be an object") + } + if err := admit.KnownKeys(record, []string{"artifactId", "artifactKind", "path"}, "project routing manifest route"); err != nil { + return nil, err + } + artifactID, err := admit.RuleID(record["artifactId"], "project routing manifest artifactId") + if err != nil { + return nil, err + } + kind, err := admit.Enum(record["artifactKind"], artifactKindSet, "project routing manifest artifactKind") + if err != nil { + return nil, err + } + pathText, err := admit.NonEmptyText(record["path"], "project routing manifest path") + if err != nil { + return nil, err + } + targetPath, err := admit.SafeRepoRelativePath(pathText, "project routing manifest path") + if err != nil { + return nil, err + } + if previous != "" && previous >= targetPath { + return nil, fmt.Errorf("project routing manifest routes must be sorted and path-unique") + } + if _, duplicate := artifactIDs[artifactID]; duplicate { + return nil, fmt.Errorf("project routing manifest artifactIds must be unique") + } + artifactIDs[artifactID] = struct{}{} + kindCounts[kind]++ + if kind == ArtifactRequirementSource && !strings.HasSuffix(targetPath, "/requirements.v1.json") { + return nil, fmt.Errorf("project routing manifest requirement-source path is outside the producer language") + } + role := roleRequirementSource + switch kind { + case ArtifactRequirementBinding: + role = roleBindingTarget + case ArtifactTestInventory: + role = roleInventoryTarget + } + pathUses = append(pathUses, pathUse{Path: targetPath, Role: role, Target: true}) + previous = targetPath + routes = append(routes, Route{ArtifactID: artifactID, ArtifactKind: kind, Path: targetPath}) + } + if kindCounts[ArtifactRequirementSource] < 1 || kindCounts[ArtifactRequirementBinding] != 1 || kindCounts[ArtifactTestInventory] != 1 { + return nil, fmt.Errorf("project routing manifest route kinds do not match the producer contract") + } + if err := validatePathRoles(pathUses); err != nil { + return nil, err + } + return routes, nil +} + +var artifactKindSet = map[string]struct{}{ + ArtifactRequirementSource: {}, + ArtifactRequirementBinding: {}, + ArtifactTestInventory: {}, +} + +func repositoryRouteLimit() int { + return MaximumRequirementSources + 2 +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/internal/command/adoptionmaterialization/model.go b/internal/command/adoptionmaterialization/model.go new file mode 100644 index 0000000..f4f3142 --- /dev/null +++ b/internal/command/adoptionmaterialization/model.go @@ -0,0 +1,186 @@ +// Package adoptionmaterialization owns the explicit promotion of admitted +// adoption records into a repository-bound, recoverable write transaction. +package adoptionmaterialization + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionplan" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +const ( + SchemaVersion = 1 + RequestKind = "proofkit.adoption-materialization-request" + PlanKind = "proofkit.adoption-materialization-plan" + ReceiptKind = "proofkit.adoption-materialization-receipt" + + ProjectManifestPath = "proofkit/project.v1.json" + + MaximumRequirementSources = repositorytransaction.MaximumOperations - 3 + MaximumOutputBytes = 256 << 10 + MaximumTextBytes = 16 << 10 + MaximumTextLines = 96 +) + +const ( + ArtifactRequirementSource = "requirement_source" + ArtifactRequirementBinding = "requirement_proof_binding" + ArtifactTestInventory = "test_evidence_inventory" + ArtifactProjectManifest = "project_routing_manifest" +) + +const ( + OperationPlan = "plan" + OperationApply = "apply" + OperationRecover = "recover" +) + +const ( + ReceiptStateBlocked = "blocked" + ReceiptStateCleanupRequired = "cleanup_required" + ReceiptStateDurabilityUnknown = "durability_unknown" + ReceiptStateFailed = "failed" + ReceiptStatePassed = "passed" + ReceiptStateRecoveryRequired = "recovery_required" +) + +var boundaryNonClaims = []string{ + "Adoption materialization does not authenticate caller declarations or approve requirement meaning, proof adequacy, merge, release, rollout, or production readiness.", + "Adoption materialization provides recoverable ordered-prefix writes for cooperative writers, not simultaneous multi-file visibility or power-loss durability.", + "The project routing manifest names canonical records but does not replace their semantic owners or prove their continuing validity.", +} + +type Request struct { + Binding requirementbinding.Input + BindingPath string + Inventory testevidenceinventory.Inventory + InventoryPath string + NonClaims []string + ProjectID string + RequestID string + SourceIntent string + SourcePlanID string + Sources []requirementsourceadmission.Source +} + +type artifact struct { + Content []byte + ID string + Kind string + Path string +} + +type Plan struct { + Manifest Manifest + NonClaims []string + ProjectID string + RequestID string + SourceIntent string + SourcePlanID string + Transaction repositorytransaction.Plan +} + +type Receipt struct { + ExpectedDesiredStateID string + ExpectedTransactionID string + FailureClass string + NonClaims []string + Operation string + ReceiptID string + State string + TransactionResult *repositorytransaction.Result +} + +type Materialization struct { + Artifacts []artifact + Plan Plan + Transaction repositorytransaction.Plan +} + +func (plan Plan) JSONValue() map[string]any { + return map[string]any{ + "manifest": plan.Manifest.JSONValue(), + "nonClaims": admit.StringSliceToAny(plan.NonClaims), + "planKind": PlanKind, + "projectId": plan.ProjectID, + "requestId": plan.RequestID, + "schemaVersion": json.Number("1"), + "sourceIntent": plan.SourceIntent, + "sourcePlanId": plan.SourcePlanID, + "state": "ready", + "transaction": plan.Transaction.JSONValue(), + } +} + +func (receipt Receipt) JSONValue() map[string]any { + value := receipt.identityValue() + value["receiptId"] = receipt.ReceiptID + return value +} + +func (receipt Receipt) identityValue() map[string]any { + var transactionResult any + if receipt.TransactionResult != nil { + transactionResult = receipt.TransactionResult.JSONValue() + } + return map[string]any{ + "expectedDesiredStateId": nullableText(receipt.ExpectedDesiredStateID), + "expectedTransactionId": nullableText(receipt.ExpectedTransactionID), + "failureClass": nullableText(receipt.FailureClass), + "nonClaims": admit.StringSliceToAny(receipt.NonClaims), + "operation": receipt.Operation, + "receiptKind": ReceiptKind, + "schemaVersion": json.Number("1"), + "state": receipt.State, + "transactionResult": transactionResult, + } +} + +func newReceipt(operation, state, failureClass, expectedTransactionID, expectedDesiredStateID string, result *repositorytransaction.Result, nonClaims []string) (Receipt, error) { + receipt := Receipt{ + ExpectedDesiredStateID: expectedDesiredStateID, + ExpectedTransactionID: expectedTransactionID, + FailureClass: failureClass, + NonClaims: mergedNonClaims(nonClaims), + Operation: operation, + State: state, + TransactionResult: result, + } + id, err := digest.StableJSONSHA256Ref(receipt.identityValue()) + if err != nil { + return Receipt{}, fmt.Errorf("derive adoption materialization receipt identity") + } + receipt.ReceiptID = id + return receipt, nil +} + +func mergedNonClaims(caller []string) []string { + values := append(append([]string{}, boundaryNonClaims...), caller...) + sort.Strings(values) + out := values[:0] + for _, value := range values { + if len(out) == 0 || out[len(out)-1] != value { + out = append(out, value) + } + } + return out +} + +func sourcePlanCoordinates(plan adoptionplan.Plan) (string, string) { + return plan.PlanID, plan.Intent +} + +func nullableText(value string) any { + if value == "" { + return nil + } + return value +} diff --git a/internal/command/adoptionmaterialization/path_roles.go b/internal/command/adoptionmaterialization/path_roles.go new file mode 100644 index 0000000..0b2846f --- /dev/null +++ b/internal/command/adoptionmaterialization/path_roles.go @@ -0,0 +1,68 @@ +package adoptionmaterialization + +import ( + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/pathidentity" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +type pathRole string + +const ( + roleBindingTarget pathRole = "binding_target" + roleInventoryTarget pathRole = "inventory_target" + roleManifestTarget pathRole = "manifest_target" + roleOverviewReference pathRole = "overview_reference" + roleRequirementSource pathRole = "requirement_source_target" + roleRequirementSpecRef pathRole = "requirement_spec_reference" + roleTestSourceReference pathRole = "test_source_reference" + roleWitnessSourceReference pathRole = "witness_source_reference" +) + +type pathUse struct { + Path string + Role pathRole + Target bool +} + +func validatePathRoles(uses []pathUse) error { + for index, use := range uses { + if _, err := pathidentity.Key(use.Path); err != nil { + return fmt.Errorf("adoption materialization %s path identity is invalid", use.Role) + } + overlapsControl, err := pathidentity.Overlaps(use.Path, repositorytransaction.ControlRoot) + if err != nil || overlapsControl { + return fmt.Errorf("adoption materialization %s path overlaps the transaction control namespace", use.Role) + } + for prior := 0; prior < index; prior++ { + overlaps, err := pathidentity.Overlaps(use.Path, uses[prior].Path) + if err != nil { + return fmt.Errorf("adoption materialization path identity is invalid") + } + if overlaps && !compatiblePathUses(use, uses[prior]) { + return fmt.Errorf("adoption materialization path roles conflict: %s and %s", uses[prior].Role, use.Role) + } + } + } + return nil +} + +func compatiblePathUses(left, right pathUse) bool { + if !left.Target && !right.Target { + return true + } + if left.Target && right.Target { + return false + } + target, reference := left, right + if !target.Target { + target, reference = right, left + } + if target.Role != roleRequirementSource || reference.Role != roleRequirementSpecRef { + return false + } + leftKey, leftErr := pathidentity.Key(target.Path) + rightKey, rightErr := pathidentity.Key(reference.Path) + return leftErr == nil && rightErr == nil && leftKey == rightKey +} diff --git a/internal/command/adoptionmaterialization/text.go b/internal/command/adoptionmaterialization/text.go new file mode 100644 index 0000000..9c27ecc --- /dev/null +++ b/internal/command/adoptionmaterialization/text.go @@ -0,0 +1,58 @@ +package adoptionmaterialization + +import ( + "fmt" + "strings" +) + +func RenderPlanText(plan Plan) (string, error) { + lines := []string{ + "Adoption materialization plan", + "State: ready", + "Project: " + plan.ProjectID, + "Transaction: " + plan.Transaction.TransactionID, + fmt.Sprintf("Operations: %d", len(plan.Transaction.Operations)), + } + for _, operation := range plan.Transaction.Operations { + lines = append(lines, fmt.Sprintf("- %s %s", operation.Action, operation.Path)) + } + return boundedText(lines) +} + +func RenderReceiptText(receipt Receipt) (string, error) { + lines := []string{ + "Adoption materialization receipt", + "Operation: " + receipt.Operation, + "State: " + receipt.State, + } + if receipt.ExpectedTransactionID != "" { + lines = append(lines, "Expected transaction: "+receipt.ExpectedTransactionID) + } + if receipt.ExpectedDesiredStateID != "" { + lines = append(lines, "Expected desired state: "+receipt.ExpectedDesiredStateID) + } + if receipt.FailureClass != "" { + lines = append(lines, "Failure: "+receipt.FailureClass) + } + if receipt.TransactionResult != nil { + lines = append(lines, "Transaction state: "+receipt.TransactionResult.State) + if receipt.TransactionResult.AppliedCountKnown { + lines = append(lines, fmt.Sprintf("Applied: %d", receipt.TransactionResult.AppliedCount)) + } + if receipt.TransactionResult.RecoveredBy != "" { + lines = append(lines, "Recovery: "+receipt.TransactionResult.RecoveredBy) + } + } + return boundedText(lines) +} + +func boundedText(lines []string) (string, error) { + if len(lines) > MaximumTextLines { + return "", fmt.Errorf("adoption materialization text exceeds its line limit") + } + text := strings.Join(lines, "\n") + "\n" + if len(text) > MaximumTextBytes { + return "", fmt.Errorf("adoption materialization text exceeds its byte limit") + } + return text, nil +} diff --git a/internal/command/requirementcoverageinput/requirementcoverageinput.go b/internal/command/requirementcoverageinput/requirementcoverageinput.go index 3519a6d..87e231c 100644 --- a/internal/command/requirementcoverageinput/requirementcoverageinput.go +++ b/internal/command/requirementcoverageinput/requirementcoverageinput.go @@ -3,6 +3,7 @@ package requirementcoverageinput import ( "crypto/sha256" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -156,16 +157,16 @@ func admitProofAndInventory(record map[string]any) (any, any, testevidenceinvent if proofResult.Record.State != "passed" { return nil, nil, testevidenceinventory.NormalizedProjection{}, fmt.Errorf("requirement coverage input compose requires passed requirement proof binding admission") } - inventoryResult, err := testevidenceinventory.Evaluate(directRaw) + inventoryResult, err := testevidenceinventory.EvaluateDirect(directRaw) if err != nil { + if errors.Is(err, testevidenceinventory.ErrDirectAuthorityRequired) { + return nil, nil, testevidenceinventory.NormalizedProjection{}, fmt.Errorf("requirement coverage input compose direct mode requires caller_owned_inventory; use normalizedTestEvidenceInventory for source-set inventory") + } return nil, nil, testevidenceinventory.NormalizedProjection{}, err } if inventoryResult.ExitCode != 0 { return nil, nil, testevidenceinventory.NormalizedProjection{}, fmt.Errorf("requirement coverage input compose requires passed test evidence inventory admission") } - if inventoryResult.Inventory.Authority != "caller_owned_inventory" { - return nil, nil, testevidenceinventory.NormalizedProjection{}, fmt.Errorf("requirement coverage input compose direct mode requires caller_owned_inventory; use normalizedTestEvidenceInventory for source-set inventory") - } return requirementbinding.InputValue(proofResult.Input), nil, testevidenceinventory.NormalizedProjection{Inventory: testevidenceinventory.InventoryValue(inventoryResult.Inventory), Result: inventoryResult}, nil } diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index c3aa457..cee607d 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "e0c7484b588a119947ee6f2568ecc44fdb5e93c3a6fa1d61e677bde45df682fe" +const presetContractSourceSHA256 = "6703b4a1a4ca3499477ffb6f694a230138db40810f86e38f1950d3d4088f02b0" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/command/testevidenceinventory/testevidenceinventory.go b/internal/command/testevidenceinventory/testevidenceinventory.go index 10ec9db..9a7ae8e 100644 --- a/internal/command/testevidenceinventory/testevidenceinventory.go +++ b/internal/command/testevidenceinventory/testevidenceinventory.go @@ -2,6 +2,7 @@ package testevidenceinventory import ( "encoding/json" + "errors" "fmt" "sort" "strings" @@ -16,6 +17,8 @@ const directAuthority = "caller_owned_inventory" const sourceSetAuthority = "caller_owned_inventory_source_set" const wrappedInventorySchema = "proofkit.requirement-test-inventory.v1" +var ErrDirectAuthorityRequired = errors.New("test evidence inventory must use direct caller-owned authority") + const ( EvidenceClassDeclaredContractAdmissionRoute = "declared_contract_admission_route" EvidenceClassDeclaredPropertyOrFuzzRoute = "declared_property_or_fuzz_route" @@ -216,7 +219,6 @@ func InventoryValue(inventory Inventory) map[string]any { } return record } - func Evaluate(raw any) (Result, error) { inventory, err := admitInventory(raw) if err != nil { @@ -266,6 +268,20 @@ func Evaluate(raw any) (Result, error) { return Result{ExitCode: exitCode, Failures: failures, Inventory: inventory, Report: record, Warnings: warnings}, nil } +// EvaluateDirect admits and classifies an inventory whose evidence records are +// supplied directly by the caller. Source-set inventories must first cross +// their owning normalization boundary. +func EvaluateDirect(raw any) (Result, error) { + result, err := Evaluate(raw) + if err != nil { + return Result{}, err + } + if result.Inventory.Authority != directAuthority { + return Result{}, ErrDirectAuthorityRequired + } + return result, nil +} + func normalizedInventoryValue(inventory Inventory) map[string]any { return map[string]any{ "schemaVersion": json.Number("1"), @@ -437,6 +453,7 @@ func admitDirectInventory(record map[string]any, context string) (Inventory, err if err != nil { return Inventory{}, err } + nonClaims = withoutOwnerDefaultNonClaims(nonClaims) ownerID, err := optionalRuleID(record["ownerId"], context+" ownerId") if err != nil { return Inventory{}, err @@ -448,6 +465,20 @@ func admitDirectInventory(record map[string]any, context string) (Inventory, err return Inventory{Authority: authority, Entries: entries, InventoryID: inventoryID, NonClaims: nonClaims, OwnerID: ownerID, SourceID: sourceID}, nil } +func withoutOwnerDefaultNonClaims(values []string) []string { + defaults := make(map[string]struct{}, len(defaultNonClaims)) + for _, value := range defaultNonClaims { + defaults[value] = struct{}{} + } + result := make([]string, 0, len(values)) + for _, value := range values { + if _, ownerDefault := defaults[value]; !ownerDefault { + result = append(result, value) + } + } + return result +} + func entries(raw any) ([]Entry, error) { values, ok := raw.([]any) if !ok { diff --git a/internal/command/testevidenceinventory/testevidenceinventory_test.go b/internal/command/testevidenceinventory/testevidenceinventory_test.go index 577b845..8cc7969 100644 --- a/internal/command/testevidenceinventory/testevidenceinventory_test.go +++ b/internal/command/testevidenceinventory/testevidenceinventory_test.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "reflect" "strings" "testing" @@ -34,6 +35,47 @@ func TestBuildAdmitsDeclaredSemanticFalsifierRouteInventory(t *testing.T) { } } +func TestEvaluateDirectOwnsDirectAuthorityBoundary(t *testing.T) { + result, err := EvaluateDirect(validInventory(t)) + if err != nil { + t.Fatalf("EvaluateDirect() error = %v", err) + } + if result.ExitCode != 0 || result.Inventory.Authority != directAuthority { + t.Fatalf("EvaluateDirect() result = %#v", result) + } + if _, err := EvaluateDirect(validSourceSetInventory(t)); err == nil || !strings.Contains(err.Error(), "direct caller-owned authority") { + t.Fatalf("EvaluateDirect(source set) error = %v", err) + } +} + +func TestDirectInventoryProjectionPreservesAdmittedMeaning(t *testing.T) { + input := validInventory(t).(map[string]any) + input["nonClaims"] = append(input["nonClaims"].([]any), defaultNonClaims[0]) + first, err := EvaluateDirect(input) + if err != nil { + t.Fatal(err) + } + second, err := EvaluateDirect(InventoryValue(first.Inventory)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(first.Inventory.NonClaims, second.Inventory.NonClaims) || !reflect.DeepEqual(InventoryValue(first.Inventory), InventoryValue(second.Inventory)) { + t.Fatalf("Admit(Project(Admit(x))) changed inventory meaning:\nfirst=%#v\nsecond=%#v", first.Inventory, second.Inventory) + } + projected := InventoryValue(first.Inventory)["nonClaims"].([]any) + for _, ownerDefault := range defaultNonClaims { + count := 0 + for _, value := range projected { + if value == ownerDefault { + count++ + } + } + if count != 1 { + t.Fatalf("owner projection contains default non-claim %q %d times, want exactly once", ownerDefault, count) + } + } +} + func TestBuildRejectsUnanchoredProofRouteCandidate(t *testing.T) { input := validInventory(t).(map[string]any) entry := input["entries"].([]any)[0].(map[string]any) diff --git a/internal/kernel/pathidentity/pathidentity.go b/internal/kernel/pathidentity/pathidentity.go new file mode 100644 index 0000000..84af432 --- /dev/null +++ b/internal/kernel/pathidentity/pathidentity.go @@ -0,0 +1,93 @@ +// Package pathidentity owns conservative, platform-portable path equivalence. +package pathidentity + +import ( + "fmt" + "path" + "strings" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +const ( + MaximumBytes = 1024 + MaximumComponents = 64 +) + +type Prefix struct { + Key string + Path string +} + +func Key(value string) (string, error) { + if err := validate(value); err != nil { + return "", err + } + return cases.Fold().String(norm.NFC.String(value)), nil +} + +func Prefixes(value string) ([]Prefix, error) { + if _, err := Key(value); err != nil { + return nil, err + } + components := strings.Split(value, "/") + prefixes := make([]Prefix, 0, len(components)) + for index := range components { + prefixPath := strings.Join(components[:index+1], "/") + prefixKey, err := Key(prefixPath) + if err != nil { + return nil, err + } + prefixes = append(prefixes, Prefix{Key: prefixKey, Path: prefixPath}) + } + return prefixes, nil +} + +func Overlaps(left, right string) (bool, error) { + leftKey, err := Key(left) + if err != nil { + return false, err + } + rightKey, err := Key(right) + if err != nil { + return false, err + } + return leftKey == rightKey || withinKey(leftKey, rightKey) || withinKey(rightKey, leftKey), nil +} + +func Within(candidate, ancestor string) (bool, error) { + candidateKey, err := Key(candidate) + if err != nil { + return false, err + } + ancestorKey, err := Key(ancestor) + if err != nil { + return false, err + } + return withinKey(candidateKey, ancestorKey), nil +} + +func withinKey(candidate, ancestor string) bool { + return len(candidate) > len(ancestor) && candidate[:len(ancestor)] == ancestor && candidate[len(ancestor)] == '/' +} + +func validate(value string) error { + if !utf8.ValidString(value) { + return fmt.Errorf("path identity requires valid UTF-8") + } + if value == "" || len(value) > MaximumBytes || strings.HasPrefix(value, "/") || strings.Contains(value, `\`) || path.Clean(value) != value || value == "." { + return fmt.Errorf("path identity requires a bounded canonical relative POSIX path") + } + components := strings.Split(value, "/") + if len(components) > MaximumComponents { + return fmt.Errorf("path identity exceeds its component limit") + } + for _, component := range components { + if component == "" || component == "." || component == ".." { + return fmt.Errorf("path identity requires canonical components") + } + } + return nil +} diff --git a/internal/kernel/pathidentity/pathidentity_test.go b/internal/kernel/pathidentity/pathidentity_test.go new file mode 100644 index 0000000..5e976ef --- /dev/null +++ b/internal/kernel/pathidentity/pathidentity_test.go @@ -0,0 +1,46 @@ +package pathidentity + +import "testing" + +func TestPortableEquivalenceAndContainment(t *testing.T) { + tests := []struct { + left string + right string + overlap bool + }{ + {left: "proofkit/A.json", right: "proofkit/a.json", overlap: true}, + {left: "proofkit/caf\u00e9.json", right: "proofkit/cafe\u0301.json", overlap: true}, + {left: "proofkit", right: "proofkit/a.json", overlap: true}, + {left: "proofkit/a.json", right: "docs/a.json", overlap: false}, + } + for _, test := range tests { + actual, err := Overlaps(test.left, test.right) + if err != nil || actual != test.overlap { + t.Fatalf("Overlaps(%q, %q)=%t,%v, want %t,nil", test.left, test.right, actual, err, test.overlap) + } + } + if _, err := Key(string([]byte{0xff})); err == nil { + t.Fatal("Key() admitted invalid UTF-8") + } + if left, _ := Key("proofkit/\u03c3.json"); left != mustKey(t, "proofkit/\u03c2.json") { + t.Fatal("Key() did not apply Unicode case folding") + } + for _, value := range []string{"", "/absolute", "a/../b", "a//b", "a\\b", "./a"} { + if _, err := Key(value); err == nil { + t.Fatalf("Key(%q) admitted a non-canonical path", value) + } + } + prefixes, err := Prefixes("Proofkit/specs/a.json") + if err != nil || len(prefixes) != 3 || prefixes[0].Key != "proofkit" || prefixes[1].Path != "Proofkit/specs" { + t.Fatalf("Prefixes() = %#v, %v", prefixes, err) + } +} + +func mustKey(t *testing.T, value string) string { + t.Helper() + key, err := Key(value) + if err != nil { + t.Fatal(err) + } + return key +} diff --git a/internal/kernel/repositorytransaction/cleanup.go b/internal/kernel/repositorytransaction/cleanup.go new file mode 100644 index 0000000..4ac9093 --- /dev/null +++ b/internal/kernel/repositorytransaction/cleanup.go @@ -0,0 +1,148 @@ +package repositorytransaction + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +var errCleanupDurabilityUnknown = errors.New("repository transaction cleanup durability is unknown") + +func cleanupActive(root *os.Root, plan *Plan) error { + return cleanupTransactionDirectory(root, activeDirectory, plan, false, nil) +} + +func (runtime engine) archiveAndCleanupTerminal(root *os.Root, plan Plan, state string) error { + tombstone, err := archiveTerminal(root, plan, state) + if err != nil { + return err + } + return runtime.compactTerminalTombstone(root, tombstone, &plan) +} + +func archiveTerminal(root *os.Root, plan Plan, state string) (string, error) { + if state != StateApplied && state != StateRolledBack { + return "", fmt.Errorf("repository transaction terminal state is invalid") + } + tombstone := terminalTombstonePath(plan.TransactionID, state) + if exists, err := pathExists(root, tombstone); err != nil { + return "", err + } else if exists { + return "", fmt.Errorf("repository transaction terminal tombstone already exists") + } + if err := ensureTerminalReceipt(root, plan, state); err != nil { + return "", err + } + if err := root.Rename(filepath.FromSlash(activeDirectory), filepath.FromSlash(tombstone)); err != nil { + return "", fmt.Errorf("archive repository transaction terminal state") + } + if err := syncDirectory(root, ControlDirectory); err != nil { + return "", err + } + return tombstone, nil +} + +func (runtime engine) cleanupTerminalTombstone(root *os.Root, tombstone string) error { + return runtime.compactTerminalTombstone(root, tombstone, nil) +} + +func (runtime engine) compactTerminalTombstone(root *os.Root, tombstone string, plan *Plan) error { + entries, err := transactionEntries(root, tombstone) + if err != nil { + return err + } + if err := validateTransactionEntries(entries, plan, true); err != nil { + return err + } + receipt, err := loadTerminalReceipt(root, tombstone) + if err != nil { + return err + } + transactionID, state, ok := terminalEntryIdentity(filepath.Base(tombstone)) + if !ok || receipt.TransactionID != transactionID || receipt.State != state { + return fmt.Errorf("repository transaction terminal receipt does not match its route") + } + if plan != nil && (receipt.TransactionID != plan.TransactionID || receipt.AppliedCount != prefixForState(*plan, state)) { + return fmt.Errorf("repository transaction terminal receipt does not match its plan") + } + for _, entry := range entries { + if entry.Name() == terminalReceiptName { + continue + } + if err := root.Remove(filepath.FromSlash(tombstone + "/" + entry.Name())); err != nil { + return fmt.Errorf("remove repository transaction artifact") + } + } + if err := syncDirectory(root, tombstone); err != nil { + return fmt.Errorf("%w: terminal receipt content sync failed", errCleanupDurabilityUnknown) + } + if err := runtime.callFault(faultAfterStateRemoval, -1); err != nil { + return fmt.Errorf("%w: terminal receipt compaction interrupted", errCleanupDurabilityUnknown) + } + if err := syncDirectory(root, ControlDirectory); err != nil { + return fmt.Errorf("%w: terminal receipt route sync failed", errCleanupDurabilityUnknown) + } + return nil +} + +func discardTerminalReceipt(root *os.Root) error { + entries, err := controlEntries(root) + if err != nil || len(entries) == 0 { + return err + } + if len(entries) != 1 { + return fmt.Errorf("repository transaction control directory contains conflicting state") + } + entry := entries[0] + transactionID, state, ok := terminalEntryIdentity(entry.Name()) + if !ok || !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("repository transaction terminal receipt is invalid") + } + tombstone := ControlDirectory + "/" + entry.Name() + children, err := transactionEntries(root, tombstone) + if err != nil || len(children) != 1 || children[0].Name() != terminalReceiptName { + return fmt.Errorf("repository transaction terminal receipt requires recovery") + } + receipt, err := loadTerminalReceipt(root, tombstone) + if err != nil || receipt.TransactionID != transactionID || receipt.State != state { + return fmt.Errorf("repository transaction terminal receipt is invalid") + } + if err := root.Remove(filepath.FromSlash(tombstone + "/" + terminalReceiptName)); err != nil { + return fmt.Errorf("remove previous repository transaction terminal receipt content") + } + if err := syncDirectory(root, tombstone); err != nil { + return err + } + if err := root.Remove(filepath.FromSlash(tombstone)); err != nil { + return fmt.Errorf("remove previous repository transaction terminal receipt") + } + return syncDirectory(root, ControlDirectory) +} + +func cleanupTransactionDirectory(root *os.Root, directory string, plan *Plan, allowPartialTerminal bool, afterRemoval func() error) error { + entries, err := transactionEntries(root, directory) + if err != nil { + return err + } + if err := validateTransactionEntries(entries, plan, allowPartialTerminal); err != nil { + return err + } + for _, entry := range entries { + if err := root.Remove(filepath.FromSlash(directory + "/" + entry.Name())); err != nil { + return fmt.Errorf("remove repository transaction artifact") + } + } + if err := root.Remove(filepath.FromSlash(directory)); err != nil { + return fmt.Errorf("remove repository transaction state") + } + if afterRemoval != nil { + if err := afterRemoval(); err != nil { + return fmt.Errorf("%w: %v", errCleanupDurabilityUnknown, err) + } + } + if err := syncDirectory(root, ControlDirectory); err != nil { + return fmt.Errorf("%w: %v", errCleanupDurabilityUnknown, err) + } + return nil +} diff --git a/internal/kernel/repositorytransaction/control_state.go b/internal/kernel/repositorytransaction/control_state.go new file mode 100644 index 0000000..ee685b4 --- /dev/null +++ b/internal/kernel/repositorytransaction/control_state.go @@ -0,0 +1,249 @@ +package repositorytransaction + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/pathidentity" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func validateActiveState(root *os.Root, plan Plan) error { + entries, err := activeEntries(root) + if err != nil { + return err + } + return validateTransactionEntries(entries, &plan, false) +} + +func validateTransactionEntries(entries []fs.DirEntry, plan *Plan, allowPartialTerminal bool) error { + allowed := map[string]struct{}{ + "journal.json": {}, + "journal.tmp": {}, + "ready": {}, + "committed": {}, + "rolled-back": {}, + terminalReceiptName: {}, + } + if plan != nil { + for index, operation := range plan.Operations { + if operation.Action == ActionUnchanged { + continue + } + allowed[strings.TrimPrefix(afterObjectPath(index), activeDirectory+"/")] = struct{}{} + allowed[strings.TrimPrefix(transactionTemporaryPath(plan.TransactionID, index, operation.Path), activeDirectory+"/")] = struct{}{} + if operation.Before.Exists { + allowed[strings.TrimPrefix(beforeObjectPath(index), activeDirectory+"/")] = struct{}{} + } + } + for index := range plan.CreatedDirectories { + allowed[strings.TrimPrefix(directoryOwnershipPath(index), activeDirectory+"/")] = struct{}{} + } + } + for _, entry := range entries { + _, explicitlyAllowed := allowed[entry.Name()] + if allowPartialTerminal && plan == nil { + explicitlyAllowed = explicitlyAllowed || isBoundedTransactionEntryName(entry.Name()) + } + if !explicitlyAllowed || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("repository transaction control directory contains an unknown entry") + } + } + return nil +} + +func activeEntries(root *os.Root) ([]fs.DirEntry, error) { + return transactionEntries(root, activeDirectory) +} + +func transactionEntries(root *os.Root, relativePath string) ([]fs.DirEntry, error) { + if err := validatePrivateDirectory(root, relativePath, 0o700); err != nil { + return nil, err + } + directory, err := root.Open(filepath.FromSlash(relativePath)) + if err != nil { + return nil, fmt.Errorf("open repository transaction state") + } + defer directory.Close() + entryLimit := MaximumOperations*2 + MaximumOperations*pathidentity.MaximumComponents + 10 + entries, err := directory.ReadDir(entryLimit + 1) + if err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("read repository transaction state") + } + if len(entries) > entryLimit { + return nil, fmt.Errorf("repository transaction state exceeds its entry limit") + } + sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() }) + return entries, nil +} + +func hasPendingTransactionState(root *os.Root) (bool, error) { + pending, err := pendingTransactionState(root) + return pending.Exists, err +} + +func pendingTransactionState(root *os.Root) (pendingState, error) { + exists, err := controlNamespaceExists(root) + if err != nil || !exists { + return pendingState{}, err + } + entries, err := controlEntries(root) + if err != nil { + return pendingState{}, err + } + if len(entries) == 0 { + return pendingState{}, nil + } + pending := pendingState{Exists: true} + if len(entries) != 1 { + return pending, nil + } + entry := entries[0] + if entry.Name() == "active" && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + if err := validatePrivateDirectory(root, activeDirectory, 0o700); err != nil { + return pendingState{}, err + } + if plan, loadErr := loadJournal(root); loadErr == nil { + pending.TransactionID = plan.TransactionID + return pending, nil + } + if transactionID, identityKnown, _, inspectErr := incompleteJournalCanBeDiscarded(root); inspectErr == nil && identityKnown { + pending.TransactionID = transactionID + } + return pending, nil + } + if transactionID, ok := terminalEntryTransactionID(entry.Name()); ok && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + children, inspectErr := transactionEntries(root, ControlDirectory+"/"+entry.Name()) + if inspectErr != nil { + return pendingState{}, inspectErr + } + if len(children) == 1 && children[0].Name() == terminalReceiptName { + receipt, receiptErr := loadTerminalReceipt(root, ControlDirectory+"/"+entry.Name()) + _, state, identityOK := terminalEntryIdentity(entry.Name()) + if receiptErr == nil && identityOK && receipt.TransactionID == transactionID && receipt.State == state { + return pendingState{}, nil + } + } + pending.TransactionID = transactionID + } + return pending, nil +} + +func controlEntries(root *os.Root) ([]fs.DirEntry, error) { + exists, err := controlNamespaceExists(root) + if err != nil { + return nil, err + } + if !exists { + return []fs.DirEntry{}, nil + } + directory, err := root.Open(filepath.FromSlash(ControlDirectory)) + if err != nil { + return nil, fmt.Errorf("open repository transaction control directory") + } + defer directory.Close() + entries, err := directory.ReadDir(4) + if err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("read repository transaction control directory") + } + if len(entries) > 3 { + return nil, fmt.Errorf("repository transaction control directory contains conflicting state") + } + sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() }) + return entries, nil +} + +func terminalEntryTransactionID(name string) (string, bool) { + transactionID, _, ok := terminalEntryIdentity(name) + return transactionID, ok +} + +func terminalEntryIdentity(name string) (string, string, bool) { + for _, state := range []string{StateApplied, StateRolledBack} { + prefix := "gc-" + suffix := "-" + state + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, suffix) { + continue + } + hexDigest := strings.TrimSuffix(strings.TrimPrefix(name, prefix), suffix) + transactionID, err := admit.SHA256Ref("sha256:"+hexDigest, "repository transaction terminal identity") + if err == nil { + return transactionID, state, true + } + } + return "", "", false +} + +func terminalTombstonePath(transactionID, state string) string { + return ControlDirectory + "/gc-" + strings.TrimPrefix(transactionID, "sha256:") + "-" + state +} + +func isBoundedTransactionEntryName(name string) bool { + for _, prefix := range []string{"after-", "before-"} { + if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".bin") { + indexText := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".bin") + index, err := strconv.Atoi(indexText) + return err == nil && len(indexText) == 3 && index >= 0 && index < MaximumOperations + } + } + if strings.HasPrefix(name, "directory-") && strings.HasSuffix(name, ".json") { + indexText := strings.TrimSuffix(strings.TrimPrefix(name, "directory-"), ".json") + index, err := strconv.Atoi(indexText) + return err == nil && len(indexText) == 4 && index >= 0 && index < MaximumOperations*pathidentity.MaximumComponents + } + if strings.HasPrefix(name, "publish-") && strings.HasSuffix(name, ".tmp") { + indexText := strings.TrimSuffix(strings.TrimPrefix(name, "publish-"), ".tmp") + index, err := strconv.Atoi(indexText) + return err == nil && len(indexText) == 3 && index >= 0 && index < MaximumOperations + } + return false +} + +func incompleteJournalCanBeDiscarded(root *os.Root) (string, bool, bool, error) { + ready, err := markerExists(root, readyMarker) + if err == nil && ready { + return "", false, false, nil + } + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", false, false, err + } + entries, err := activeEntries(root) + if err != nil { + return "", false, false, err + } + for _, entry := range entries { + if entry.Name() != "journal.tmp" || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return "", false, false, nil + } + } + if len(entries) == 0 { + return "", false, true, nil + } + content, err := readOwnedFile(root, journalTemp, MaximumJournalBytes) + if err != nil { + return "", false, false, err + } + value, err := admission.DecodeJSON(bytes.NewReader(content), MaximumJournalBytes) + if err != nil { + return "", false, true, nil + } + plan, err := admitJournal(value) + if err != nil { + return "", false, true, nil + } + canonical, err := stablejson.Marshal(journalValue(plan)) + if err != nil || !bytes.Equal(content, canonical) { + return "", false, true, nil + } + return plan.TransactionID, true, true, nil +} diff --git a/internal/kernel/repositorytransaction/directory_ownership.go b/internal/kernel/repositorytransaction/directory_ownership.go new file mode 100644 index 0000000..25f0b2f --- /dev/null +++ b/internal/kernel/repositorytransaction/directory_ownership.go @@ -0,0 +1,211 @@ +package repositorytransaction + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const maximumDirectoryOwnershipBytes = 4096 + +type directoryOwnership struct { + Identity string + Path string + TransactionID string +} + +func ensureTargetDirectories(root *os.Root, plan Plan) error { + for index, directory := range plan.CreatedDirectories { + record, recorded, err := loadDirectoryOwnership(root, plan, index) + if err != nil { + return err + } + if recorded { + identity, exists, err := inspectOwnedTargetDirectory(root, directory) + if err != nil || !exists || identity != record.Identity { + return fmt.Errorf("repository target directory ownership changed") + } + continue + } + if _, exists, err := inspectOwnedTargetDirectory(root, directory); err != nil { + return err + } else if exists { + return fmt.Errorf("repository target directory appeared without transaction ownership") + } + if err := root.Mkdir(filepath.FromSlash(directory), 0o755); err != nil { + return fmt.Errorf("create repository target directory") + } + if err := root.Chmod(filepath.FromSlash(directory), 0o755); err != nil { + return fmt.Errorf("set repository target directory mode") + } + identity, exists, err := inspectOwnedTargetDirectory(root, directory) + if err != nil || !exists { + return fmt.Errorf("admit created repository target directory") + } + if err := syncDirectory(root, path.Dir(directory)); err != nil { + return err + } + record = directoryOwnership{Identity: identity, Path: directory, TransactionID: plan.TransactionID} + if err := writeDirectoryOwnership(root, index, record); err != nil { + _ = removeOwnedTargetDirectory(root, directory, identity) + return err + } + } + return nil +} + +func removeCreatedDirectories(root *os.Root, plan Plan) error { + for index := len(plan.CreatedDirectories) - 1; index >= 0; index-- { + directory := plan.CreatedDirectories[index] + record, recorded, err := loadDirectoryOwnership(root, plan, index) + if err != nil { + return err + } + identity, exists, err := inspectOwnedTargetDirectory(root, directory) + if err != nil { + return err + } + if !recorded { + if exists { + return fmt.Errorf("repository target directory lacks transaction ownership") + } + continue + } + if !exists { + continue + } + if identity != record.Identity { + return fmt.Errorf("repository target directory ownership changed") + } + if err := removeOwnedTargetDirectory(root, directory, identity); err != nil { + return err + } + } + return nil +} + +func inspectOwnedTargetDirectory(root *os.Root, relativePath string) (string, bool, error) { + native := filepath.FromSlash(relativePath) + routeInfo, err := root.Lstat(native) + if errors.Is(err, fs.ErrNotExist) { + return "", false, nil + } + if err != nil || routeInfo.Mode()&os.ModeSymlink != 0 || !routeInfo.IsDir() || routeInfo.Mode().Perm() != 0o755 { + return "", false, fmt.Errorf("repository target directory is unsafe") + } + owned, err := platformOwnedByCurrentUser(routeInfo) + if err != nil || !owned { + return "", false, fmt.Errorf("repository target directory is not owned by the current user") + } + directory, err := root.Open(native) + if err != nil { + return "", false, fmt.Errorf("open repository target directory") + } + defer directory.Close() + handleInfo, err := directory.Stat() + if err != nil || !os.SameFile(routeInfo, handleInfo) { + return "", false, fmt.Errorf("repository target directory changed during admission") + } + identity, err := platformFileIdentity(handleInfo) + if err != nil { + return "", false, err + } + current, err := root.Lstat(native) + if err != nil || !os.SameFile(handleInfo, current) { + return "", false, fmt.Errorf("repository target directory route changed during admission") + } + return identity, true, nil +} + +func removeOwnedTargetDirectory(root *os.Root, relativePath, expectedIdentity string) error { + identity, exists, err := inspectOwnedTargetDirectory(root, relativePath) + if err != nil || !exists || identity != expectedIdentity { + return fmt.Errorf("repository target directory cannot be restored") + } + if err := root.Remove(filepath.FromSlash(relativePath)); err != nil { + return fmt.Errorf("repository target directory cannot be restored") + } + return syncDirectory(root, path.Dir(relativePath)) +} + +func writeDirectoryOwnership(root *os.Root, index int, record directoryOwnership) error { + content, err := stablejson.Marshal(directoryOwnershipValue(record)) + if err != nil || len(content) > maximumDirectoryOwnershipBytes { + return fmt.Errorf("encode repository target directory ownership") + } + return writeOwnedFile(root, directoryOwnershipPath(index), content, 0o600) +} + +func loadDirectoryOwnership(root *os.Root, plan Plan, index int) (directoryOwnership, bool, error) { + relativePath := directoryOwnershipPath(index) + exists, err := pathExists(root, relativePath) + if err != nil || !exists { + return directoryOwnership{}, false, err + } + content, err := readOwnedFile(root, relativePath, maximumDirectoryOwnershipBytes) + if err != nil { + return directoryOwnership{}, false, err + } + raw, err := admission.DecodeJSON(bytes.NewReader(content), maximumDirectoryOwnershipBytes) + if err != nil { + return directoryOwnership{}, false, fmt.Errorf("admit repository target directory ownership") + } + record, err := admitDirectoryOwnership(raw) + if err != nil || index >= len(plan.CreatedDirectories) || record.Path != plan.CreatedDirectories[index] || record.TransactionID != plan.TransactionID { + return directoryOwnership{}, false, fmt.Errorf("repository target directory ownership does not match the transaction") + } + canonical, err := stablejson.Marshal(directoryOwnershipValue(record)) + if err != nil || !bytes.Equal(content, canonical) { + return directoryOwnership{}, false, fmt.Errorf("repository target directory ownership is not canonical") + } + return record, true, nil +} + +func admitDirectoryOwnership(raw any) (directoryOwnership, error) { + record, ok := raw.(map[string]any) + if !ok { + return directoryOwnership{}, fmt.Errorf("repository target directory ownership must be an object") + } + if err := admit.KnownKeys(record, []string{"directoryKind", "identity", "path", "schemaVersion", "transactionId"}, "repository target directory ownership"); err != nil { + return directoryOwnership{}, err + } + if record["directoryKind"] != "proofkit.repository-created-directory" || !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return directoryOwnership{}, fmt.Errorf("repository target directory ownership identity is invalid") + } + identity, err := admit.NonEmptyText(record["identity"], "repository target directory ownership filesystem identity") + if err != nil { + return directoryOwnership{}, err + } + directoryPath, err := admit.SafeRepoRelativePath(recordText(record["path"]), "repository target directory ownership path") + if err != nil { + return directoryOwnership{}, err + } + transactionID, err := admit.SHA256Ref(record["transactionId"], "repository target directory ownership transactionId") + if err != nil { + return directoryOwnership{}, err + } + return directoryOwnership{Identity: identity, Path: directoryPath, TransactionID: transactionID}, nil +} + +func directoryOwnershipValue(record directoryOwnership) map[string]any { + return map[string]any{ + "directoryKind": "proofkit.repository-created-directory", + "identity": record.Identity, + "path": record.Path, + "schemaVersion": json.Number("1"), + "transactionId": record.TransactionID, + } +} + +func directoryOwnershipPath(index int) string { + return fmt.Sprintf("%s/directory-%04d.json", activeDirectory, index) +} diff --git a/internal/kernel/repositorytransaction/execution.go b/internal/kernel/repositorytransaction/execution.go new file mode 100644 index 0000000..ef896a3 --- /dev/null +++ b/internal/kernel/repositorytransaction/execution.go @@ -0,0 +1,133 @@ +package repositorytransaction + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +func (runtime engine) applyForward(ctx context.Context, root *os.Root, plan Plan, prefix int) error { + if err := ensureTargetDirectories(root, plan); err != nil { + return err + } + for directoryIndex := range plan.CreatedDirectories { + if err := runtime.callFault(faultAfterDirectory, directoryIndex); err != nil { + return err + } + } + changedIndex := 0 + for operationIndex, operation := range plan.Operations { + if operation.Action == ActionUnchanged { + continue + } + if changedIndex < prefix { + changedIndex++ + continue + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("repository transaction operation cancelled: %w", err) + } + if err := runtime.callFault(faultBeforePublish, changedIndex); err != nil { + return err + } + if err := publishContent(root, plan, operationIndex, operation.Before, operation.afterContent, operation.After.Mode); err != nil { + return err + } + changedIndex++ + if err := runtime.callFault(faultAfterPublish, changedIndex); err != nil { + return err + } + } + return nil +} + +func (runtime engine) rollbackAfterFailure(ctx context.Context, root *os.Root, plan Plan, failureClass string) (Result, error) { + prefix, err := classifyPrefix(root, plan) + if err != nil { + return Result{FailureClass: failureClass, State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := runtime.rollbackPrefix(ctx, root, plan, prefix); err != nil { + return resultWithObservedPrefix(root, plan, Result{FailureClass: "rollback_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}), nil + } + if err := removeInterruptedTemporary(root, plan); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "temporary_cleanup_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := removeCreatedDirectories(root, plan); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "directory_cleanup_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := writeMarker(root, rolledBackMarker); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "terminal_marker_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := runtime.archiveAndCleanupTerminal(root, plan, StateRolledBack); err != nil { + if errors.Is(err, errCleanupDurabilityUnknown) { + return Result{AppliedCountKnown: true, FailureClass: "rolled_back_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCountKnown: true, FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID}, nil +} + +func (runtime engine) rollbackPrefix(ctx context.Context, root *os.Root, plan Plan, prefix int) error { + changed := changedOperationIndexes(plan) + for position := prefix - 1; position >= 0; position-- { + if err := ctx.Err(); err != nil { + return fmt.Errorf("repository transaction rollback cancelled: %w", err) + } + operationIndex := changed[position] + operation := plan.Operations[operationIndex] + if operation.Action == ActionCreate { + if err := removeCreatedTarget(root, operation); err != nil { + return err + } + continue + } + if err := publishContent(root, plan, operationIndex, operation.After, operation.beforeContent, operation.Before.Mode); err != nil { + return err + } + } + return nil +} + +func (runtime engine) finishPreparingFailure(root *os.Root, plan Plan, failureClass string) (Result, error) { + exists, err := pathExists(root, activeDirectory) + if err != nil { + return Result{FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + if !exists { + return Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID}, nil + } + if err := cleanupActive(root, &plan); err != nil { + if errors.Is(err, errCleanupDurabilityUnknown) { + return Result{AppliedCountKnown: true, FailureClass: "preparing_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCountKnown: true, FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID}, nil +} + +func removeInterruptedTemporary(root *os.Root, plan Plan) error { + for index, operation := range plan.Operations { + if operation.Action == ActionUnchanged { + continue + } + temporary := transactionTemporaryPath(plan.TransactionID, index, operation.Path) + info, err := root.Lstat(filepath.FromSlash(temporary)) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("repository transaction temporary route is unsafe") + } + owned, ownershipErr := platformOwnedByCurrentUser(info) + if ownershipErr != nil || !owned { + return fmt.Errorf("repository transaction temporary route is not owned") + } + if err := root.Remove(filepath.FromSlash(temporary)); err != nil { + return fmt.Errorf("remove repository transaction temporary file") + } + } + return nil +} diff --git a/internal/kernel/repositorytransaction/filesystem.go b/internal/kernel/repositorytransaction/filesystem.go new file mode 100644 index 0000000..c44b481 --- /dev/null +++ b/internal/kernel/repositorytransaction/filesystem.go @@ -0,0 +1,370 @@ +package repositorytransaction + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +const activeDirectory = ControlDirectory + "/active" + +func openRepository(rootPath string) (*os.Root, string, error) { + if strings.TrimSpace(rootPath) == "" { + return nil, "", fmt.Errorf("repository root must be explicit") + } + absolute, err := filepath.Abs(rootPath) + if err != nil { + return nil, "", fmt.Errorf("resolve repository root") + } + routeInfo, err := os.Lstat(absolute) + if err != nil { + return nil, "", fmt.Errorf("inspect repository root") + } + if routeInfo.Mode()&os.ModeSymlink != 0 || !routeInfo.IsDir() { + return nil, "", fmt.Errorf("repository root must be a non-symlink directory") + } + root, err := os.OpenRoot(absolute) + if err != nil { + return nil, "", fmt.Errorf("open repository root") + } + handleInfo, err := root.Stat(".") + if err != nil || !os.SameFile(routeInfo, handleInfo) { + root.Close() + return nil, "", fmt.Errorf("repository root changed during admission") + } + identity, err := platformFileIdentity(handleInfo) + if err != nil { + root.Close() + return nil, "", err + } + return root, digest.SHA256TextRef(filepath.Clean(absolute) + "\x00" + identity), nil +} + +func inspectParentDirectories(root *os.Root, directory string) ([]string, error) { + if directory == "." || directory == "" { + return nil, nil + } + components := strings.Split(directory, "/") + missing := []string{} + current := "" + ancestorMissing := false + for _, component := range components { + if current == "" { + current = component + } else { + current += "/" + component + } + if ancestorMissing { + missing = append(missing, current) + continue + } + info, err := root.Lstat(filepath.FromSlash(current)) + if errors.Is(err, fs.ErrNotExist) { + ancestorMissing = true + missing = append(missing, current) + continue + } + if err != nil { + return nil, fmt.Errorf("inspect repository transaction parent") + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, fmt.Errorf("repository transaction path traverses a symlink or non-directory") + } + } + return missing, nil +} + +func inspectTarget(root *os.Root, relativePath string, maximum int64) (Snapshot, []byte, error) { + missing, err := inspectParentDirectories(root, path.Dir(relativePath)) + if err != nil { + return Snapshot{}, nil, err + } + if len(missing) > 0 { + return Snapshot{}, nil, nil + } + native := filepath.FromSlash(relativePath) + routeInfo, err := root.Lstat(native) + if errors.Is(err, fs.ErrNotExist) { + return Snapshot{}, nil, nil + } + if err != nil { + return Snapshot{}, nil, fmt.Errorf("inspect repository transaction target") + } + if routeInfo.Mode()&os.ModeSymlink != 0 || !routeInfo.Mode().IsRegular() || routeInfo.Mode()&^fs.ModePerm != 0 { + return Snapshot{}, nil, fmt.Errorf("repository transaction target must be a regular non-symlink file") + } + if routeInfo.Size() > maximum { + return Snapshot{}, nil, fmt.Errorf("repository transaction target exceeds the file byte limit") + } + file, err := openNoFollow(root, native) + if err != nil { + return Snapshot{}, nil, fmt.Errorf("open repository transaction target") + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(routeInfo, opened) { + return Snapshot{}, nil, fmt.Errorf("repository transaction target changed during admission") + } + content, err := io.ReadAll(io.LimitReader(file, maximum+1)) + if err != nil || int64(len(content)) > maximum { + return Snapshot{}, nil, fmt.Errorf("read repository transaction target") + } + after, err := file.Stat() + if err != nil || !os.SameFile(opened, after) || opened.Size() != after.Size() || !opened.ModTime().Equal(after.ModTime()) || after.Size() != int64(len(content)) { + return Snapshot{}, nil, fmt.Errorf("repository transaction target changed during read") + } + current, err := root.Lstat(native) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) { + return Snapshot{}, nil, fmt.Errorf("repository transaction target route changed during read") + } + snapshot := snapshotForContent(content, opened.Mode().Perm()) + return snapshot, append([]byte(nil), content...), nil +} + +func pathExists(root *os.Root, relativePath string) (bool, error) { + info, err := root.Lstat(filepath.FromSlash(relativePath)) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect repository transaction state") + } + if info.Mode()&os.ModeSymlink != 0 { + return false, fmt.Errorf("repository transaction state must not be a symlink") + } + return true, nil +} + +func ensureDirectory(root *os.Root, relativePath string, mode fs.FileMode) error { + current := "" + for _, component := range strings.Split(relativePath, "/") { + if current == "" { + current = component + } else { + current += "/" + component + } + _, err := root.Lstat(filepath.FromSlash(current)) + if errors.Is(err, fs.ErrNotExist) { + if err := root.Mkdir(filepath.FromSlash(current), mode); err != nil { + return fmt.Errorf("create repository transaction directory") + } + if err := root.Chmod(filepath.FromSlash(current), mode); err != nil { + return fmt.Errorf("set repository transaction directory mode") + } + if err := syncDirectory(root, path.Dir(current)); err != nil { + return err + } + } else if err != nil { + return fmt.Errorf("inspect repository transaction directory") + } + if err := validatePrivateDirectory(root, current, mode); err != nil { + return err + } + } + return nil +} + +func validatePrivateDirectory(root *os.Root, relativePath string, mode fs.FileMode) error { + native := filepath.FromSlash(relativePath) + routeInfo, err := root.Lstat(native) + if err != nil || routeInfo.Mode()&os.ModeSymlink != 0 || !routeInfo.IsDir() || routeInfo.Mode().Perm() != mode.Perm() || routeInfo.Mode()&(fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky) != 0 { + return fmt.Errorf("repository transaction directory is unsafe") + } + owned, err := platformOwnedByCurrentUser(routeInfo) + if err != nil || !owned { + return fmt.Errorf("repository transaction directory is not privately owned") + } + directory, err := root.Open(native) + if err != nil { + return fmt.Errorf("open repository transaction directory") + } + defer directory.Close() + handleInfo, err := directory.Stat() + if err != nil || !os.SameFile(routeInfo, handleInfo) { + return fmt.Errorf("repository transaction directory changed during admission") + } + owned, err = platformOwnedByCurrentUser(handleInfo) + if err != nil || !owned || handleInfo.Mode().Perm() != mode.Perm() { + return fmt.Errorf("repository transaction directory ownership changed during admission") + } + return nil +} + +func controlNamespaceExists(root *os.Root) (bool, error) { + rootExists, err := pathExists(root, ControlRoot) + if err != nil || !rootExists { + return false, err + } + if err := validatePrivateDirectory(root, ControlRoot, 0o700); err != nil { + return false, err + } + directoryExists, err := pathExists(root, ControlDirectory) + if err != nil || !directoryExists { + return false, err + } + if err := validatePrivateDirectory(root, ControlDirectory, 0o700); err != nil { + return false, err + } + return true, nil +} + +func syncDirectory(root *os.Root, relativePath string) error { + if relativePath == "" { + relativePath = "." + } + directory, err := root.Open(filepath.FromSlash(relativePath)) + if err != nil { + return fmt.Errorf("open repository directory for sync") + } + defer directory.Close() + if err := directory.Sync(); err != nil { + return fmt.Errorf("sync repository directory") + } + return nil +} + +func writeOwnedFile(root *os.Root, relativePath string, content []byte, mode fs.FileMode) error { + file, err := root.OpenFile(filepath.FromSlash(relativePath), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("create repository transaction file") + } + remove := true + defer func() { + if remove { + _ = root.Remove(filepath.FromSlash(relativePath)) + } + }() + if _, err := file.Write(content); err != nil { + file.Close() + return fmt.Errorf("write repository transaction file") + } + if err := file.Chmod(mode); err != nil { + file.Close() + return fmt.Errorf("set repository transaction file mode") + } + if err := file.Sync(); err != nil { + file.Close() + return fmt.Errorf("sync repository transaction file") + } + if err := file.Close(); err != nil { + return fmt.Errorf("close repository transaction file") + } + remove = false + return syncDirectory(root, path.Dir(relativePath)) +} + +func readOwnedFile(root *os.Root, relativePath string, maximum int64) ([]byte, error) { + file, err := openNoFollow(root, filepath.FromSlash(relativePath)) + if err != nil { + return nil, fmt.Errorf("open repository transaction file") + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Mode()&^fs.ModePerm != 0 || info.Mode().Perm() != 0o600 || info.Size() > maximum { + return nil, fmt.Errorf("repository transaction file is invalid") + } + owned, err := platformOwnedByCurrentUser(info) + if err != nil || !owned { + return nil, fmt.Errorf("repository transaction file is not privately owned") + } + content, err := io.ReadAll(io.LimitReader(file, maximum+1)) + if err != nil || int64(len(content)) > maximum { + return nil, fmt.Errorf("read repository transaction file") + } + current, err := root.Lstat(filepath.FromSlash(relativePath)) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, current) { + return nil, fmt.Errorf("repository transaction file route changed") + } + return content, nil +} + +func publishContent(root *os.Root, plan Plan, operationIndex int, expected Snapshot, content []byte, mode fs.FileMode) error { + operation := plan.Operations[operationIndex] + temporaryPath := transactionTemporaryPath(plan.TransactionID, operationIndex, operation.Path) + if exists, err := pathExists(root, temporaryPath); err != nil { + return err + } else if exists { + info, err := root.Lstat(filepath.FromSlash(temporaryPath)) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("repository transaction temporary route is unsafe") + } + owned, ownershipErr := platformOwnedByCurrentUser(info) + if ownershipErr != nil || !owned { + return fmt.Errorf("repository transaction temporary route is not owned") + } + if err := root.Remove(filepath.FromSlash(temporaryPath)); err != nil { + return fmt.Errorf("remove interrupted repository transaction temporary file") + } + } + if err := writeOwnedFile(root, temporaryPath, content, mode); err != nil { + return err + } + observed, _, err := inspectTarget(root, operation.Path, MaximumFileBytes) + if err != nil || !equalSnapshot(observed, expected) { + return fmt.Errorf("repository transaction target changed before publication") + } + if err := root.Rename(filepath.FromSlash(temporaryPath), filepath.FromSlash(operation.Path)); err != nil { + return fmt.Errorf("publish repository transaction target") + } + if err := syncDirectory(root, path.Dir(operation.Path)); err != nil { + return err + } + observed, observedContent, err := inspectTarget(root, operation.Path, MaximumFileBytes) + if err != nil || !equalSnapshot(observed, snapshotForContent(content, mode)) || !bytes.Equal(observedContent, content) { + return fmt.Errorf("repository transaction target failed after publication verification") + } + return nil +} + +func removeCreatedTarget(root *os.Root, operation Operation) error { + observed, _, err := inspectTarget(root, operation.Path, MaximumFileBytes) + if err != nil || !equalSnapshot(observed, operation.After) { + return fmt.Errorf("repository transaction target cannot be restored") + } + if err := root.Remove(filepath.FromSlash(operation.Path)); err != nil { + return fmt.Errorf("remove created repository transaction target") + } + return syncDirectory(root, path.Dir(operation.Path)) +} + +func transactionTemporaryPath(transactionID string, index int, targetPath string) string { + _ = transactionID + _ = targetPath + return fmt.Sprintf("%s/publish-%03d.tmp", activeDirectory, index) +} + +func modeText(mode fs.FileMode) string { + if mode == 0 { + return "0000" + } + return fmt.Sprintf("%04o", mode.Perm()) +} + +func parseMode(value any, context string) (fs.FileMode, error) { + text, ok := value.(string) + if !ok || len(text) != 4 || text[0] != '0' { + return 0, fmt.Errorf("%s is invalid", context) + } + parsed, err := strconv.ParseUint(text, 8, 32) + if err != nil || parsed > 0o777 { + return 0, fmt.Errorf("%s is invalid", context) + } + return fs.FileMode(parsed), nil +} + +func intString(value int) string { + return strconv.Itoa(value) +} + +func int64String(value int64) string { + return strconv.FormatInt(value, 10) +} diff --git a/internal/kernel/repositorytransaction/invariant_test.go b/internal/kernel/repositorytransaction/invariant_test.go new file mode 100644 index 0000000..dc5b20f --- /dev/null +++ b/internal/kernel/repositorytransaction/invariant_test.go @@ -0,0 +1,221 @@ +package repositorytransaction + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyExecutesFrozenPlan(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "proofkit"), 0o755); err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, _ int) error { + if point == faultAfterReady { + plan.Operations[0].Path = "proofkit/redirected.json" + plan.Operations[0].afterContent[0] = 'X' + } + return nil + }} + result, err := runtime.apply(context.Background(), root, plan) + if err != nil || result.State != StateApplied { + t.Fatalf("apply() result=%#v error=%v", result, err) + } + assertTestFile(t, root, "proofkit/target.json", "desired\n", 0o644) + if _, err := os.Stat(filepath.Join(root, "proofkit", "redirected.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("caller mutation redirected an admitted effect: %v", err) + } +} + +func TestApplyDoesNotRemoveUnownedNeighbourTemporary(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "proofkit"), 0o755); err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + oldTemporary := filepath.Join(root, "proofkit", ".agentic-proofkit-txn-"+strings.TrimPrefix(plan.TransactionID, "sha256:")+"-000.tmp") + if err := os.WriteFile(oldTemporary, []byte("caller-owned\n"), 0o600); err != nil { + t.Fatal(err) + } + result, err := Apply(context.Background(), root, plan) + if err != nil || result.State != StateApplied { + t.Fatalf("Apply() result=%#v error=%v", result, err) + } + content, err := os.ReadFile(oldTemporary) + if err != nil || string(content) != "caller-owned\n" { + t.Fatalf("Apply() changed an undeclared neighbour: %q, %v", content, err) + } +} + +func TestLateDirectoryIsNeverClaimedOrRemoved(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "new/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, _ int) error { + if point == faultAfterReady { + if err := os.Mkdir(filepath.Join(root, "new"), 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(root, "new", "foreign.txt"), []byte("foreign\n"), 0o644) + } + return nil + }} + result, err := runtime.apply(context.Background(), root, plan) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "directory_cleanup_failed" { + t.Fatalf("apply() result=%#v error=%v", result, err) + } + assertTestFile(t, root, "new/foreign.txt", "foreign\n", 0o644) +} + +func TestDirectoryOwnershipRejectsInodeSubstitution(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "new/nested/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, root, plan, 0) + if err := os.Rename(filepath.Join(root, "new"), filepath.Join(root, "captured")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "new"), 0o755); err != nil { + t.Fatal(err) + } + result, err := Recover(context.Background(), root, plan.TransactionID, RecoveryRollback) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "directory_cleanup_failed" { + t.Fatalf("Recover() result=%#v error=%v", result, err) + } + if info, err := os.Stat(filepath.Join(root, "new")); err != nil || !info.IsDir() { + t.Fatalf("substituted directory was removed: %v", err) + } +} + +func TestRecoveryActionAndTerminalReceiptAreStable(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, root, plan, 0) + transactionRoot, _, err := openRepository(root) + if err != nil { + t.Fatal(err) + } + if err := writeMarker(transactionRoot, rolledBackMarker); err != nil { + transactionRoot.Close() + t.Fatal(err) + } + if err := transactionRoot.Close(); err != nil { + t.Fatal(err) + } + mismatch, err := Recover(context.Background(), root, plan.TransactionID, RecoveryResume) + if err != nil || mismatch.State != StateRecoveryRequired || mismatch.FailureClass != "rolled_back_state_mismatch" { + t.Fatalf("Recover(resume active rollback)=%#v, %v", mismatch, err) + } + for attempt := 0; attempt < 2; attempt++ { + result, err := Recover(context.Background(), root, plan.TransactionID, RecoveryRollback) + if err != nil || result.State != StateRolledBack || result.RecoveredBy != RecoveryRollback || !result.AppliedCountKnown || result.AppliedCount != 0 { + t.Fatalf("Recover(rollback attempt %d)=%#v, %v", attempt, result, err) + } + } + mismatch, err = Recover(context.Background(), root, plan.TransactionID, RecoveryResume) + if err != nil || mismatch.State != StateRecoveryRequired || mismatch.FailureClass != "rolled_back_state_mismatch" { + t.Fatalf("Recover(resume terminal rollback)=%#v, %v", mismatch, err) + } +} + +func TestAppliedTerminalReceiptReplaysCompleteResult(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{ + {Path: "proofkit/a.json", Content: []byte("a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("b\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), root, plan); err != nil || result.State != StateApplied || result.AppliedCount != 2 { + t.Fatalf("Apply()=%#v, %v", result, err) + } + for attempt := 0; attempt < 2; attempt++ { + result, err := Recover(context.Background(), root, plan.TransactionID, RecoveryResume) + if err != nil || result.State != StateApplied || result.RecoveredBy != RecoveryResume || !result.AppliedCountKnown || result.AppliedCount != 2 { + t.Fatalf("Recover(attempt %d)=%#v, %v", attempt, result, err) + } + } +} + +func TestCommittedRecoveryRejectsRollback(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, _ int) error { + if point == faultAfterTerminal { + return errors.New("injected") + } + return nil + }} + result, err := runtime.apply(context.Background(), root, plan) + if err != nil || result.State != StateCleanupRequired { + t.Fatalf("apply() result=%#v error=%v", result, err) + } + mismatch, err := Recover(context.Background(), root, plan.TransactionID, RecoveryRollback) + if err != nil || mismatch.State != StateRecoveryRequired || mismatch.FailureClass != "committed_state_mismatch" { + t.Fatalf("Recover(rollback committed)=%#v, %v", mismatch, err) + } +} + +func TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity(t *testing.T) { + root := t.TempDir() + unknown := filepath.Join(root, ControlDirectory, "unknown") + if err := os.MkdirAll(unknown, 0o700); err != nil { + t.Fatal(err) + } + for _, directory := range []string{filepath.Join(root, ControlRoot), filepath.Join(root, ControlDirectory), unknown} { + if err := os.Chmod(directory, 0o700); err != nil { + t.Fatal(err) + } + } + expected := "sha256:" + strings.Repeat("7", 64) + result, err := Recover(context.Background(), root, expected, RecoveryRollback) + if err != nil || result.State != StateRecoveryRequired || result.TransactionID != "" { + t.Fatalf("Recover() result=%#v error=%v", result, err) + } +} + +func TestRejectedApplyPreservesPreviousTerminalReceipt(t *testing.T) { + root := t.TempDir() + first, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/first.json", Content: []byte("first\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), root, first); err != nil || result.State != StateApplied { + t.Fatalf("Apply(first)=%#v, %v", result, err) + } + mustWriteTestFile(t, root, "proofkit/second.json", "before\n", 0o644) + second, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/second.json", Content: []byte("after\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + mustWriteTestFile(t, root, "proofkit/second.json", "concurrent\n", 0o644) + if _, err := Apply(context.Background(), root, second); err == nil { + t.Fatal("Apply(second) admitted stale input") + } + replayed, err := Recover(context.Background(), root, first.TransactionID, RecoveryResume) + if err != nil || replayed.State != StateApplied { + t.Fatalf("Recover(first)=%#v, %v", replayed, err) + } +} diff --git a/internal/kernel/repositorytransaction/journal.go b/internal/kernel/repositorytransaction/journal.go new file mode 100644 index 0000000..4b5a2f9 --- /dev/null +++ b/internal/kernel/repositorytransaction/journal.go @@ -0,0 +1,131 @@ +package repositorytransaction + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const ( + journalPath = activeDirectory + "/journal.json" + journalTemp = activeDirectory + "/journal.tmp" + readyMarker = activeDirectory + "/ready" + committedMarker = activeDirectory + "/committed" + rolledBackMarker = activeDirectory + "/rolled-back" +) + +func prepareJournal(root *os.Root, plan Plan) error { + if err := ensureDirectory(root, activeDirectory, 0o700); err != nil { + return err + } + content, err := stablejson.Marshal(journalValue(plan)) + if err != nil { + return fmt.Errorf("encode repository transaction journal") + } + if len(content) > MaximumJournalBytes { + return fmt.Errorf("repository transaction journal exceeds the byte limit") + } + if err := writeOwnedFile(root, journalTemp, content, 0o600); err != nil { + return err + } + if err := root.Rename(filepath.FromSlash(journalTemp), filepath.FromSlash(journalPath)); err != nil { + return fmt.Errorf("publish repository transaction journal") + } + return syncDirectory(root, activeDirectory) +} + +func stageObjects(root *os.Root, plan Plan) error { + for index, operation := range plan.Operations { + if operation.Action == ActionUnchanged { + continue + } + if err := writeOwnedFile(root, afterObjectPath(index), operation.afterContent, 0o600); err != nil { + return err + } + if operation.Before.Exists { + if err := writeOwnedFile(root, beforeObjectPath(index), operation.beforeContent, 0o600); err != nil { + return err + } + } + } + return nil +} + +func loadJournal(root *os.Root) (Plan, error) { + content, err := readOwnedFile(root, journalPath, MaximumJournalBytes) + if err != nil { + return Plan{}, err + } + value, err := admission.DecodeJSON(bytes.NewReader(content), MaximumJournalBytes) + if err != nil { + return Plan{}, fmt.Errorf("admit repository transaction journal") + } + plan, err := admitJournal(value) + if err != nil { + return Plan{}, err + } + canonical, err := stablejson.Marshal(journalValue(plan)) + if err != nil || !bytes.Equal(content, canonical) { + return Plan{}, fmt.Errorf("repository transaction journal is not canonical") + } + return plan, nil +} + +func loadObjects(root *os.Root, plan Plan) (Plan, error) { + for index := range plan.Operations { + operation := &plan.Operations[index] + if operation.Action == ActionUnchanged { + continue + } + after, err := readOwnedFile(root, afterObjectPath(index), MaximumFileBytes) + if err != nil || !contentMatches(after, operation.After) { + return Plan{}, fmt.Errorf("repository transaction after object is invalid") + } + operation.afterContent = after + if operation.Before.Exists { + before, err := readOwnedFile(root, beforeObjectPath(index), MaximumFileBytes) + if err != nil || !contentMatches(before, operation.Before) { + return Plan{}, fmt.Errorf("repository transaction before object is invalid") + } + operation.beforeContent = before + } + } + return plan, nil +} + +func contentMatches(content []byte, snapshot Snapshot) bool { + return snapshot.Exists && int64(len(content)) == snapshot.ByteCount && digest.SHA256BytesRef(content) == snapshot.SHA256 +} + +func afterObjectPath(index int) string { + return fmt.Sprintf("%s/after-%03d.bin", activeDirectory, index) +} + +func beforeObjectPath(index int) string { + return fmt.Sprintf("%s/before-%03d.bin", activeDirectory, index) +} + +func markerExists(root *os.Root, marker string) (bool, error) { + exists, err := pathExists(root, marker) + if err != nil || !exists { + return exists, err + } + info, err := root.Lstat(filepath.FromSlash(marker)) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 || info.Size() != 0 { + return false, fmt.Errorf("repository transaction marker is invalid") + } + owned, err := platformOwnedByCurrentUser(info) + if err != nil || !owned { + return false, fmt.Errorf("repository transaction marker is not privately owned") + } + return true, nil +} + +func writeMarker(root *os.Root, marker string) error { + return writeOwnedFile(root, marker, nil, 0o600) +} diff --git a/internal/kernel/repositorytransaction/journal_admission.go b/internal/kernel/repositorytransaction/journal_admission.go new file mode 100644 index 0000000..69d2262 --- /dev/null +++ b/internal/kernel/repositorytransaction/journal_admission.go @@ -0,0 +1,212 @@ +package repositorytransaction + +import ( + "encoding/json" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +func journalValue(plan Plan) map[string]any { + operations := make([]any, 0, len(plan.Operations)) + for _, operation := range plan.Operations { + operations = append(operations, operationValue(operation)) + } + return map[string]any{ + "createdDirectories": admit.StringSliceToAny(plan.CreatedDirectories), + "desiredStateId": plan.DesiredStateID, + "journalKind": "proofkit.repository-write-journal", + "operations": operations, + "rootId": plan.RootID, + "schemaVersion": json.Number("1"), + "transactionId": plan.TransactionID, + } +} + +func admitJournal(raw any) (Plan, error) { + record, ok := raw.(map[string]any) + if !ok { + return Plan{}, fmt.Errorf("repository transaction journal must be an object") + } + if err := admit.KnownKeys(record, []string{"createdDirectories", "desiredStateId", "journalKind", "operations", "rootId", "schemaVersion", "transactionId"}, "repository transaction journal"); err != nil { + return Plan{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["journalKind"] != "proofkit.repository-write-journal" { + return Plan{}, fmt.Errorf("repository transaction journal identity is invalid") + } + rootID, err := admit.SHA256Ref(record["rootId"], "repository transaction rootId") + if err != nil { + return Plan{}, err + } + transactionID, err := admit.SHA256Ref(record["transactionId"], "repository transaction transactionId") + if err != nil { + return Plan{}, err + } + desiredStateID, err := admit.SHA256Ref(record["desiredStateId"], "repository transaction desiredStateId") + if err != nil { + return Plan{}, err + } + directories, err := admit.PreserveSortedPathArray(record["createdDirectories"], "repository transaction createdDirectories", true) + if err != nil { + return Plan{}, err + } + for _, directory := range directories { + if pathsOverlap(directory, ControlRoot) { + return Plan{}, fmt.Errorf("repository transaction created directory overlaps its control directory") + } + } + values, ok := record["operations"].([]any) + if !ok || len(values) == 0 || len(values) > MaximumOperations { + return Plan{}, fmt.Errorf("repository transaction journal operation count is invalid") + } + operations := make([]Operation, 0, len(values)) + previous := "" + for index, value := range values { + operation, err := admitOperation(value, index) + if err != nil { + return Plan{}, err + } + if previous != "" && previous >= operation.Path { + return Plan{}, fmt.Errorf("repository transaction operations must be sorted and unique") + } + previous = operation.Path + operations = append(operations, operation) + } + plan := Plan{CreatedDirectories: directories, DesiredStateID: desiredStateID, Operations: operations, RootID: rootID, TransactionID: transactionID} + if err := validatePlanShape(plan); err != nil { + return Plan{}, err + } + wantDesiredStateID, err := digest.StableJSONSHA256Ref(desiredStateIdentityValue(plan)) + if err != nil || wantDesiredStateID != desiredStateID { + return Plan{}, fmt.Errorf("repository transaction desired-state identity does not match its targets") + } + wantID, err := digest.StableJSONSHA256Ref(planIdentityValue(plan)) + if err != nil || wantID != transactionID { + return Plan{}, fmt.Errorf("repository transaction journal identity does not match its content") + } + return plan, nil +} + +func admitOperation(raw any, index int) (Operation, error) { + record, ok := raw.(map[string]any) + if !ok { + return Operation{}, fmt.Errorf("repository transaction operation %d must be an object", index) + } + if err := admit.KnownKeys(record, []string{"action", "after", "before", "path"}, fmt.Sprintf("repository transaction operation %d", index)); err != nil { + return Operation{}, err + } + targetPath, err := admit.SafeRepoRelativePath(recordText(record["path"]), fmt.Sprintf("repository transaction operation %d path", index)) + if err != nil { + return Operation{}, err + } + if pathsOverlap(targetPath, ControlRoot) { + return Operation{}, fmt.Errorf("repository transaction operation overlaps its control directory") + } + action, ok := record["action"].(string) + if !ok || (action != ActionCreate && action != ActionReplace && action != ActionUnchanged) { + return Operation{}, fmt.Errorf("repository transaction operation %d action is invalid", index) + } + before, err := admitSnapshot(record["before"], fmt.Sprintf("repository transaction operation %d before", index)) + if err != nil { + return Operation{}, err + } + after, err := admitSnapshot(record["after"], fmt.Sprintf("repository transaction operation %d after", index)) + if err != nil || !after.Exists { + return Operation{}, fmt.Errorf("repository transaction operation %d after snapshot is invalid", index) + } + wantAction := ActionCreate + if before.Exists { + wantAction = ActionReplace + if equalSnapshot(before, after) { + wantAction = ActionUnchanged + } + } + if action != wantAction { + return Operation{}, fmt.Errorf("repository transaction operation %d action contradicts its snapshots", index) + } + return Operation{Action: action, After: after, Before: before, Path: targetPath}, nil +} + +func admitSnapshot(raw any, context string) (Snapshot, error) { + record, ok := raw.(map[string]any) + if !ok { + return Snapshot{}, fmt.Errorf("%s must be an object", context) + } + if err := admit.KnownKeys(record, []string{"byteCount", "exists", "mode", "sha256"}, context); err != nil { + return Snapshot{}, err + } + exists, err := admit.Bool(record["exists"], context+" exists") + if err != nil { + return Snapshot{}, err + } + byteCount, err := admit.CanonicalInteger(record["byteCount"], context+" byteCount") + if err != nil || byteCount < 0 || byteCount > MaximumFileBytes { + return Snapshot{}, fmt.Errorf("%s byteCount is invalid", context) + } + mode, err := parseMode(record["mode"], context+" mode") + if err != nil { + return Snapshot{}, err + } + sha := "" + if record["sha256"] != nil { + sha, err = admit.SHA256Ref(record["sha256"], context+" sha256") + if err != nil { + return Snapshot{}, err + } + } + if exists && (sha == "" || mode == 0 || mode.Perm()&0o400 == 0) { + return Snapshot{}, fmt.Errorf("%s existing snapshot is incomplete", context) + } + if !exists && (sha != "" || mode != 0 || byteCount != 0) { + return Snapshot{}, fmt.Errorf("%s missing snapshot must have zero metadata", context) + } + return Snapshot{ByteCount: byteCount, Exists: exists, Mode: mode, SHA256: sha}, nil +} + +func validatePlanShape(plan Plan) error { + if len(plan.Operations) == 0 || len(plan.Operations) > MaximumOperations { + return fmt.Errorf("repository transaction operation count is invalid") + } + var aggregate int64 + paths := make([]string, 0, len(plan.Operations)) + for _, operation := range plan.Operations { + for _, existingPath := range paths { + if pathsOverlap(operation.Path, existingPath) { + return fmt.Errorf("repository transaction operation paths must be unique and non-overlapping") + } + } + if pathsOverlap(operation.Path, ControlRoot) { + return fmt.Errorf("repository transaction operation overlaps its control directory") + } + paths = append(paths, operation.Path) + aggregate += operation.Before.ByteCount + operation.After.ByteCount + if aggregate > MaximumAggregateBytes { + return fmt.Errorf("repository transaction exceeds the aggregate byte limit") + } + } + if err := validatePortablePathSet(paths); err != nil { + return fmt.Errorf("repository transaction paths have conflicting portable identities: %w", err) + } + for _, directory := range plan.CreatedDirectories { + if pathsOverlap(directory, ControlRoot) { + return fmt.Errorf("repository transaction created directory overlaps its control directory") + } + ownsTarget := false + for _, operation := range plan.Operations { + if pathWithin(operation.Path, directory) { + ownsTarget = true + break + } + } + if !ownsTarget { + return fmt.Errorf("repository transaction created directory does not own a target") + } + } + return nil +} + +func recordText(value any) string { + text, _ := value.(string) + return text +} diff --git a/internal/kernel/repositorytransaction/lock.go b/internal/kernel/repositorytransaction/lock.go new file mode 100644 index 0000000..17cd3f9 --- /dev/null +++ b/internal/kernel/repositorytransaction/lock.go @@ -0,0 +1,55 @@ +package repositorytransaction + +import ( + "fmt" + "os" + "path/filepath" +) + +func acquireTransactionLock(root *os.Root) (*transactionLock, error) { + if err := ensureDirectory(root, ControlDirectory, 0o700); err != nil { + return nil, err + } + return lockTransactionDirectory(root) +} + +func acquireExistingTransactionLock(root *os.Root) (*transactionLock, bool, error) { + exists, err := controlNamespaceExists(root) + if err != nil || !exists { + return nil, false, err + } + lock, err := lockTransactionDirectory(root) + return lock, true, err +} + +func lockTransactionDirectory(root *os.Root) (*transactionLock, error) { + if err := validatePrivateDirectory(root, ControlDirectory, 0o700); err != nil { + return nil, err + } + routeInfo, err := root.Lstat(filepath.FromSlash(ControlDirectory)) + if err != nil || routeInfo.Mode()&os.ModeSymlink != 0 || !routeInfo.IsDir() { + return nil, fmt.Errorf("repository transaction control directory is unsafe") + } + directory, err := root.Open(filepath.FromSlash(ControlDirectory)) + if err != nil { + return nil, fmt.Errorf("open repository transaction lock") + } + handleInfo, err := directory.Stat() + if err != nil || !os.SameFile(routeInfo, handleInfo) { + directory.Close() + return nil, fmt.Errorf("repository transaction control directory changed during lock admission") + } + if err := lockDirectory(directory); err != nil { + directory.Close() + return nil, err + } + return &transactionLock{directory: directory}, nil +} + +func (lock *transactionLock) release() { + if lock == nil || lock.directory == nil { + return + } + _ = unlockDirectory(lock.directory) + _ = lock.directory.Close() +} diff --git a/internal/kernel/repositorytransaction/mode_unix_test.go b/internal/kernel/repositorytransaction/mode_unix_test.go new file mode 100644 index 0000000..68bc8bb --- /dev/null +++ b/internal/kernel/repositorytransaction/mode_unix_test.go @@ -0,0 +1,39 @@ +//go:build darwin || linux + +package repositorytransaction + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCreatedDirectoryModesDoNotDependOnUmask(t *testing.T) { + previous := syscall.Umask(0o077) + defer syscall.Umask(previous) + + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "nested/specs/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), root, plan); err != nil || result.State != StateApplied { + t.Fatalf("Apply() result=%#v error=%v", result, err) + } + for _, item := range []struct { + path string + mode os.FileMode + }{ + {path: "nested", mode: 0o755}, + {path: "nested/specs", mode: 0o755}, + {path: ControlRoot, mode: 0o700}, + {path: ControlDirectory, mode: 0o700}, + } { + info, err := os.Stat(filepath.Join(root, filepath.FromSlash(item.path))) + if err != nil || info.Mode().Perm() != item.mode { + t.Fatalf("%s mode=%v error=%v, want %04o", item.path, infoMode(info), err, item.mode) + } + } +} diff --git a/internal/kernel/repositorytransaction/model.go b/internal/kernel/repositorytransaction/model.go new file mode 100644 index 0000000..02c61cc --- /dev/null +++ b/internal/kernel/repositorytransaction/model.go @@ -0,0 +1,167 @@ +package repositorytransaction + +import ( + "encoding/json" + "io/fs" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +const ( + ControlRoot = ".agentic-proofkit" + ControlDirectory = ControlRoot + "/transactions" + + MaximumOperations = 32 + MaximumFileBytes = 1 << 20 + MaximumAggregateBytes = 8 << 20 + MaximumJournalBytes = 256 << 10 +) + +const ( + ActionCreate = "create" + ActionReplace = "replace" + ActionUnchanged = "unchanged" +) + +const ( + StateApplied = "applied" + StateAlreadySatisfied = "already_satisfied" + StateCleanupRequired = "cleanup_required" + StateDurabilityUnknown = "durability_unknown" + StateRecoveryRequired = "recovery_required" + StateRolledBack = "rolled_back" +) + +const ( + RecoveryResume = "resume" + RecoveryRollback = "rollback" +) + +var boundaryNonClaims = []string{ + "Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.", + "Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.", + "Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.", +} + +type Target struct { + Content []byte + Mode fs.FileMode + Path string +} + +type Snapshot struct { + ByteCount int64 + Exists bool + Mode fs.FileMode + SHA256 string +} + +type Operation struct { + Action string + After Snapshot + Before Snapshot + Path string + afterContent []byte + beforeContent []byte +} + +type Plan struct { + CreatedDirectories []string + DesiredStateID string + Operations []Operation + RootID string + TransactionID string +} + +type Result struct { + AppliedCount int + AppliedCountKnown bool + FailureClass string + RecoveredBy string + State string + TransactionID string +} + +type pendingState struct { + Exists bool + TransactionID string +} + +// BeforeContent returns a defensive copy of the target bytes captured while +// building this in-memory plan. The bytes are intentionally absent from the +// public JSON projection. +func (plan Plan) BeforeContent(operationIndex int) ([]byte, bool) { + if operationIndex < 0 || operationIndex >= len(plan.Operations) || !plan.Operations[operationIndex].Before.Exists { + return nil, false + } + return append([]byte(nil), plan.Operations[operationIndex].beforeContent...), true +} + +func clonePlan(plan Plan) Plan { + clone := plan + clone.CreatedDirectories = append([]string(nil), plan.CreatedDirectories...) + clone.Operations = append([]Operation(nil), plan.Operations...) + for index := range clone.Operations { + clone.Operations[index].afterContent = append([]byte(nil), plan.Operations[index].afterContent...) + clone.Operations[index].beforeContent = append([]byte(nil), plan.Operations[index].beforeContent...) + } + return clone +} + +func (plan Plan) JSONValue() map[string]any { + operations := make([]any, 0, len(plan.Operations)) + for _, operation := range plan.Operations { + operations = append(operations, operationValue(operation)) + } + return map[string]any{ + "createdDirectories": admit.StringSliceToAny(plan.CreatedDirectories), + "desiredStateId": plan.DesiredStateID, + "nonClaims": admit.StringSliceToAny(boundaryNonClaims), + "operations": operations, + "rootId": plan.RootID, + "schemaVersion": json.Number("1"), + "transactionId": plan.TransactionID, + "transactionKind": "proofkit.repository-write-plan", + } +} + +func (result Result) JSONValue() map[string]any { + var appliedCount any + if result.AppliedCountKnown { + appliedCount = json.Number(intString(result.AppliedCount)) + } + return map[string]any{ + "appliedCount": appliedCount, + "failureClass": nullableText(result.FailureClass), + "nonClaims": admit.StringSliceToAny(boundaryNonClaims), + "recoveredBy": nullableText(result.RecoveredBy), + "schemaVersion": json.Number("1"), + "state": result.State, + "transactionId": nullableText(result.TransactionID), + } +} + +func operationValue(operation Operation) map[string]any { + return map[string]any{ + "action": operation.Action, + "after": snapshotValue(operation.After), + "before": snapshotValue(operation.Before), + "path": operation.Path, + } +} + +func snapshotValue(snapshot Snapshot) map[string]any { + return map[string]any{ + "byteCount": json.Number(int64String(snapshot.ByteCount)), + "exists": snapshot.Exists, + "mode": modeText(snapshot.Mode), + "sha256": nullableText(snapshot.SHA256), + } +} + +func nullableText(value string) any { + if value == "" { + return nil + } + return value +} diff --git a/internal/kernel/repositorytransaction/plan.go b/internal/kernel/repositorytransaction/plan.go new file mode 100644 index 0000000..8c76d45 --- /dev/null +++ b/internal/kernel/repositorytransaction/plan.go @@ -0,0 +1,217 @@ +package repositorytransaction + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "path" + "sort" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/pathidentity" +) + +var ErrRecoveryRequired = errors.New("repository transaction recovery is required") +var ErrBusy = errors.New("repository transaction is busy") + +type RecoveryRequiredError struct { + TransactionID string +} + +func (err *RecoveryRequiredError) Error() string { + return ErrRecoveryRequired.Error() +} + +func (err *RecoveryRequiredError) Unwrap() error { + return ErrRecoveryRequired +} + +func RecoveryTransactionID(err error) (string, bool) { + var pending *RecoveryRequiredError + if !errors.As(err, &pending) || pending.TransactionID == "" { + return "", false + } + return pending.TransactionID, true +} + +func BuildPlan(ctx context.Context, rootPath string, targets []Target) (Plan, error) { + if err := ctx.Err(); err != nil { + return Plan{}, fmt.Errorf("repository transaction planning interrupted: %w", err) + } + if len(targets) == 0 || len(targets) > MaximumOperations { + return Plan{}, fmt.Errorf("repository transaction target count must be between 1 and %d", MaximumOperations) + } + root, rootID, err := openRepository(rootPath) + if err != nil { + return Plan{}, err + } + defer root.Close() + if pending, err := pendingTransactionState(root); err != nil { + return Plan{}, err + } else if pending.Exists { + return Plan{}, &RecoveryRequiredError{TransactionID: pending.TransactionID} + } + + ordered := make([]Target, len(targets)) + for index, target := range targets { + ordered[index] = target + ordered[index].Content = append([]byte(nil), target.Content...) + } + sort.Slice(ordered, func(left, right int) bool { return ordered[left].Path < ordered[right].Path }) + plan := Plan{RootID: rootID} + directories := map[string]struct{}{} + prefixSpellings := map[string]string{} + var aggregate int64 + validatedPaths := make([]string, 0, len(ordered)) + for index, target := range ordered { + if err := ctx.Err(); err != nil { + return Plan{}, fmt.Errorf("repository transaction planning interrupted: %w", err) + } + targetPath, err := admit.SafeRepoRelativePath(target.Path, fmt.Sprintf("repository transaction target %d path", index)) + if err != nil { + return Plan{}, err + } + if err := registerPortablePrefixes(prefixSpellings, targetPath); err != nil { + return Plan{}, fmt.Errorf("repository transaction target %d path has an invalid portable identity: %w", index, err) + } + if pathsOverlap(targetPath, ControlRoot) { + return Plan{}, fmt.Errorf("repository transaction target must not overlap the transaction control directory") + } + for _, existingPath := range validatedPaths { + if pathsOverlap(targetPath, existingPath) { + return Plan{}, fmt.Errorf("repository transaction target paths must be unique and non-overlapping") + } + } + validatedPaths = append(validatedPaths, targetPath) + if target.Mode == 0 || target.Mode&^fs.ModePerm != 0 || target.Mode.Perm()&0o400 == 0 { + return Plan{}, fmt.Errorf("repository transaction target %d mode is invalid", index) + } + if len(target.Content) > MaximumFileBytes { + return Plan{}, fmt.Errorf("repository transaction target %d exceeds the file byte limit", index) + } + missing, err := inspectParentDirectories(root, path.Dir(targetPath)) + if err != nil { + return Plan{}, err + } + for _, directory := range missing { + directories[directory] = struct{}{} + } + before, beforeContent, err := inspectTarget(root, targetPath, MaximumFileBytes) + if err != nil { + return Plan{}, err + } + if before.Exists && before.Mode.Perm()&0o400 == 0 { + return Plan{}, fmt.Errorf("repository transaction target %d existing mode is not owner-readable", index) + } + after := snapshotForContent(target.Content, target.Mode) + aggregate += before.ByteCount + after.ByteCount + if aggregate > MaximumAggregateBytes { + return Plan{}, fmt.Errorf("repository transaction exceeds the aggregate byte limit") + } + action := ActionCreate + if before.Exists { + action = ActionReplace + if equalSnapshot(before, after) { + action = ActionUnchanged + } + } + plan.Operations = append(plan.Operations, Operation{ + Action: action, + After: after, + Before: before, + Path: targetPath, + afterContent: append([]byte(nil), target.Content...), + beforeContent: beforeContent, + }) + } + for directory := range directories { + plan.CreatedDirectories = append(plan.CreatedDirectories, directory) + } + sort.Strings(plan.CreatedDirectories) + desiredStateID, err := digest.StableJSONSHA256Ref(desiredStateIdentityValue(plan)) + if err != nil { + return Plan{}, fmt.Errorf("derive repository desired-state identity: %w", err) + } + plan.DesiredStateID = desiredStateID + identity := planIdentityValue(plan) + transactionID, err := digest.StableJSONSHA256Ref(identity) + if err != nil { + return Plan{}, fmt.Errorf("derive repository transaction identity: %w", err) + } + plan.TransactionID = transactionID + return plan, nil +} + +func registerPortablePrefixes(spellings map[string]string, value string) error { + prefixes, err := pathidentity.Prefixes(value) + if err != nil { + return err + } + for _, prefix := range prefixes { + if prior, exists := spellings[prefix.Key]; exists && prior != prefix.Path { + return fmt.Errorf("path prefix %q conflicts with portable spelling %q", prefix.Path, prior) + } + spellings[prefix.Key] = prefix.Path + } + return nil +} + +func validatePortablePathSet(paths []string) error { + spellings := map[string]string{} + for _, value := range paths { + if err := registerPortablePrefixes(spellings, value); err != nil { + return err + } + } + return nil +} + +func desiredStateIdentityValue(plan Plan) map[string]any { + targets := make([]any, 0, len(plan.Operations)) + for _, operation := range plan.Operations { + targets = append(targets, map[string]any{ + "after": snapshotValue(operation.After), + "path": operation.Path, + }) + } + return map[string]any{ + "desiredStateKind": "proofkit.repository-desired-state", + "rootId": plan.RootID, + "schemaVersion": json.Number("1"), + "targets": targets, + } +} + +func planIdentityValue(plan Plan) map[string]any { + value := plan.JSONValue() + delete(value, "nonClaims") + delete(value, "transactionId") + delete(value, "transactionKind") + return value +} + +func snapshotForContent(content []byte, mode fs.FileMode) Snapshot { + return Snapshot{ + ByteCount: int64(len(content)), + Exists: true, + Mode: mode.Perm(), + SHA256: digest.SHA256BytesRef(content), + } +} + +func equalSnapshot(left, right Snapshot) bool { + return left.Exists == right.Exists && left.ByteCount == right.ByteCount && left.Mode == right.Mode && left.SHA256 == right.SHA256 +} + +func pathWithin(candidate, directory string) bool { + within, err := pathidentity.Within(candidate, directory) + return err == nil && within +} + +func pathsOverlap(left, right string) bool { + overlaps, err := pathidentity.Overlaps(left, right) + return err != nil || overlaps +} diff --git a/internal/kernel/repositorytransaction/plan_test.go b/internal/kernel/repositorytransaction/plan_test.go new file mode 100644 index 0000000..977dec9 --- /dev/null +++ b/internal/kernel/repositorytransaction/plan_test.go @@ -0,0 +1,241 @@ +package repositorytransaction + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +func TestBuildPlanIsReadOnlyCanonicalAndContentBound(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "proofkit/existing.json", "before\n", 0o600) + mustWriteTestFile(t, root, "proofkit/same.json", "same\n", 0o644) + + plan, err := BuildPlan(context.Background(), root, []Target{ + {Path: "proofkit/new.json", Content: []byte("new\n"), Mode: 0o644}, + {Path: "proofkit/existing.json", Content: []byte("after\n"), Mode: 0o644}, + {Path: "proofkit/same.json", Content: []byte("same\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + if got := []string{plan.Operations[0].Path, plan.Operations[1].Path, plan.Operations[2].Path}; strings.Join(got, ",") != "proofkit/existing.json,proofkit/new.json,proofkit/same.json" { + t.Fatalf("operation order = %v", got) + } + if plan.Operations[0].Action != ActionReplace || plan.Operations[1].Action != ActionCreate || plan.Operations[2].Action != ActionUnchanged { + t.Fatalf("actions = %s, %s, %s", plan.Operations[0].Action, plan.Operations[1].Action, plan.Operations[2].Action) + } + if plan.TransactionID == "" || plan.RootID == "" { + t.Fatalf("plan identities are empty: %#v", plan) + } + before, ok := plan.BeforeContent(0) + if !ok || string(before) != "before\n" { + t.Fatalf("BeforeContent(0) = %q, %t", before, ok) + } + before[0] = 'X' + again, _ := plan.BeforeContent(0) + if string(again) != "before\n" { + t.Fatalf("BeforeContent exposed mutable plan bytes: %q", again) + } + if _, ok := plan.BeforeContent(1); ok { + t.Fatal("BeforeContent reported bytes for a missing target") + } + if _, err := os.Stat(filepath.Join(root, ".agentic-proofkit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read-only plan created control state: %v", err) + } + changed, err := BuildPlan(context.Background(), root, []Target{ + {Path: "proofkit/existing.json", Content: []byte("different\n"), Mode: 0o644}, + {Path: "proofkit/new.json", Content: []byte("new\n"), Mode: 0o644}, + {Path: "proofkit/same.json", Content: []byte("same\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatalf("BuildPlan(changed) error = %v", err) + } + if changed.TransactionID == plan.TransactionID { + t.Fatal("content change preserved transaction identity") + } +} + +func TestBuildPlanPreservesContextCause(t *testing.T) { + tests := []struct { + name string + ctx context.Context + want error + }{ + {name: "cancelled", ctx: cancelledContext(), want: context.Canceled}, + {name: "deadline", ctx: expiredContext(), want: context.DeadlineExceeded}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := BuildPlan(test.ctx, t.TempDir(), []Target{{Path: "proofkit/a.json", Content: []byte("a"), Mode: 0o644}}); !errors.Is(err, test.want) { + t.Fatalf("BuildPlan() error=%v, want %v", err, test.want) + } + }) + } +} + +func cancelledContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +} + +func expiredContext() context.Context { + ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0)) + cancel() + return ctx +} + +func TestBuildPlanRejectsUnsafeTargetsAndBounds(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil { + t.Fatal(err) + } + tests := []struct { + name string + targets []Target + }{ + {name: "duplicate", targets: []Target{{Path: "proofkit/a.json", Content: []byte("a"), Mode: 0o644}, {Path: "proofkit/a.json", Content: []byte("b"), Mode: 0o644}}}, + {name: "ancestor collision", targets: []Target{{Path: "a", Content: []byte("a"), Mode: 0o644}, {Path: "a/b", Content: []byte("b"), Mode: 0o644}}}, + {name: "control overlap", targets: []Target{{Path: ControlDirectory + "/payload", Content: []byte("a"), Mode: 0o644}}}, + {name: "control ancestor", targets: []Target{{Path: ".agentic-proofkit", Content: []byte("a"), Mode: 0o644}}}, + {name: "control sibling", targets: []Target{{Path: ".agentic-proofkit/config", Content: []byte("a"), Mode: 0o644}}}, + {name: "control case alias", targets: []Target{{Path: ".AGENTIC-PROOFKIT/transactions/payload", Content: []byte("a"), Mode: 0o644}}}, + {name: "case alias", targets: []Target{{Path: "proofkit/A.json", Content: []byte("a"), Mode: 0o644}, {Path: "proofkit/a.json", Content: []byte("b"), Mode: 0o644}}}, + {name: "case alias in parent prefixes", targets: []Target{{Path: "Proofkit/a.json", Content: []byte("a"), Mode: 0o644}, {Path: "proofkit/b.json", Content: []byte("b"), Mode: 0o644}}}, + {name: "unicode normalization alias", targets: []Target{{Path: "proofkit/caf\u00e9.json", Content: []byte("a"), Mode: 0o644}, {Path: "proofkit/cafe\u0301.json", Content: []byte("b"), Mode: 0o644}}}, + {name: "path byte limit", targets: []Target{{Path: strings.Repeat("a", 1025), Content: []byte("a"), Mode: 0o644}}}, + {name: "path component limit", targets: []Target{{Path: strings.Repeat("a/", 64) + "a", Content: []byte("a"), Mode: 0o644}}}, + {name: "parent symlink", targets: []Target{{Path: "linked/a.json", Content: []byte("a"), Mode: 0o644}}}, + {name: "oversize", targets: []Target{{Path: "proofkit/a.json", Content: make([]byte, MaximumFileBytes+1), Mode: 0o644}}}, + {name: "invalid mode", targets: []Target{{Path: "proofkit/a.json", Content: []byte("a"), Mode: 0}}}, + {name: "unreadable mode", targets: []Target{{Path: "proofkit/a.json", Content: []byte("a"), Mode: 0o200}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := BuildPlan(context.Background(), root, test.targets); err == nil { + t.Fatal("BuildPlan() admitted unsafe targets") + } + }) + } +} + +func TestApplyRejectsMutatedPlanBeforeControlMutation(t *testing.T) { + mutations := []struct { + name string + mutate func(*Plan) + }{ + {name: "unknown action", mutate: func(plan *Plan) { plan.Operations[0].Action = "unknown" }}, + {name: "unsafe path", mutate: func(plan *Plan) { plan.Operations[0].Path = "../outside" }}, + {name: "unowned directory", mutate: func(plan *Plan) { plan.CreatedDirectories = []string{"unrelated"} }}, + {name: "unreadable mode", mutate: func(plan *Plan) { plan.Operations[0].After.Mode = 0o200 }}, + {name: "setuid mode", mutate: func(plan *Plan) { plan.Operations[0].After.Mode |= fs.ModeSetuid }}, + {name: "setgid mode", mutate: func(plan *Plan) { plan.Operations[0].After.Mode |= fs.ModeSetgid }}, + {name: "sticky mode", mutate: func(plan *Plan) { plan.Operations[0].After.Mode |= fs.ModeSticky }}, + {name: "directory mode", mutate: func(plan *Plan) { plan.Operations[0].After.Mode |= fs.ModeDir }}, + } + for _, test := range mutations { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/a.json", Content: []byte("after\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + test.mutate(&plan) + plan.TransactionID, err = digest.StableJSONSHA256Ref(planIdentityValue(plan)) + if err != nil { + t.Fatal(err) + } + if _, err := Apply(context.Background(), root, plan); err == nil { + t.Fatal("Apply() admitted mutated plan") + } + if _, err := os.Stat(filepath.Join(root, ".agentic-proofkit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("mutated plan created control state: %v", err) + } + }) + } +} + +func TestApplyRejectsForgedCreatedDirectoryOwnership(t *testing.T) { + tests := []struct { + name string + target string + prepare func(string) + directories []string + }{ + { + name: "pre-existing parent", + target: "proofkit/a.json", + prepare: func(root string) { + if err := os.Mkdir(filepath.Join(root, "proofkit"), 0o755); err != nil { + t.Fatal(err) + } + }, + directories: []string{"proofkit"}, + }, + {name: "missing required descendant", target: "docs/specs/a.json", prepare: func(string) {}, directories: []string{"docs"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + test.prepare(root) + plan, err := BuildPlan(context.Background(), root, []Target{{Path: test.target, Content: []byte("after\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + plan.CreatedDirectories = test.directories + plan.TransactionID, err = digest.StableJSONSHA256Ref(planIdentityValue(plan)) + if err != nil { + t.Fatal(err) + } + if _, err := Apply(context.Background(), root, plan); err == nil { + t.Fatal("Apply() admitted forged created-directory ownership") + } + if _, err := os.Stat(filepath.Join(root, ".agentic-proofkit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("forged plan created control state: %v", err) + } + if test.name == "pre-existing parent" { + info, err := os.Stat(filepath.Join(root, "proofkit")) + if err != nil || !info.IsDir() { + t.Fatalf("pre-existing directory was removed: %v", err) + } + } + }) + } +} + +func TestBuildPlanRejectsFinalSymlink(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.json") + if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "proofkit"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "proofkit", "target.json")); err != nil { + t.Fatal(err) + } + if _, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/target.json", Content: []byte("after\n"), Mode: 0o644}}); err == nil { + t.Fatal("BuildPlan() admitted a symlink target") + } +} + +func mustWriteTestFile(t *testing.T, root, relative, content string, mode os.FileMode) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} diff --git a/internal/kernel/repositorytransaction/platform_other.go b/internal/kernel/repositorytransaction/platform_other.go new file mode 100644 index 0000000..093d0b3 --- /dev/null +++ b/internal/kernel/repositorytransaction/platform_other.go @@ -0,0 +1,28 @@ +//go:build !darwin && !linux + +package repositorytransaction + +import ( + "fmt" + "os" +) + +func platformFileIdentity(os.FileInfo) (string, error) { + return "", fmt.Errorf("repository transactions require darwin or linux") +} + +func platformOwnedByCurrentUser(os.FileInfo) (bool, error) { + return false, fmt.Errorf("repository transactions require darwin or linux") +} + +func openNoFollow(*os.Root, string) (*os.File, error) { + return nil, fmt.Errorf("repository transactions require darwin or linux") +} + +func lockDirectory(*os.File) error { + return fmt.Errorf("repository transactions require darwin or linux") +} + +func unlockDirectory(*os.File) error { + return fmt.Errorf("repository transactions require darwin or linux") +} diff --git a/internal/kernel/repositorytransaction/platform_unix.go b/internal/kernel/repositorytransaction/platform_unix.go new file mode 100644 index 0000000..09929a1 --- /dev/null +++ b/internal/kernel/repositorytransaction/platform_unix.go @@ -0,0 +1,49 @@ +//go:build darwin || linux + +package repositorytransaction + +import ( + "errors" + "fmt" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func platformFileIdentity(info os.FileInfo) (string, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", fmt.Errorf("repository filesystem identity is unavailable") + } + return fmt.Sprintf("%d:%d", uint64(stat.Dev), uint64(stat.Ino)), nil +} + +func platformOwnedByCurrentUser(info os.FileInfo) (bool, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("repository filesystem ownership is unavailable") + } + return stat.Uid == uint32(os.Geteuid()), nil +} + +func openNoFollow(root *os.Root, name string) (*os.File, error) { + return root.OpenFile(name, os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) +} + +func lockDirectory(file *os.File) error { + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + if errors.Is(err, unix.EWOULDBLOCK) { + return ErrBusy + } + return fmt.Errorf("lock repository transaction directory") + } + return nil +} + +func unlockDirectory(file *os.File) error { + if err := unix.Flock(int(file.Fd()), unix.LOCK_UN); err != nil { + return fmt.Errorf("unlock repository transaction directory") + } + return nil +} diff --git a/internal/kernel/repositorytransaction/recovery.go b/internal/kernel/repositorytransaction/recovery.go new file mode 100644 index 0000000..1bbd75c --- /dev/null +++ b/internal/kernel/repositorytransaction/recovery.go @@ -0,0 +1,251 @@ +package repositorytransaction + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +func (runtime engine) recover(ctx context.Context, rootPath, transactionID, action string) (Result, error) { + if action != RecoveryResume && action != RecoveryRollback { + return Result{}, fmt.Errorf("repository transaction recovery action must be resume or rollback") + } + admittedTransactionID, err := admit.SHA256Ref(transactionID, "repository transaction recovery transactionId") + if err != nil { + return Result{}, err + } + transactionID = admittedTransactionID + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + root, rootID, err := openRepository(rootPath) + if err != nil { + return Result{}, err + } + defer root.Close() + lock, exists, err := acquireExistingTransactionLock(root) + if err != nil { + return Result{}, err + } + if !exists { + return Result{}, fmt.Errorf("repository transaction recovery state is absent") + } + defer lock.release() + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + entries, err := controlEntries(root) + if err != nil { + return Result{}, err + } + active := false + for _, entry := range entries { + if entry.Name() == "active" && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + active = true + } + } + if !active { + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + if result, handled, err := runtime.recoverTerminalTombstone(root, entries, transactionID, action); handled || err != nil { + return result, err + } + if len(entries) > 0 { + return Result{FailureClass: "conflicting_control_state", State: StateRecoveryRequired}, nil + } + return Result{}, fmt.Errorf("repository transaction recovery state is absent") + } + if len(entries) != 1 { + return Result{FailureClass: "conflicting_control_state", State: StateRecoveryRequired}, nil + } + if err := validatePrivateDirectory(root, activeDirectory, 0o700); err != nil { + return Result{FailureClass: "invalid_control_state", State: StateRecoveryRequired}, nil + } + plan, err := loadJournal(root) + if err != nil { + observedTransactionID, identityKnown, discardable, inspectErr := incompleteJournalCanBeDiscarded(root) + if inspectErr != nil || !discardable { + return Result{FailureClass: "invalid_journal", State: StateRecoveryRequired, TransactionID: observedTransactionID}, nil + } + if identityKnown && observedTransactionID != transactionID { + return Result{}, fmt.Errorf("repository transaction recovery identity does not match preparing state") + } + if action != RecoveryRollback { + return Result{FailureClass: "preparing_state_mismatch", State: StateRecoveryRequired, TransactionID: observedTransactionID}, nil + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + if cleanupErr := cleanupActive(root, nil); cleanupErr != nil { + if errors.Is(cleanupErr, errCleanupDurabilityUnknown) { + return Result{AppliedCountKnown: true, FailureClass: "preparing_cleanup_durability_unknown", RecoveredBy: RecoveryRollback, State: StateDurabilityUnknown}, nil + } + return Result{FailureClass: "cleanup_failed", RecoveredBy: RecoveryRollback, State: StateCleanupRequired, TransactionID: observedTransactionID}, nil + } + return Result{AppliedCountKnown: true, RecoveredBy: RecoveryRollback, State: StateRolledBack, TransactionID: observedTransactionID}, nil + } + if plan.TransactionID != transactionID || plan.RootID != rootID { + return Result{}, fmt.Errorf("repository transaction recovery identity does not match active state") + } + if err := validateActiveState(root, plan); err != nil { + return Result{FailureClass: "invalid_control_state", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + committed, err := markerExists(root, committedMarker) + if err != nil { + return Result{}, err + } + rolledBack, err := markerExists(root, rolledBackMarker) + if err != nil { + return Result{}, err + } + if committed && rolledBack { + return Result{FailureClass: "conflicting_terminal_markers", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if committed { + plan, err = loadObjects(root, plan) + if err != nil { + return Result{FailureClass: "invalid_staged_objects", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if action != RecoveryResume || verifyTargetVector(root, plan, changedCount(plan)) != nil { + return Result{FailureClass: "committed_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + return runtime.cleanupRecovered(root, plan, StateApplied, action) + } + if rolledBack { + plan, err = loadObjects(root, plan) + if err != nil { + return Result{FailureClass: "invalid_staged_objects", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if action != RecoveryRollback || verifyTargetVector(root, plan, 0) != nil { + return Result{FailureClass: "rolled_back_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + if removeCreatedDirectories(root, plan) != nil { + return Result{FailureClass: "rolled_back_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + return runtime.cleanupRecovered(root, plan, StateRolledBack, RecoveryRollback) + } + ready, err := markerExists(root, readyMarker) + if err != nil { + return Result{}, err + } + if !ready { + if action != RecoveryRollback { + return Result{FailureClass: "preparing_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if verifyTargetVector(root, plan, 0) != nil { + return Result{FailureClass: "preparing_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + if err := removeCreatedDirectories(root, plan); err != nil { + return Result{FailureClass: "directory_cleanup_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if err := cleanupActive(root, &plan); err != nil { + if errors.Is(err, errCleanupDurabilityUnknown) { + return Result{AppliedCountKnown: true, FailureClass: "rolled_back_cleanup_durability_unknown", RecoveredBy: RecoveryRollback, State: StateDurabilityUnknown, TransactionID: transactionID}, nil + } + return Result{AppliedCountKnown: true, FailureClass: "cleanup_failed", RecoveredBy: RecoveryRollback, State: StateCleanupRequired, TransactionID: transactionID}, nil + } + return Result{AppliedCountKnown: true, RecoveredBy: RecoveryRollback, State: StateRolledBack, TransactionID: transactionID}, nil + } + plan, err = loadObjects(root, plan) + if err != nil { + return Result{FailureClass: "invalid_staged_objects", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + prefix, err := classifyPrefix(root, plan) + if err != nil { + return Result{FailureClass: "ambiguous_target_state", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if action == RecoveryResume { + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + if err := runtime.applyForward(context.WithoutCancel(ctx), root, plan, prefix); err != nil { + return resultWithObservedPrefix(root, plan, Result{FailureClass: "resume_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}), nil + } + if err := writeMarker(root, committedMarker); err != nil { + return Result{AppliedCount: changedCount(plan), AppliedCountKnown: true, FailureClass: "terminal_marker_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + return runtime.cleanupRecovered(root, plan, StateApplied, action) + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + } + if err := runtime.rollbackPrefix(context.WithoutCancel(ctx), root, plan, prefix); err != nil { + return resultWithObservedPrefix(root, plan, Result{FailureClass: "rollback_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}), nil + } + if err := removeInterruptedTemporary(root, plan); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "temporary_cleanup_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if err := removeCreatedDirectories(root, plan); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "directory_cleanup_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if err := writeMarker(root, rolledBackMarker); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "terminal_marker_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + return runtime.cleanupRecovered(root, plan, StateRolledBack, action) +} + +func (runtime engine) cleanupRecovered(root *os.Root, plan Plan, state, action string) (Result, error) { + if err := removeInterruptedTemporary(root, plan); err != nil { + return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, FailureClass: "temporary_cleanup_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := runtime.archiveAndCleanupTerminal(root, plan, state); err != nil { + if errors.Is(err, errCleanupDurabilityUnknown) { + return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, FailureClass: state + "_cleanup_durability_unknown", RecoveredBy: action, State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, FailureClass: "cleanup_failed", RecoveredBy: action, State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, RecoveredBy: action, State: state, TransactionID: plan.TransactionID}, nil +} + +func (runtime engine) recoverTerminalTombstone(root *os.Root, entries []fs.DirEntry, transactionID, action string) (Result, bool, error) { + if len(entries) != 1 { + return Result{}, false, nil + } + entry := entries[0] + if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return Result{}, false, nil + } + appliedPath := terminalTombstonePath(transactionID, StateApplied) + rolledBackPath := terminalTombstonePath(transactionID, StateRolledBack) + path := ControlDirectory + "/" + entry.Name() + state := "" + switch path { + case appliedPath: + if action != RecoveryResume { + return Result{FailureClass: "committed_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, true, nil + } + state = StateApplied + case rolledBackPath: + if action != RecoveryRollback { + return Result{FailureClass: "rolled_back_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, true, nil + } + state = StateRolledBack + default: + return Result{}, false, nil + } + receipt, err := loadTerminalReceipt(root, path) + if err != nil || receipt.TransactionID != transactionID || receipt.State != state { + return Result{FailureClass: "invalid_terminal_receipt", State: StateRecoveryRequired, TransactionID: transactionID}, true, nil + } + if err := runtime.cleanupTerminalTombstone(root, path); err != nil { + if errors.Is(err, errCleanupDurabilityUnknown) { + return Result{FailureClass: state + "_cleanup_durability_unknown", RecoveredBy: action, State: StateDurabilityUnknown, TransactionID: transactionID}, true, nil + } + return Result{FailureClass: "cleanup_failed", RecoveredBy: action, State: StateCleanupRequired, TransactionID: transactionID}, true, nil + } + return Result{AppliedCount: receipt.AppliedCount, AppliedCountKnown: true, RecoveredBy: action, State: state, TransactionID: transactionID}, true, nil +} diff --git a/internal/kernel/repositorytransaction/state.go b/internal/kernel/repositorytransaction/state.go new file mode 100644 index 0000000..634a3cd --- /dev/null +++ b/internal/kernel/repositorytransaction/state.go @@ -0,0 +1,126 @@ +package repositorytransaction + +import ( + "fmt" + "os" + "path" + "slices" + "sort" +) + +func resultWithObservedPrefix(root *os.Root, plan Plan, result Result) Result { + prefix, err := classifyPrefix(root, plan) + if err == nil { + result.AppliedCount = prefix + result.AppliedCountKnown = true + } + return result +} + +func prefixForState(plan Plan, state string) int { + if state == StateApplied { + return changedCount(plan) + } + return 0 +} + +func classifyPrefix(root *os.Root, plan Plan) (int, error) { + prefix := 0 + seenBefore := false + for _, operation := range plan.Operations { + if operation.Action == ActionUnchanged { + observed, _, err := inspectTarget(root, operation.Path, MaximumFileBytes) + if err != nil || !equalSnapshot(observed, operation.After) { + return 0, fmt.Errorf("repository transaction unchanged target state changed") + } + continue + } + observed, _, err := inspectTarget(root, operation.Path, MaximumFileBytes) + if err != nil { + return 0, err + } + switch { + case equalSnapshot(observed, operation.After): + if seenBefore { + return 0, fmt.Errorf("repository transaction target vector is not a legal prefix") + } + prefix++ + case equalSnapshot(observed, operation.Before): + seenBefore = true + default: + return 0, fmt.Errorf("repository transaction target state is unknown") + } + } + return prefix, nil +} + +func verifyTargetVector(root *os.Root, plan Plan, expectedPrefix int) error { + prefix, err := classifyPrefix(root, plan) + if err != nil || prefix != expectedPrefix { + return fmt.Errorf("repository transaction target snapshot changed") + } + return nil +} + +func validateExecutablePlan(plan Plan, rootID string) error { + if plan.RootID != rootID || plan.DesiredStateID == "" || plan.TransactionID == "" || len(plan.Operations) == 0 || len(plan.Operations) > MaximumOperations { + return fmt.Errorf("repository transaction plan is not executable for this root") + } + if _, err := admitJournal(journalValue(plan)); err != nil { + return fmt.Errorf("repository transaction plan semantic admission failed") + } + for index, operation := range plan.Operations { + if operation.After.Mode != operation.After.Mode.Perm() || operation.Before.Mode != operation.Before.Mode.Perm() { + return fmt.Errorf("repository transaction plan mode contains non-permission bits") + } + if !contentMatches(operation.afterContent, operation.After) { + return fmt.Errorf("repository transaction plan after content is invalid") + } + if operation.Before.Exists && !contentMatches(operation.beforeContent, operation.Before) { + return fmt.Errorf("repository transaction plan before content is invalid") + } + if !operation.Before.Exists && len(operation.beforeContent) != 0 { + return fmt.Errorf("repository transaction plan before content is invalid") + } + if operation.Path == "" || transactionTemporaryPath(plan.TransactionID, index, operation.Path) == "" { + return fmt.Errorf("repository transaction plan path is invalid") + } + } + return nil +} + +func verifyCreatedDirectories(root *os.Root, plan Plan) error { + directorySet := map[string]struct{}{} + for _, operation := range plan.Operations { + missing, err := inspectParentDirectories(root, path.Dir(operation.Path)) + if err != nil { + return err + } + for _, directory := range missing { + directorySet[directory] = struct{}{} + } + } + want := make([]string, 0, len(directorySet)) + for directory := range directorySet { + want = append(want, directory) + } + sort.Strings(want) + if !slices.Equal(plan.CreatedDirectories, want) { + return fmt.Errorf("repository transaction created directories do not match the target snapshot") + } + return nil +} + +func changedOperationIndexes(plan Plan) []int { + indexes := []int{} + for index, operation := range plan.Operations { + if operation.Action != ActionUnchanged { + indexes = append(indexes, index) + } + } + return indexes +} + +func changedCount(plan Plan) int { + return len(changedOperationIndexes(plan)) +} diff --git a/internal/kernel/repositorytransaction/state_machine_test.go b/internal/kernel/repositorytransaction/state_machine_test.go new file mode 100644 index 0000000..28b97b4 --- /dev/null +++ b/internal/kernel/repositorytransaction/state_machine_test.go @@ -0,0 +1,179 @@ +package repositorytransaction + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDesiredStateIdentityIsIndependentOfBeforeSnapshot(t *testing.T) { + root := t.TempDir() + targets := []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}} + mustWriteTestFile(t, root, "proofkit/state.json", "before-one\n", 0o644) + first, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + mustWriteTestFile(t, root, "proofkit/state.json", "before-two\n", 0o644) + second, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + if first.DesiredStateID == "" || first.DesiredStateID != second.DesiredStateID { + t.Fatalf("desired-state identities differ: %q != %q", first.DesiredStateID, second.DesiredStateID) + } + if first.TransactionID == second.TransactionID { + t.Fatal("different before snapshots produced one transaction identity") + } + changed, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("other\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if changed.DesiredStateID == first.DesiredStateID { + t.Fatal("changed final bytes preserved desired-state identity") + } + if first.JSONValue()["desiredStateId"] != first.DesiredStateID { + t.Fatalf("plan projection omitted desired-state identity: %#v", first.JSONValue()) + } +} + +func TestApplyAcceptsOriginalPlanAfterLostAcknowledgement(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), root, plan); err != nil || result.State != StateApplied { + t.Fatalf("first Apply() result=%#v error=%v", result, err) + } + result, err := Apply(context.Background(), root, plan) + if err != nil || result.State != StateAlreadySatisfied || result.TransactionID != plan.TransactionID { + t.Fatalf("retry Apply() result=%#v error=%v", result, err) + } +} + +func TestApplyCancellationRespectsMutationBoundary(t *testing.T) { + t.Run("before control state", func(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := Apply(ctx, root, plan); !errors.Is(err, context.Canceled) { + t.Fatalf("Apply() error=%v, want context cancellation", err) + } + if _, err := os.Stat(filepath.Join(root, ControlRoot)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cancelled apply created control state: %v", err) + } + }) + + t.Run("after ready marker", func(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "proofkit/state.json", "before\n", 0o644) + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + runtime := engine{fault: func(point failurePoint, _ int) error { + if point == faultAfterReady { + cancel() + } + return nil + }} + result, err := runtime.apply(ctx, root, plan) + if err != nil || result.State != StateRolledBack || result.FailureClass != "cancelled" { + t.Fatalf("cancelled apply result=%#v error=%v", result, err) + } + assertTestFile(t, root, "proofkit/state.json", "before\n", 0o644) + assertNoPendingTransaction(t, root) + }) +} + +func TestRecoverCancellationDoesNotCreateOrRemoveState(t *testing.T) { + t.Run("absent", func(t *testing.T) { + root := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + transactionID := "sha256:" + strings.Repeat("1", 64) + if _, err := Recover(ctx, root, transactionID, RecoveryRollback); !errors.Is(err, context.Canceled) { + t.Fatalf("Recover() error=%v, want context cancellation", err) + } + if _, err := os.Stat(filepath.Join(root, ControlRoot)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cancelled recovery created control state: %v", err) + } + }) + + t.Run("pending", func(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, root, plan, 0) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := Recover(ctx, root, plan.TransactionID, RecoveryRollback); !errors.Is(err, context.Canceled) { + t.Fatalf("Recover() error=%v, want context cancellation", err) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(activeDirectory))); err != nil { + t.Fatalf("cancelled recovery removed active state: %v", err) + } + }) +} + +func TestPendingErrorCarriesOnlyObservedIdentity(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, root, plan, 0) + _, err = BuildPlan(context.Background(), root, []Target{{Path: "proofkit/other.json", Content: []byte("other\n"), Mode: 0o644}}) + if !errors.Is(err, ErrRecoveryRequired) { + t.Fatalf("BuildPlan() error=%v, want recovery required", err) + } + transactionID, ok := RecoveryTransactionID(err) + if !ok || transactionID != plan.TransactionID { + t.Fatalf("RecoveryTransactionID()=%q,%t, want %q,true", transactionID, ok, plan.TransactionID) + } +} + +func TestCleanupDurabilityFailureDoesNotClaimRecoverableState(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, _ int) error { + if point == faultAfterStateRemoval { + return errors.New("injected") + } + return nil + }} + result, err := runtime.apply(context.Background(), root, plan) + if err != nil || result.State != StateDurabilityUnknown { + t.Fatalf("apply() result=%#v error=%v", result, err) + } + assertTestFile(t, root, "proofkit/state.json", "desired\n", 0o644) + assertNoPendingTransaction(t, root) + replayed, err := Recover(context.Background(), root, plan.TransactionID, RecoveryResume) + if err != nil || replayed.State != StateApplied || replayed.RecoveredBy != RecoveryResume { + t.Fatalf("Recover() replay=%#v error=%v", replayed, err) + } +} + +func TestControlNamespaceRequiresPrivateMode(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ControlRoot), 0o755); err != nil { + t.Fatal(err) + } + if _, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}); err == nil { + t.Fatal("BuildPlan() admitted a non-private control namespace") + } +} diff --git a/internal/kernel/repositorytransaction/terminal_receipt.go b/internal/kernel/repositorytransaction/terminal_receipt.go new file mode 100644 index 0000000..7221fb2 --- /dev/null +++ b/internal/kernel/repositorytransaction/terminal_receipt.go @@ -0,0 +1,101 @@ +package repositorytransaction + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const ( + maximumTerminalReceiptBytes = 2048 + terminalReceiptName = "terminal.json" +) + +type terminalReceipt struct { + AppliedCount int + State string + TransactionID string +} + +func ensureTerminalReceipt(root *os.Root, plan Plan, state string) error { + want := terminalReceipt{AppliedCount: prefixForState(plan, state), State: state, TransactionID: plan.TransactionID} + path := activeDirectory + "/" + terminalReceiptName + if exists, err := pathExists(root, path); err != nil { + return err + } else if exists { + got, err := loadTerminalReceipt(root, activeDirectory) + if err != nil || got != want { + return fmt.Errorf("repository transaction terminal receipt contradicts terminal state") + } + return nil + } + content, err := stablejson.Marshal(terminalReceiptValue(want)) + if err != nil || len(content) > maximumTerminalReceiptBytes { + return fmt.Errorf("encode repository transaction terminal receipt") + } + return writeOwnedFile(root, path, content, 0o600) +} + +func loadTerminalReceipt(root *os.Root, directory string) (terminalReceipt, error) { + content, err := readOwnedFile(root, directory+"/"+terminalReceiptName, maximumTerminalReceiptBytes) + if err != nil { + return terminalReceipt{}, err + } + raw, err := admission.DecodeJSON(bytes.NewReader(content), maximumTerminalReceiptBytes) + if err != nil { + return terminalReceipt{}, fmt.Errorf("admit repository transaction terminal receipt") + } + receipt, err := admitTerminalReceipt(raw) + if err != nil { + return terminalReceipt{}, err + } + canonical, err := stablejson.Marshal(terminalReceiptValue(receipt)) + if err != nil || !bytes.Equal(content, canonical) { + return terminalReceipt{}, fmt.Errorf("repository transaction terminal receipt is not canonical") + } + return receipt, nil +} + +func admitTerminalReceipt(raw any) (terminalReceipt, error) { + record, ok := raw.(map[string]any) + if !ok { + return terminalReceipt{}, fmt.Errorf("repository transaction terminal receipt must be an object") + } + if err := admit.KnownKeys(record, []string{"appliedCount", "schemaVersion", "state", "terminalKind", "transactionId"}, "repository transaction terminal receipt"); err != nil { + return terminalReceipt{}, err + } + if record["terminalKind"] != "proofkit.repository-terminal-receipt" || !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return terminalReceipt{}, fmt.Errorf("repository transaction terminal receipt identity is invalid") + } + appliedCount, err := admit.CanonicalInteger(record["appliedCount"], "repository transaction terminal receipt appliedCount") + if err != nil || appliedCount < 0 || appliedCount > MaximumOperations { + return terminalReceipt{}, fmt.Errorf("repository transaction terminal receipt appliedCount is invalid") + } + state, err := admit.Enum(record["state"], map[string]struct{}{StateApplied: {}, StateRolledBack: {}}, "repository transaction terminal receipt state") + if err != nil { + return terminalReceipt{}, err + } + if state == StateRolledBack && appliedCount != 0 { + return terminalReceipt{}, fmt.Errorf("rolled-back repository transaction terminal receipt must have zero appliedCount") + } + transactionID, err := admit.SHA256Ref(record["transactionId"], "repository transaction terminal receipt transactionId") + if err != nil { + return terminalReceipt{}, err + } + return terminalReceipt{AppliedCount: int(appliedCount), State: state, TransactionID: transactionID}, nil +} + +func terminalReceiptValue(receipt terminalReceipt) map[string]any { + return map[string]any{ + "appliedCount": json.Number(intString(receipt.AppliedCount)), + "schemaVersion": json.Number("1"), + "state": receipt.State, + "terminalKind": "proofkit.repository-terminal-receipt", + "transactionId": receipt.TransactionID, + } +} diff --git a/internal/kernel/repositorytransaction/transaction.go b/internal/kernel/repositorytransaction/transaction.go new file mode 100644 index 0000000..a8afaa7 --- /dev/null +++ b/internal/kernel/repositorytransaction/transaction.go @@ -0,0 +1,167 @@ +package repositorytransaction + +import ( + "context" + "errors" + "fmt" + "os" +) + +type failurePoint string + +const ( + faultAfterJournal failurePoint = "after_journal" + faultAfterStaging failurePoint = "after_staging" + faultAfterReady failurePoint = "after_ready" + faultAfterDirectory failurePoint = "after_directory" + faultBeforePublish failurePoint = "before_publish" + faultAfterPublish failurePoint = "after_publish" + faultAfterTerminal failurePoint = "after_terminal" + faultAfterStateRemoval failurePoint = "after_state_removal" + faultBeforeCleanup failurePoint = "before_cleanup" +) + +type engine struct { + fault func(failurePoint, int) error +} + +type transactionLock struct { + directory *os.File +} + +func Apply(ctx context.Context, rootPath string, plan Plan) (Result, error) { + return engine{}.apply(ctx, rootPath, plan) +} + +func Recover(ctx context.Context, rootPath, transactionID, action string) (Result, error) { + return engine{}.recover(ctx, rootPath, transactionID, action) +} + +func (runtime engine) apply(ctx context.Context, rootPath string, plan Plan) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction apply cancelled: %w", err) + } + plan = clonePlan(plan) + root, rootID, err := openRepository(rootPath) + if err != nil { + return Result{}, err + } + defer root.Close() + if pending, err := pendingTransactionState(root); err != nil { + return Result{}, err + } else if pending.Exists { + return Result{}, &RecoveryRequiredError{TransactionID: pending.TransactionID} + } + if err := validateExecutablePlan(plan, rootID); err != nil { + return Result{}, err + } + prefix, err := classifyPrefix(root, plan) + if err != nil { + return Result{}, fmt.Errorf("repository transaction target snapshot changed") + } + changed := changedCount(plan) + if prefix == changed { + return Result{AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: plan.TransactionID}, nil + } + if prefix != 0 { + return Result{}, fmt.Errorf("repository transaction target snapshot is a partial prefix without recovery state") + } + if err := verifyCreatedDirectories(root, plan); err != nil { + return Result{}, err + } + if changed == 0 { + return Result{AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: plan.TransactionID}, nil + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction apply cancelled: %w", err) + } + lock, err := acquireTransactionLock(root) + if err != nil { + return Result{}, err + } + defer lock.release() + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction apply cancelled: %w", err) + } + if pending, err := pendingTransactionState(root); err != nil { + return Result{}, err + } else if pending.Exists { + return Result{}, &RecoveryRequiredError{TransactionID: pending.TransactionID} + } + prefix, err = classifyPrefix(root, plan) + if err != nil { + return Result{}, fmt.Errorf("repository transaction target snapshot changed") + } + if prefix == changed { + return Result{AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: plan.TransactionID}, nil + } + if prefix != 0 { + return Result{}, fmt.Errorf("repository transaction target snapshot is a partial prefix without recovery state") + } + if err := verifyCreatedDirectories(root, plan); err != nil { + return Result{}, err + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction apply cancelled: %w", err) + } + if err := discardTerminalReceipt(root); err != nil { + return Result{}, err + } + if err := prepareJournal(root, plan); err != nil { + return runtime.finishPreparingFailure(root, plan, "journal_prepare_failed") + } + if err := ctx.Err(); err != nil { + return runtime.finishPreparingFailure(root, plan, "cancelled") + } + if err := runtime.callFault(faultAfterJournal, -1); err != nil { + return runtime.finishPreparingFailure(root, plan, "injected_prepare_failure") + } + if err := stageObjects(root, plan); err != nil { + return runtime.finishPreparingFailure(root, plan, "object_staging_failed") + } + if err := ctx.Err(); err != nil { + return runtime.finishPreparingFailure(root, plan, "cancelled") + } + if err := runtime.callFault(faultAfterStaging, -1); err != nil { + return runtime.finishPreparingFailure(root, plan, "injected_prepare_failure") + } + if err := verifyTargetVector(root, plan, 0); err != nil { + return runtime.finishPreparingFailure(root, plan, "target_changed_before_ready") + } + if err := writeMarker(root, readyMarker); err != nil { + return runtime.finishPreparingFailure(root, plan, "ready_marker_failed") + } + if err := runtime.callFault(faultAfterReady, -1); err != nil { + return runtime.rollbackAfterFailure(context.WithoutCancel(ctx), root, plan, "injected_apply_failure") + } + if err := runtime.applyForward(ctx, root, plan, 0); err != nil { + failureClass := "publication_failed" + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + failureClass = "cancelled" + } + return runtime.rollbackAfterFailure(context.WithoutCancel(ctx), root, plan, failureClass) + } + if err := writeMarker(root, committedMarker); err != nil { + return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "terminal_marker_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := runtime.callFault(faultAfterTerminal, -1); err != nil { + return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "injected_cleanup_failure", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + if err := runtime.callFault(faultBeforeCleanup, -1); err != nil { + return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "injected_cleanup_failure", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + if err := runtime.archiveAndCleanupTerminal(root, plan, StateApplied); err != nil { + if errors.Is(err, errCleanupDurabilityUnknown) { + return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "applied_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + return Result{AppliedCount: changed, AppliedCountKnown: true, State: StateApplied, TransactionID: plan.TransactionID}, nil +} + +func (runtime engine) callFault(point failurePoint, index int) error { + if runtime.fault == nil { + return nil + } + return runtime.fault(point, index) +} diff --git a/internal/kernel/repositorytransaction/transaction_test.go b/internal/kernel/repositorytransaction/transaction_test.go new file mode 100644 index 0000000..3ead351 --- /dev/null +++ b/internal/kernel/repositorytransaction/transaction_test.go @@ -0,0 +1,597 @@ +package repositorytransaction + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func TestApplyCommitsAndRepeatedPlanIsAlreadySatisfied(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "proofkit/existing.json", "before\n", 0o600) + targets := []Target{ + {Path: "docs/specs/core/requirements.v1.json", Content: []byte("requirements\n"), Mode: 0o644}, + {Path: "proofkit/existing.json", Content: []byte("after\n"), Mode: 0o644}, + } + plan, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + result, err := Apply(context.Background(), root, plan) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + if result.State != StateApplied || result.AppliedCount != 2 { + t.Fatalf("Apply() result = %#v", result) + } + assertTestFile(t, root, "proofkit/existing.json", "after\n", 0o644) + assertTestFile(t, root, "docs/specs/core/requirements.v1.json", "requirements\n", 0o644) + assertNoActiveTransaction(t, root) + + repeated, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + result, err = Apply(context.Background(), root, repeated) + if err != nil || result.State != StateAlreadySatisfied || result.AppliedCount != 0 { + t.Fatalf("repeated Apply() = %#v, %v", result, err) + } +} + +func TestApplyRejectsStalePlanBeforeAnyMutation(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, root, "proofkit/b.json", "before-b\n", 0o644) + plan, err := BuildPlan(context.Background(), root, []Target{ + {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + mustWriteTestFile(t, root, "proofkit/b.json", "concurrent\n", 0o644) + if _, err := Apply(context.Background(), root, plan); err == nil { + t.Fatal("Apply() admitted stale plan") + } + assertTestFile(t, root, "proofkit/a.json", "before-a\n", 0o644) + assertTestFile(t, root, "proofkit/b.json", "concurrent\n", 0o644) + assertNoActiveTransaction(t, root) +} + +func TestApplyRejectsStaleUnchangedTargetsBeforeControlMutation(t *testing.T) { + for _, mixed := range []bool{false, true} { + t.Run(fmt.Sprintf("mixed=%t", mixed), func(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "proofkit/unchanged.json", "same\n", 0o644) + targets := []Target{{Path: "proofkit/unchanged.json", Content: []byte("same\n"), Mode: 0o644}} + if mixed { + mustWriteTestFile(t, root, "proofkit/changed.json", "before\n", 0o644) + targets = append(targets, Target{Path: "proofkit/changed.json", Content: []byte("after\n"), Mode: 0o644}) + } + plan, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + mustWriteTestFile(t, root, "proofkit/unchanged.json", "stale\n", 0o644) + if _, err := Apply(context.Background(), root, plan); err == nil { + t.Fatal("Apply() admitted a stale unchanged target") + } + if mixed { + assertTestFile(t, root, "proofkit/changed.json", "before\n", 0o644) + } + if _, err := os.Stat(filepath.Join(root, ".agentic-proofkit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale plan created control state: %v", err) + } + }) + } +} + +func TestApplyFaultAfterFirstPublishRestoresExactBeforeState(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "proofkit/a.json", "before\n", 0o600) + plan, err := BuildPlan(context.Background(), root, []Target{ + {Path: "docs/new.json", Content: []byte("created\n"), Mode: 0o644}, + {Path: "proofkit/a.json", Content: []byte("after\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, index int) error { + if point == faultAfterPublish && index == 1 { + return errors.New("injected") + } + return nil + }} + result, err := runtime.apply(context.Background(), root, plan) + if err != nil { + t.Fatalf("apply() error = %v", err) + } + if result.State != StateRolledBack || result.FailureClass != "publication_failed" { + t.Fatalf("apply() result = %#v", result) + } + assertTestFile(t, root, "proofkit/a.json", "before\n", 0o600) + if _, err := os.Stat(filepath.Join(root, "docs", "new.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("created target survived rollback: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "docs")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("created directory survived rollback: %v", err) + } + assertNoActiveTransaction(t, root) +} + +func TestRecoverResumesOrRollsBackARecordedPrefix(t *testing.T) { + for _, action := range []string{RecoveryResume, RecoveryRollback} { + t.Run(action, func(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{ + {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 1) + result, err := Recover(context.Background(), rootPath, plan.TransactionID, action) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + wantState := StateApplied + wantA, wantB := "after-a\n", "after-b\n" + if action == RecoveryRollback { + wantState = StateRolledBack + wantA, wantB = "before-a\n", "before-b\n" + } + if result.State != wantState || result.RecoveredBy != action { + t.Fatalf("Recover() result = %#v", result) + } + assertTestFile(t, rootPath, "proofkit/a.json", wantA, 0o644) + assertTestFile(t, rootPath, "proofkit/b.json", wantB, 0o644) + assertNoActiveTransaction(t, rootPath) + }) + } +} + +func TestJournalRoundTripPreservesExecutablePlan(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before\n", 0o600) + plan, err := BuildPlan(context.Background(), rootPath, []Target{ + {Path: "docs/new.json", Content: []byte("created\n"), Mode: 0o644}, + {Path: "proofkit/a.json", Content: []byte("after\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := prepareJournal(root, plan); err != nil { + t.Fatal(err) + } + content, err := readOwnedFile(root, journalPath, MaximumJournalBytes) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(content, []byte(`"nonClaims"`)) { + t.Fatal("recovery journal must not persist presentation-only non-claims") + } + loaded, err := loadJournal(root) + if err != nil { + t.Fatalf("loadJournal() error = %v", err) + } + if loaded.TransactionID != plan.TransactionID || loaded.RootID != plan.RootID || len(loaded.Operations) != len(plan.Operations) { + t.Fatalf("journal round trip changed plan identity: %#v", loaded) + } +} + +func TestRecoverAttributesOnlyCanonicalPreparingJournalIdentity(t *testing.T) { + rootPath := t.TempDir() + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/a.json", Content: []byte("after\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := ensureDirectory(root, activeDirectory, 0o700); err != nil { + t.Fatal(err) + } + content, err := stablejson.Marshal(journalValue(plan)) + if err != nil { + t.Fatal(err) + } + if err := writeOwnedFile(root, journalTemp, content, 0o600); err != nil { + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + wrongID := "sha256:" + strings.Repeat("0", 64) + if _, err := Recover(context.Background(), rootPath, wrongID, RecoveryRollback); err == nil || !strings.Contains(err.Error(), "identity does not match") { + t.Fatalf("Recover(wrong identity) error=%v", err) + } + if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(journalTemp))); err != nil { + t.Fatalf("wrong identity removed preparing journal: %v", err) + } + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryRollback) + if err != nil || result.State != StateRolledBack || result.TransactionID != plan.TransactionID { + t.Fatalf("Recover(canonical temp) result=%#v err=%v", result, err) + } +} + +func TestRecoverDoesNotInventIdentityForPartialPreparingJournal(t *testing.T) { + rootPath := t.TempDir() + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := ensureDirectory(root, activeDirectory, 0o700); err != nil { + t.Fatal(err) + } + if err := writeOwnedFile(root, journalTemp, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + suppliedID := "sha256:" + strings.Repeat("1", 64) + result, err := Recover(context.Background(), rootPath, suppliedID, RecoveryRollback) + if err != nil || result.State != StateRolledBack || result.TransactionID != "" || !result.AppliedCountKnown || result.AppliedCount != 0 { + t.Fatalf("Recover(partial temp) result=%#v err=%v", result, err) + } + if result.JSONValue()["transactionId"] != nil { + t.Fatalf("partial recovery attributed caller identity: %#v", result.JSONValue()) + } +} + +func TestRecoverFailsClosedOnAmbiguousTargetVector(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{ + {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 1) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "unknown\n", 0o644) + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil { + t.Fatalf("Recover() error = %v", err) + } + if result.State != StateRecoveryRequired || result.FailureClass != "ambiguous_target_state" { + t.Fatalf("Recover() result = %#v", result) + } + assertTestFile(t, rootPath, "proofkit/a.json", "after-a\n", 0o644) + assertTestFile(t, rootPath, "proofkit/b.json", "unknown\n", 0o644) + if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(activeDirectory))); err != nil { + t.Fatalf("recovery state was not retained: %v", err) + } +} + +func TestRecoverRollbackRemovesInterruptedTemporaryBeforeCreatedDirectories(t *testing.T) { + rootPath := t.TempDir() + plan, err := BuildPlan(context.Background(), rootPath, []Target{ + {Path: "new/nested/record.json", Content: []byte("after\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 0) + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + temporary := transactionTemporaryPath(plan.TransactionID, 0, plan.Operations[0].Path) + if err := writeOwnedFile(root, temporary, []byte("partial\n"), 0o600); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryRollback) + if err != nil { + t.Fatal(err) + } + if result.State != StateRolledBack || result.RecoveredBy != RecoveryRollback { + t.Fatalf("Recover() result = %#v", result) + } + if _, err := os.Stat(filepath.Join(rootPath, "new")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("created directory survived recovery: %v", err) + } + assertNoActiveTransaction(t, rootPath) +} + +func TestRecoverCompletesPartiallyDeletedTerminalTombstone(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/a.json", Content: []byte("after\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 1) + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := writeMarker(root, committedMarker); err != nil { + root.Close() + t.Fatal(err) + } + tombstone, err := archiveTerminal(root, plan, StateApplied) + if err != nil { + root.Close() + t.Fatal(err) + } + for _, name := range []string{"after-000.bin", "journal.json"} { + if err := root.Remove(filepath.FromSlash(tombstone + "/" + name)); err != nil { + root.Close() + t.Fatal(err) + } + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil { + t.Fatal(err) + } + if result.State != StateApplied || result.RecoveredBy != RecoveryResume || !result.AppliedCountKnown || result.AppliedCount != 1 { + t.Fatalf("Recover() tombstone result = %#v", result) + } + assertTestFile(t, rootPath, "proofkit/a.json", "after\n", 0o644) + assertNoPendingTransaction(t, rootPath) +} + +func TestRecoverRejectsSecretShapedTransactionBeforeFilesystemAccess(t *testing.T) { + rootPath := t.TempDir() + secret := "sk-proj-secret-sentinel" + result, err := Recover(context.Background(), rootPath, secret, RecoveryRollback) + if err == nil || strings.Contains(err.Error(), secret) { + t.Fatalf("Recover() result=%#v error=%v", result, err) + } + if encoded := fmt.Sprintf("%#v", result.JSONValue()); strings.Contains(encoded, secret) { + t.Fatalf("Recover() leaked caller transaction in result: %s", encoded) + } + if _, err := os.Stat(filepath.Join(rootPath, ".agentic-proofkit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid recovery input touched filesystem: %v", err) + } +} + +func TestRecoverReportsObservedPrefixAfterResumeFailure(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{ + {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 0) + runtime := engine{fault: func(point failurePoint, index int) error { + if point == faultAfterPublish && index == 1 { + return errors.New("injected") + } + return nil + }} + result, err := runtime.recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil { + t.Fatal(err) + } + if result.State != StateRecoveryRequired || !result.AppliedCountKnown || result.AppliedCount != 1 { + t.Fatalf("recover() result = %#v", result) + } +} + +func TestRecoverRejectsUnknownActiveEntryBeforeMutation(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{ + {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 1) + mustWriteTestFile(t, rootPath, activeDirectory+"/unknown.bin", "unknown\n", 0o600) + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil { + t.Fatal(err) + } + if result.State != StateRecoveryRequired || result.FailureClass != "invalid_control_state" { + t.Fatalf("Recover() result = %#v", result) + } + assertTestFile(t, rootPath, "proofkit/a.json", "after-a\n", 0o644) + assertTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) +} + +func TestProcessDeathAfterRenameIsRecoverable(t *testing.T) { + if os.Getenv("PROOFKIT_TRANSACTION_CRASH_HELPER") == "1" { + runTransactionCrashHelper(t) + return + } + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + targets := crashHelperTargets() + plan, err := BuildPlan(context.Background(), rootPath, targets) + if err != nil { + t.Fatal(err) + } + command := exec.Command(os.Args[0], "-test.run=^TestProcessDeathAfterRenameIsRecoverable$") + command.Env = append(os.Environ(), "PROOFKIT_TRANSACTION_CRASH_HELPER=1", "PROOFKIT_TRANSACTION_CRASH_ROOT="+rootPath) + err = command.Run() + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 73 { + t.Fatalf("crash helper error = %v", err) + } + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil { + t.Fatal(err) + } + if result.State != StateApplied || !result.AppliedCountKnown || result.AppliedCount != 2 { + t.Fatalf("Recover() result = %#v", result) + } + assertTestFile(t, rootPath, "proofkit/a.json", "after-a\n", 0o644) + assertTestFile(t, rootPath, "proofkit/b.json", "after-b\n", 0o644) + assertNoPendingTransaction(t, rootPath) +} + +func runTransactionCrashHelper(t *testing.T) { + rootPath := os.Getenv("PROOFKIT_TRANSACTION_CRASH_ROOT") + plan, err := BuildPlan(context.Background(), rootPath, crashHelperTargets()) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, index int) error { + if point == faultAfterPublish && index == 1 { + os.Exit(73) + } + return nil + }} + if _, err := runtime.apply(context.Background(), rootPath, plan); err != nil { + t.Fatal(err) + } + t.Fatal("crash helper did not terminate") +} + +func crashHelperTargets() []Target { + return []Target{ + {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, + } +} + +func TestPreparingFailureWithoutActiveStateIsRolledBack(t *testing.T) { + rootPath := t.TempDir() + root, rootID, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + plan := Plan{RootID: rootID, TransactionID: "sha256:0000000000000000000000000000000000000000000000000000000000000000"} + result, err := (engine{}).finishPreparingFailure(root, plan, "journal_prepare_failed") + if err != nil { + t.Fatal(err) + } + if result.State != StateRolledBack || result.FailureClass != "journal_prepare_failed" { + t.Fatalf("finishPreparingFailure() result = %#v", result) + } +} + +func TestApplyRejectsConcurrentCooperativeWriter(t *testing.T) { + rootPath := t.TempDir() + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/a.json", Content: []byte("a\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + lock, err := acquireTransactionLock(root) + if err != nil { + t.Fatal(err) + } + defer lock.release() + if _, err := Apply(context.Background(), rootPath, plan); !errors.Is(err, ErrBusy) { + t.Fatalf("Apply() error = %v, want ErrBusy", err) + } +} + +func leaveInterruptedPrefix(t *testing.T, rootPath string, plan Plan, prefix int) { + t.Helper() + root, rootID, err := openRepository(rootPath) + if err != nil || rootID != plan.RootID { + t.Fatalf("openRepository() = %s, %v", rootID, err) + } + defer root.Close() + lock, err := acquireTransactionLock(root) + if err != nil { + t.Fatal(err) + } + defer lock.release() + if err := prepareJournal(root, plan); err != nil { + t.Fatal(err) + } + if err := stageObjects(root, plan); err != nil { + t.Fatal(err) + } + if err := writeMarker(root, readyMarker); err != nil { + t.Fatal(err) + } + if err := ensureTargetDirectories(root, plan); err != nil { + t.Fatal(err) + } + changed := changedOperationIndexes(plan) + for position := 0; position < prefix; position++ { + operationIndex := changed[position] + operation := plan.Operations[operationIndex] + if err := publishContent(root, plan, operationIndex, operation.Before, operation.afterContent, operation.After.Mode); err != nil { + t.Fatal(err) + } + } +} + +func assertTestFile(t *testing.T, root, relative, content string, mode os.FileMode) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(relative)) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + if string(got) != content { + t.Fatalf("%s content = %q, want %q", relative, got, content) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != mode { + t.Fatalf("%s mode = %v, %v, want %v", relative, infoMode(info), err, mode) + } +} + +func assertNoActiveTransaction(t *testing.T, root string) { + t.Helper() + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(activeDirectory))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("active transaction remains: %v", err) + } +} + +func assertNoPendingTransaction(t *testing.T, rootPath string) { + t.Helper() + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + pending, err := hasPendingTransactionState(root) + if err != nil || pending { + t.Fatalf("pending transaction = %t, %v", pending, err) + } +} + +func infoMode(info os.FileInfo) any { + if info == nil { + return nil + } + return fmt.Sprintf("%04o", info.Mode().Perm()) +} diff --git a/internal/tools/commandfamilygen/main_test.go b/internal/tools/commandfamilygen/main_test.go index 0b8f156..402c166 100644 --- a/internal/tools/commandfamilygen/main_test.go +++ b/internal/tools/commandfamilygen/main_test.go @@ -49,7 +49,8 @@ func TestCommandFamilyCatalogRejectsParityAndCardinalityMutations(t *testing.T) { name: "duplicate command", mutate: func(value *catalog, _ *[]string) { - value.Families[1].Commands = append(value.Families[1].Commands, value.Families[0].Commands[0]) + commands := value.Families[1].Commands + value.Families[1].Commands = append(commands, commands[len(commands)-1]) }, want: "sorted unique", }, diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 046565f..55ff794 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -616,6 +616,37 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction", "TestRetiredInitRouteHasNoPublicDispatcher", }, + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-owner-closure"}: { + "TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry", + "TestMaterializationRejectsCrossRecordDriftAndManifestMutation", + "TestMaterializationWholeChainIsCanonicalAndOwnerClosed", + }, + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-reference-closure"}: { + "TestInventoryReferencesMustResolveThroughBindingEdges", + "TestManifestAdmissionEqualsProducerImage", + "TestPathRoleLedgerRejectsWriteReferenceCollisions", + }, + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-whole-cli"}: { + "TestAdoptionMaterializationCLI", + }, + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-fault-recovery"}: { + "TestApplyFaultAfterFirstPublishRestoresExactBeforeState", + "TestApplyRejectsConcurrentCooperativeWriter", + "TestProcessDeathAfterRenameIsRecoverable", + }, + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-portable-path-identity"}: { + "TestPortableEquivalenceAndContainment", + }, + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-terminal-state"}: { + "TestAppliedTerminalReceiptReplaysCompleteResult", + "TestApplyExecutesFrozenPlan", + "TestRecoveryActionAndTerminalReceiptAreStable", + "TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity", + }, + {"REQ-PROOFKIT-SPEC-034", "proofkit.spec-proof-core.adoption-materialization-version-edge"}: { + "TestAdoptionMaterializationVersionEdgeClosesPublicCommands", + "TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift", + }, {"REQ-PROOFKIT-RETIRE-006", "proofkit.consumer-infra-retirement.migration-parity-admission"}: { "TestBuildProjectsEveryCallerDeclaredStatusAndSummaryField", }, @@ -713,6 +744,13 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-installed-contract"}: "internal/tools/installedclicontract/contract_test.go", {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-kernel-owner"}: "internal/kernel/commandroute/route_test.go", {"REQ-PROOFKIT-SPEC-031", "proofkit.spec-proof-core.adoption-version-edge-closure"}: "internal/app/adoption_front_door_version_edge_test.go", + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-owner-closure"}: "internal/command/adoptionmaterialization/adoptionmaterialization_test.go", + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-reference-closure"}: "internal/command/adoptionmaterialization/closure_test.go", + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-whole-cli"}: "internal/app/adoption_materialization_command_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-fault-recovery"}: "internal/kernel/repositorytransaction/transaction_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-portable-path-identity"}: "internal/kernel/pathidentity/pathidentity_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-terminal-state"}: "internal/kernel/repositorytransaction/invariant_test.go", + {"REQ-PROOFKIT-SPEC-034", "proofkit.spec-proof-core.adoption-materialization-version-edge"}: "internal/app/adoption_materialization_version_edge_test.go", } if len(requiredPaths) != len(required) { return fmt.Errorf("required selector path inventory=%d, selector inventory=%d", len(requiredPaths), len(required)) diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 9b149b4..d8f19be 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -194,28 +194,14 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { assertCurrentChangeRecordNotesRejected(t, "appended duplicate change section", record, notes+"## Breaking Contract Changes\n\n- `proofkit.surplus.section`: Surplus section.\n") } -var currentBreakingChanges = []Change{ - {ChangeID: "proofkit.adoption.init-retired", Summary: "Remove the overloaded init command and its route presets in favor of the explicit read-only adopt plan trust-mode route and the existing bounded specialist commands."}, - {ChangeID: "proofkit.agent-route.input-contract-v2", Summary: "Replace the agent-route input contract identity with proofkit.agent-route.input.v2 so the materialized-reference rule that rejects the stdin sentinel is machine-distinguishable from earlier v1 semantics; the wire schema remains version 1."}, -} +var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.adoption.front-door", Summary: "Add adopt plan as a read-only candidate-authoring front door with explicit fresh, code-baseline, and audit-from-code intent plus an optional orthogonal stack hint."}, - {ChangeID: "proofkit.adoption.repository-inventory", Summary: "Add a bounded explicit repository-inventory command that observes only a fixed root-file catalog without stack or source-semantic inference."}, - {ChangeID: "proofkit.cli.generated-adapter-command-routes", Summary: "Extend the generated TypeScript CLI adapter to consume the exact public contract-projected one-to-four-token command-route grammar and pass each admitted route token as a separate process argument while preserving one-token calls."}, - {ChangeID: "proofkit.cli.hierarchical-command-routes", Summary: "Publish one exact bounded command-route grammar in the CLI process contract and add owner-generated multi-token routes while retaining stable internal command IDs for contract and implementation ownership."}, - {ChangeID: "proofkit.python-wheel.embedded-cli-contract", Summary: "Embed the exact public CLI contract in every Python wheel and use the installed record to prove command-family route closure."}, + {ChangeID: "proofkit.adoption.transactional-materialization", Summary: "Add separate read-only plan, compare-and-swap apply, and state-bound recovery routes that compile owner-admitted adoption candidates into canonical repository artifacts."}, + {ChangeID: "proofkit.repository.transaction-protocol", Summary: "Add a bounded repository-confined transaction owner with immutable journals, exact before-state checks, deterministic resume, and byte-identical rollback for cooperative writers."}, } -var currentMigrationSteps = []string{ - "Replace explicit init --preset fresh with adopt plan --mode fresh --repo-root .", - "Replace init --preset code-baseline with adopt plan --mode code-baseline --repo-root , and replace init --preset code-audit with adopt plan --mode audit-from-code --repo-root .", - "Replace init --preset legacy with migration-parity-admission followed by migration-plan over explicit caller-owned records; run requirement-source-transition when the migration changes requirement lifecycle state.", - "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", - "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", - "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", - "Replace persisted proofkit.agent-route.input.v1 contract identity with proofkit.agent-route.input.v2; the admitted wire schemaVersion remains 1.", -} +var currentMigrationSteps = []string{} func validateCurrentChangeRecord(record Record, notes string) error { if !slices.Equal(record.BreakingChanges, currentBreakingChanges) { @@ -235,7 +221,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.7.0", + "# @research-engineering/agentic-proofkit 0.8.0", "", "## Breaking Contract Changes", "", @@ -243,6 +229,9 @@ func currentExpectedReleaseNotes() string { for _, change := range currentBreakingChanges { lines = append(lines, currentChangeBullet(change)) } + if len(currentBreakingChanges) == 0 { + lines = append(lines, "- None.") + } lines = append(lines, "", "## Additions", "") for _, change := range currentAdditions { lines = append(lines, currentChangeBullet(change)) @@ -254,14 +243,10 @@ func currentExpectedReleaseNotes() string { "", "## Migration", "", - "Migration is required:", + "No consumer migration is required.", "", ) - for _, step := range currentMigrationSteps { - lines = append(lines, "- "+step) - } lines = append(lines, - "", "## Platform Requirements", "", "- Published Darwin package binaries require macOS 13.0 or later on arm64 and x86_64.", @@ -269,6 +254,7 @@ func currentExpectedReleaseNotes() string { "## Known Limitations", "", "- Adopt plan inventories only a fixed root-file catalog; it does not infer stack identity, inspect arbitrary source semantics, generate requirements, write files, or execute native evidence.", + "- Transactional materialization writes only owner-admitted candidate artifacts under one explicit repository root; it does not infer requirement meaning, execute native evidence, approve merge or release, provide filesystem-wide atomic visibility to concurrent readers, or protect its private namespace from a hostile same-user process.", "- Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", "- Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", "- Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", @@ -280,7 +266,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.7.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.8.0", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -291,7 +277,7 @@ func currentExpectedReleaseNotes() string { "", "## Rollback", "", - "- Pin npm consumers to the previous admitted version 0.6.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.6.0`.", + "- Pin npm consumers to the previous admitted version 0.7.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.7.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/package-lock.json b/package-lock.json index 0984c11..9dfc540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.7.0", + "version": "0.8.0", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 6a57a4b..e798ecc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@research-engineering/agentic-proofkit", "description": "Reusable proof profile, report, graph, and witness-planning primitives.", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index 65638e7..e843748 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -42,6 +42,370 @@ } }, "commands": [ + { + "command": "adopt-materialize-apply", + "route": [ + "adopt", + "materialize", + "apply" + ], + "input": "required", + "stdin": true, + "inputPointer": true, + "scopeClass": "explicit_filesystem_mutation", + "outputModes": [ + "json", + "text" + ], + "allowedFlags": [ + "--color", + "--expect-desired-state", + "--expect-transaction", + "--format", + "--input", + "--input-pointer", + "--repo-root" + ], + "requiredFlags": [ + "--expect-desired-state", + "--expect-transaction", + "--repo-root" + ], + "singleOccurrenceFlags": [ + "--color", + "--expect-desired-state", + "--expect-transaction", + "--format", + "--input", + "--input-pointer", + "--repo-root" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--color", + "requiredFlagValues": [ + { + "flag": "--format", + "value": "text" + } + ], + "requiredFlags": [] + } + ], + "inputContract": { + "contractId": "proofkit.adopt-materialize-apply.input.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.adoption-materialization.apply-input.v1.root-shape", + "rootDefinitionDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad", + "nativeSource": { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "evidenceClass": "source_checkout" + }, + "nativeAdmissionWitnessSelector": { + "path": "internal/app/adoption_materialization_command_test.go", + "test": "TestAdoptionMaterializationCLI", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", + "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-032", + "REQ-PROOFKIT-SPEC-033" + ] + }, + "outputContract": { + "contractId": "proofkit.adopt-materialize-apply.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.adoption-materialization.apply-output.v1.root-shape", + "rootDefinitionDigest": "sha256:4b58bd4e89da98ad79c5e6faf32fe58766313117ad2558b602bfed853de0cf7e", + "nativeSource": { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "evidenceClass": "source_checkout" + }, + "nativeOutputWitnessSelector": { + "path": "internal/app/adoption_materialization_command_test.go", + "test": "TestAdoptionMaterializationCLI", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "apply receipt binds expected transaction and desired-state identities to the transaction result", + "JSON receipt and bounded text projection preserve the same operation outcome", + "root-shape-only definition proofkit.adoption-materialization.apply-output.v1.root-shape; nested fields, types, and cardinalities remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-032", + "REQ-PROOFKIT-SPEC-033" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ] + } + } + }, + { + "command": "adopt-materialize-plan", + "route": [ + "adopt", + "materialize", + "plan" + ], + "input": "required", + "stdin": true, + "inputPointer": true, + "scopeClass": "explicit_filesystem_scan", + "outputModes": [ + "json", + "text" + ], + "allowedFlags": [ + "--color", + "--format", + "--input", + "--input-pointer", + "--repo-root" + ], + "requiredFlags": [ + "--repo-root" + ], + "singleOccurrenceFlags": [ + "--color", + "--format", + "--input", + "--input-pointer", + "--repo-root" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--color", + "requiredFlagValues": [ + { + "flag": "--format", + "value": "text" + } + ], + "requiredFlags": [] + } + ], + "inputContract": { + "contractId": "proofkit.adopt-materialize-plan.input.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.adoption-materialization.plan-input.v1.root-shape", + "rootDefinitionDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e", + "nativeSource": { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "evidenceClass": "source_checkout" + }, + "nativeAdmissionWitnessSelector": { + "path": "internal/app/adoption_materialization_command_test.go", + "test": "TestAdoptionMaterializationCLI", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", + "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-032", + "REQ-PROOFKIT-SPEC-033" + ] + }, + "outputContract": { + "contractId": "proofkit.adopt-materialize-plan.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.adoption-materialization.plan-output.v1.root-shape", + "rootDefinitionDigest": "sha256:fc0e2d547a5fd54ebebe9d237a48ae15418b32d3d28fa5185aae64a0fba9b255", + "nativeSource": { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "evidenceClass": "source_checkout" + }, + "nativeOutputWitnessSelector": { + "path": "internal/app/adoption_materialization_command_test.go", + "test": "TestAdoptionMaterializationCLI", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "read-only content-bound plan with transaction and desired-state identities", + "JSON plan and bounded text are derived from one admitted typed plan", + "root-shape-only definition proofkit.adoption-materialization.plan-output.v1.root-shape; nested fields, types, and cardinalities remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-032", + "REQ-PROOFKIT-SPEC-033" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ] + } + } + }, + { + "command": "adopt-materialize-recover", + "route": [ + "adopt", + "materialize", + "recover" + ], + "input": "none", + "stdin": false, + "inputPointer": false, + "scopeClass": "explicit_filesystem_mutation", + "outputModes": [ + "json", + "text" + ], + "allowedFlags": [ + "--action", + "--color", + "--format", + "--repo-root", + "--transaction" + ], + "requiredFlags": [ + "--action", + "--repo-root", + "--transaction" + ], + "singleOccurrenceFlags": [ + "--action", + "--color", + "--format", + "--repo-root", + "--transaction" + ], + "flagChoices": { + "--action": [ + "resume", + "rollback" + ], + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--color", + "requiredFlagValues": [ + { + "flag": "--format", + "value": "text" + } + ], + "requiredFlags": [] + } + ], + "outputContract": { + "contractId": "proofkit.adopt-materialize-recover.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.adoption-materialization.recover-output.v1.root-shape", + "rootDefinitionDigest": "sha256:06129ef857ffb11351535c769f9ca207522a08f3c5bf2da52c6f5fff0b1ac757", + "nativeSource": { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "evidenceClass": "source_checkout" + }, + "nativeOutputWitnessSelector": { + "path": "internal/app/adoption_materialization_command_test.go", + "test": "TestAdoptionMaterializationCLI", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "resume or rollback receipt binds one exact pending transaction identity to its terminal or retained recovery state", + "JSON receipt and bounded text projection preserve the same operation outcome", + "root-shape-only definition proofkit.adoption-materialization.recover-output.v1.root-shape; nested fields, types, and cardinalities remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-032", + "REQ-PROOFKIT-SPEC-033" + ], + "flagChoices": { + "--action": [ + "resume", + "rollback" + ], + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ] + } + } + }, { "command": "adopt-plan", "route": [ @@ -785,7 +1149,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", + "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", "evidenceClass": "source_checkout" }, { @@ -2563,7 +2927,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", + "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", "evidenceClass": "source_checkout" }, { @@ -4159,7 +4523,7 @@ "rootDefinitionDigest": "sha256:f6300d6d0f80f5066fe4079433978132ee2bb1df75e602e59cf3dc37a385d3ce", "nativeSource": { "path": "internal/command/requirementcoverageinput", - "canonicalDigest": "sha256:4c10b1cd675af7d7753bb72a44fca1bb9a568bcd4d3733ee1907e6c4e22757f8", + "canonicalDigest": "sha256:ca685e4dd96bcd3981443471bcb9ef6f2a74ca2d8c2f9926aba2662bf7876ab4", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -4207,7 +4571,7 @@ "rootDefinitionDigest": "sha256:0f264c996a058fb8aff102e42b95a4610d3a584b35687cc870b3d16c9589cfed", "nativeSource": { "path": "internal/command/requirementcoverageinput", - "canonicalDigest": "sha256:4c10b1cd675af7d7753bb72a44fca1bb9a568bcd4d3733ee1907e6c4e22757f8", + "canonicalDigest": "sha256:ca685e4dd96bcd3981443471bcb9ef6f2a74ca2d8c2f9926aba2662bf7876ab4", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5853,7 +6217,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", + "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5882,7 +6246,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", + "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -6233,7 +6597,7 @@ }, { "path": "internal/command/testevidenceinventory", - "canonicalDigest": "sha256:60983b8f25544c7b85e818f202de053fa15557b0de37a7df35aa0d58b58c3888", + "canonicalDigest": "sha256:5b0a149b1875f3844459420645c9b2fa86a9d7c933c5edd90292192220fcdc33", "evidenceClass": "source_checkout" } ], @@ -6330,7 +6694,7 @@ }, { "path": "internal/command/testevidenceinventory", - "canonicalDigest": "sha256:60983b8f25544c7b85e818f202de053fa15557b0de37a7df35aa0d58b58c3888", + "canonicalDigest": "sha256:5b0a149b1875f3844459420645c9b2fa86a9d7c933c5edd90292192220fcdc33", "evidenceClass": "source_checkout" } ], @@ -7765,6 +8129,126 @@ }, "canonicalDigest": "sha256:90583a47b9bdd605b2d3f24dcb1a5dbb2b4d9da20d7be097da381c9c77229e62" }, + { + "definitionId": "proofkit.adoption-materialization.apply-input.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": ["nonClaims", "projectId", "requestId", "requestKind", "requirementProofBinding", "requirementSources", "schemaVersion", "sourcePlan", "testEvidenceInventory"], + "requiredFields": ["nonClaims", "projectId", "requestId", "requestKind", "requirementProofBinding", "requirementSources", "schemaVersion", "sourcePlan", "testEvidenceInventory"], + "rootKind": "object", + "variantId": "01-root", + "when": ["default JSON input"] + } + ] + }, + "canonicalDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad" + }, + { + "definitionId": "proofkit.adoption-materialization.apply-output.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": ["expectedDesiredStateId", "expectedTransactionId", "failureClass", "nonClaims", "operation", "receiptId", "receiptKind", "schemaVersion", "state", "transactionResult"], + "requiredFields": ["expectedDesiredStateId", "expectedTransactionId", "failureClass", "nonClaims", "operation", "receiptId", "receiptKind", "schemaVersion", "state", "transactionResult"], + "rootKind": "object", + "variantId": "01-root", + "when": ["default JSON mode"] + } + ] + }, + "canonicalDigest": "sha256:4b58bd4e89da98ad79c5e6faf32fe58766313117ad2558b602bfed853de0cf7e" + }, + { + "definitionId": "proofkit.adoption-materialization.plan-input.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": ["nonClaims", "projectId", "requestId", "requestKind", "requirementProofBinding", "requirementSources", "schemaVersion", "sourcePlan", "testEvidenceInventory"], + "requiredFields": ["nonClaims", "projectId", "requestId", "requestKind", "requirementProofBinding", "requirementSources", "schemaVersion", "sourcePlan", "testEvidenceInventory"], + "rootKind": "object", + "variantId": "01-root", + "when": ["default JSON input"] + } + ] + }, + "canonicalDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e" + }, + { + "definitionId": "proofkit.adoption-materialization.plan-output.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": ["manifest", "nonClaims", "planKind", "projectId", "requestId", "schemaVersion", "sourceIntent", "sourcePlanId", "state", "transaction"], + "requiredFields": ["manifest", "nonClaims", "planKind", "projectId", "requestId", "schemaVersion", "sourceIntent", "sourcePlanId", "state", "transaction"], + "rootKind": "object", + "variantId": "01-root", + "when": ["default JSON mode"] + } + ] + }, + "canonicalDigest": "sha256:fc0e2d547a5fd54ebebe9d237a48ae15418b32d3d28fa5185aae64a0fba9b255" + }, + { + "definitionId": "proofkit.adoption-materialization.recover-output.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": ["expectedDesiredStateId", "expectedTransactionId", "failureClass", "nonClaims", "operation", "receiptId", "receiptKind", "schemaVersion", "state", "transactionResult"], + "requiredFields": ["expectedDesiredStateId", "expectedTransactionId", "failureClass", "nonClaims", "operation", "receiptId", "receiptKind", "schemaVersion", "state", "transactionResult"], + "rootKind": "object", + "variantId": "01-root", + "when": ["default JSON mode"] + } + ] + }, + "canonicalDigest": "sha256:06129ef857ffb11351535c769f9ca207522a08f3c5bf2da52c6f5fff0b1ac757" + }, { "definitionId": "proofkit.adoption-workflow-plan.input.v1.root-shape", "schemaVersion": 1, diff --git a/proofkit/command-families.v1.json b/proofkit/command-families.v1.json index ea62f8e..9723151 100644 --- a/proofkit/command-families.v1.json +++ b/proofkit/command-families.v1.json @@ -17,6 +17,16 @@ "pilot-admission" ] }, + { + "familyId": "adoption-materialization", + "label": "Adoption materialization", + "purpose": "Plan, apply, and recover confined candidate adoption artifacts.", + "commands": [ + "adopt-materialize-apply", + "adopt-materialize-plan", + "adopt-materialize-recover" + ] + }, { "familyId": "agent-workflow-planning", "label": "Agent workflow planning", diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index ac744bc..8993410 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -752,6 +752,30 @@ "proofState": "witness_backed", "nonClaims": ["A source-bound version edge does not authenticate Git history, registry publication, provider ingestion, consumer migration, rollout, or production readiness."] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-032", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["Materialization does not infer requirement meaning, authenticate witness truth or freshness, execute native witnesses, approve merge or release, establish rollout or production readiness, or make its routing manifest a second semantic owner."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["Repository transactions do not provide multi-file atomic visibility to concurrent arbitrary readers, distributed transactions, protection from a hostile same-user process, or stronger power-loss durability than the admitted filesystem synchronization operations."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-034", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["A source-bound version edge does not authenticate registry publication, provider ingestion, consumer adoption, native witness truth, rollout, or production readiness."] + }, { "requirementId": "REQ-PROOFKIT-WORKFLOW-001", "ownerId": "proofkit.agent-workflow", @@ -5809,6 +5833,151 @@ ], "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-032", + "scenarioId": "proofkit.spec-proof-core.adoption-materialization-owner-closure", + "witnessId": "proofkit.adoption-materialization.owner-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/adoptionmaterialization/adoptionmaterialization_test.go", + "witnessSelectors": [ + { + "selector": "TestMaterializationWholeChainIsCanonicalAndOwnerClosed", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestMaterializationWholeChainIsCanonicalAndOwnerClosed$'" + }, + { + "selector": "TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry$'" + }, + { + "selector": "TestMaterializationRejectsCrossRecordDriftAndManifestMutation", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestMaterializationRejectsCrossRecordDriftAndManifestMutation$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-032", + "scenarioId": "proofkit.spec-proof-core.adoption-materialization-reference-closure", + "witnessId": "proofkit.adoption-materialization.reference-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/adoptionmaterialization/closure_test.go", + "witnessSelectors": [ + { + "selector": "TestPathRoleLedgerRejectsWriteReferenceCollisions", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestPathRoleLedgerRejectsWriteReferenceCollisions$'" + }, + { + "selector": "TestInventoryReferencesMustResolveThroughBindingEdges", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestInventoryReferencesMustResolveThroughBindingEdges$'" + }, + { + "selector": "TestManifestAdmissionEqualsProducerImage", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestManifestAdmissionEqualsProducerImage$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-032", + "scenarioId": "proofkit.spec-proof-core.adoption-materialization-whole-cli", + "witnessId": "proofkit.adoption-materialization.whole-cli-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/adoption_materialization_command_test.go", + "witnessSelectors": [ + { + "selector": "TestAdoptionMaterializationCLI", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.spec-proof-core.repository-transaction-terminal-state", + "witnessId": "proofkit.repository-transaction.terminal-state-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/invariant_test.go", + "witnessSelectors": [ + { + "selector": "TestApplyExecutesFrozenPlan", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestApplyExecutesFrozenPlan$'" + }, + { + "selector": "TestRecoveryActionAndTerminalReceiptAreStable", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestRecoveryActionAndTerminalReceiptAreStable$'" + }, + { + "selector": "TestAppliedTerminalReceiptReplaysCompleteResult", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestAppliedTerminalReceiptReplaysCompleteResult$'" + }, + { + "selector": "TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.spec-proof-core.repository-transaction-fault-recovery", + "witnessId": "proofkit.repository-transaction.fault-recovery-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/transaction_test.go", + "witnessSelectors": [ + { + "selector": "TestApplyFaultAfterFirstPublishRestoresExactBeforeState", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestApplyFaultAfterFirstPublishRestoresExactBeforeState$'" + }, + { + "selector": "TestProcessDeathAfterRenameIsRecoverable", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestProcessDeathAfterRenameIsRecoverable$'" + }, + { + "selector": "TestApplyRejectsConcurrentCooperativeWriter", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestApplyRejectsConcurrentCooperativeWriter$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.spec-proof-core.repository-transaction-portable-path-identity", + "witnessId": "proofkit.repository-transaction.portable-path-identity-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/pathidentity/pathidentity_test.go", + "witnessSelectors": [ + { + "selector": "TestPortableEquivalenceAndContainment", + "command": "go test ./internal/kernel/pathidentity -run '^TestPortableEquivalenceAndContainment$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-034", + "scenarioId": "proofkit.spec-proof-core.adoption-materialization-version-edge", + "witnessId": "proofkit.adoption-materialization.version-edge-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/adoption_materialization_version_edge_test.go", + "witnessSelectors": [ + { + "selector": "TestAdoptionMaterializationVersionEdgeClosesPublicCommands", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationVersionEdgeClosesPublicCommands$'" + }, + { + "selector": "TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] } ], "witnessCommands": [ diff --git a/release/change-record.v2.json b/release/change-record.v2.json index cb498ec..4125ae2 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,57 +1,29 @@ { "schemaVersion": 2, - "previousVersion": "0.6.0", - "version": "0.7.0", - "changeClass": "breaking", - "breakingChanges": [ - { - "changeId": "proofkit.adoption.init-retired", - "summary": "Remove the overloaded init command and its route presets in favor of the explicit read-only adopt plan trust-mode route and the existing bounded specialist commands." - }, - { - "changeId": "proofkit.agent-route.input-contract-v2", - "summary": "Replace the agent-route input contract identity with proofkit.agent-route.input.v2 so the materialized-reference rule that rejects the stdin sentinel is machine-distinguishable from earlier v1 semantics; the wire schema remains version 1." - } - ], + "previousVersion": "0.7.0", + "version": "0.8.0", + "changeClass": "compatible", + "breakingChanges": [], "additions": [ { - "changeId": "proofkit.adoption.front-door", - "summary": "Add adopt plan as a read-only candidate-authoring front door with explicit fresh, code-baseline, and audit-from-code intent plus an optional orthogonal stack hint." - }, - { - "changeId": "proofkit.adoption.repository-inventory", - "summary": "Add a bounded explicit repository-inventory command that observes only a fixed root-file catalog without stack or source-semantic inference." - }, - { - "changeId": "proofkit.cli.generated-adapter-command-routes", - "summary": "Extend the generated TypeScript CLI adapter to consume the exact public contract-projected one-to-four-token command-route grammar and pass each admitted route token as a separate process argument while preserving one-token calls." - }, - { - "changeId": "proofkit.cli.hierarchical-command-routes", - "summary": "Publish one exact bounded command-route grammar in the CLI process contract and add owner-generated multi-token routes while retaining stable internal command IDs for contract and implementation ownership." + "changeId": "proofkit.adoption.transactional-materialization", + "summary": "Add separate read-only plan, compare-and-swap apply, and state-bound recovery routes that compile owner-admitted adoption candidates into canonical repository artifacts." }, { - "changeId": "proofkit.python-wheel.embedded-cli-contract", - "summary": "Embed the exact public CLI contract in every Python wheel and use the installed record to prove command-family route closure." + "changeId": "proofkit.repository.transaction-protocol", + "summary": "Add a bounded repository-confined transaction owner with immutable journals, exact before-state checks, deterministic resume, and byte-identical rollback for cooperative writers." } ], "migration": { - "required": true, - "steps": [ - "Replace explicit init --preset fresh with adopt plan --mode fresh --repo-root .", - "Replace init --preset code-baseline with adopt plan --mode code-baseline --repo-root , and replace init --preset code-audit with adopt plan --mode audit-from-code --repo-root .", - "Replace init --preset legacy with migration-parity-admission followed by migration-plan over explicit caller-owned records; run requirement-source-transition when the migration changes requirement lifecycle state.", - "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", - "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", - "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", - "Replace persisted proofkit.agent-route.input.v1 contract identity with proofkit.agent-route.input.v2; the admitted wire schemaVersion remains 1." - ] + "required": false, + "steps": [] }, "platformRequirements": [ "Published Darwin package binaries require macOS 13.0 or later on arm64 and x86_64." ], "knownLimitations": [ "Adopt plan inventories only a fixed root-file catalog; it does not infer stack identity, inspect arbitrary source semantics, generate requirements, write files, or execute native evidence.", + "Transactional materialization writes only owner-admitted candidate artifacts under one explicit repository root; it does not infer requirement meaning, execute native evidence, approve merge or release, provide filesystem-wide atomic visibility to concurrent readers, or protect its private namespace from a hostile same-user process.", "Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", "Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", "Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", From 3e80bce06971a2d540d9e5f3391ded23f29dd64c Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 18:41:31 +0200 Subject: [PATCH 2/5] fix: close transaction recovery boundaries --- go.mod | 4 +- ...ption_materialization_version_edge_test.go | 2 +- internal/app/cli_contract_test.go | 2 +- internal/app/command_contract_generated.go | 8 +- .../app/testdata/v0.8-wire-observations.json | 12 +- .../adoptionmaterialization_test.go | 92 +++++++- .../command/adoptionmaterialization/build.go | 39 ++- .../command/adoptionmaterialization/model.go | 3 + .../output_admission.go | 223 ++++++++++++++++++ .../stackpreset/preset_ids_generated.go | 2 +- internal/kernel/pathidentity/pathidentity.go | 12 - .../kernel/repositorytransaction/cleanup.go | 78 +++--- .../repositorytransaction/control_state.go | 94 ++++++-- .../directory_ownership.go | 106 +++++++-- .../kernel/repositorytransaction/execution.go | 5 +- .../repositorytransaction/filesystem.go | 36 +++ .../repositorytransaction/invariant_test.go | 182 +++++++++++++- .../kernel/repositorytransaction/journal.go | 2 +- .../journal_admission.go | 5 +- .../repositorytransaction/output_admission.go | 147 ++++++++++++ .../output_admission_test.go | 54 +++++ internal/kernel/repositorytransaction/plan.go | 13 +- .../kernel/repositorytransaction/recovery.go | 42 ++-- .../state_machine_test.go | 8 + .../repositorytransaction/terminal_receipt.go | 120 +++++++++- .../repositorytransaction/transaction.go | 11 +- .../repositorytransaction/transaction_test.go | 156 +++++++++++- internal/tools/coveragemetrics/main.go | 2 + proofkit/cli-contract.v2.json | 10 +- proofkit/requirement-bindings.json | 8 + 30 files changed, 1345 insertions(+), 133 deletions(-) create mode 100644 internal/command/adoptionmaterialization/output_admission.go create mode 100644 internal/kernel/repositorytransaction/output_admission.go create mode 100644 internal/kernel/repositorytransaction/output_admission_test.go diff --git a/go.mod b/go.mod index 5de85b1..f9a4dd1 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( github.com/mattn/go-isatty v0.0.24 go.yaml.in/yaml/v3 v3.0.5 golang.org/x/mod v0.40.0 + golang.org/x/sys v0.47.0 + golang.org/x/text v0.41.0 golang.org/x/tools v0.49.0 ) @@ -31,9 +33,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/telemetry v0.0.0-20260902144106-3ef544be8421 // indirect - golang.org/x/text v0.41.0 // indirect golang.org/x/vuln v1.7.0 // indirect honnef.co/go/tools v0.8.1 // indirect ) diff --git a/internal/app/adoption_materialization_version_edge_test.go b/internal/app/adoption_materialization_version_edge_test.go index 209589b..7ca5c51 100644 --- a/internal/app/adoption_materialization_version_edge_test.go +++ b/internal/app/adoption_materialization_version_edge_test.go @@ -195,7 +195,7 @@ func validateAdoptionMaterializationVersionEdge(record adoptionMaterializationVe return fmt.Errorf("adoption materialization command-contract selection policy is invalid") } if record.PreviousPublicABISHA256 != "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7" || record.CurrentPublicABISHA256 != currentPublicABI || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { - return fmt.Errorf("adoption materialization version-edge ABI identity is invalid") + return fmt.Errorf("adoption materialization version-edge ABI identity is invalid: previous=%s current=%s wantCurrent=%s", record.PreviousPublicABISHA256, record.CurrentPublicABISHA256, currentPublicABI) } if !slices.EqualFunc(record.AddedCommandContracts, currentCommands, equalMaterializationCommandContract) { return fmt.Errorf("adoption materialization added command contracts are not exact") diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 7d02ede..a0074da 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "47311b441bb2f68f7485c54c15daad275340c27f8f7a68cfcd3fb4d92e9b976e" + cliContractPublicABISHA256 = "77dfd235e5a7404101cc8588e4eea718117a0e6dd020b407e353027ad51c8369" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index 02b9483..fde2d59 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "6703b4a1a4ca3499477ffb6f694a230138db40810f86e38f1950d3d4088f02b0" +const commandContractSourceSHA256 = "df6613bdfb325bba2bc2cdd6260819057635978fcf4138b01fa2dc3613169459" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,9 +12,9 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-materialize-apply": {InputContractSHA256: "sha256:72c303d1a9d586b0e5d8e3bf33ae2e1e0aa78b8d62e496fa55b53b36c22d3079", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:8b532d1d0887c30a0dc1194553cda987f8379e0a55cf35dda24714a6f2ca90c7", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, - "adopt-materialize-plan": {InputContractSHA256: "sha256:4b16fc73012d3ffc049c7fdc0103951f081ce9b5d08e86814a59c41cd08ea55b", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:aca3230f658bfd98e1f8319b78ea99abb984c7fcb572e38f2fe41c515097eb54", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, - "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:fa97d129e1a920a814e852bfa97528e85fb952c8fde710bb52cd749672becb18", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, + "adopt-materialize-apply": {InputContractSHA256: "sha256:7782c5ec512dc383edb9fc62fb64ea6109dd1ed49d9869b230ebe96f2aeec9fa", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:a1742452337ae99102291012ea8cc1157e15f7b96c728df8f1067aa24dbfdc70", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:4c769f5f13e04c8c57c6ac67c1805c55cfa350e890541153fd5d46fd2d850e86", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:84026c514413ebaa5f0b8fe91e02df63631ab87c742b1b0b9468fe8f84f97207", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, + "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:dddb3d69b6765c1549a8efc9c70962a109c60087e51db9e62b36dfa100628385", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "adoption-checklist": {InputContractSHA256: "sha256:4e6c4c9b369279837a5894c0b3f842a411dce529b91c91cb2d4ec63eb5ee4c2c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-checklist.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9d0d0e60f0935407fd31007d8502459663eb4c7228dc5e3c7727ae2c9907bdc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, diff --git a/internal/app/testdata/v0.8-wire-observations.json b/internal/app/testdata/v0.8-wire-observations.json index 021f6ae..d813206 100644 --- a/internal/app/testdata/v0.8-wire-observations.json +++ b/internal/app/testdata/v0.8-wire-observations.json @@ -9,18 +9,18 @@ "changeRecordRef": "release/change-record.v2.json", "changeRecordSha256": "sha256:4c8434f7b77a5c623441b021a37b50786d56aba7f65091c38be5ed902231318d", "previousPublicAbiSha256": "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7", - "currentPublicAbiSha256": "sha256:47311b441bb2f68f7485c54c15daad275340c27f8f7a68cfcd3fb4d92e9b976e", + "currentPublicAbiSha256": "sha256:77dfd235e5a7404101cc8588e4eea718117a0e6dd020b407e353027ad51c8369", "addedCommandContracts": [ { "command": "adopt-materialize-apply", "route": ["adopt", "materialize", "apply"], "inputContract": { "contractId": "proofkit.adopt-materialize-apply.input.v1", - "contractSha256": "sha256:72c303d1a9d586b0e5d8e3bf33ae2e1e0aa78b8d62e496fa55b53b36c22d3079" + "contractSha256": "sha256:7782c5ec512dc383edb9fc62fb64ea6109dd1ed49d9869b230ebe96f2aeec9fa" }, "outputContract": { "contractId": "proofkit.adopt-materialize-apply.output.v1", - "contractSha256": "sha256:8b532d1d0887c30a0dc1194553cda987f8379e0a55cf35dda24714a6f2ca90c7" + "contractSha256": "sha256:a1742452337ae99102291012ea8cc1157e15f7b96c728df8f1067aa24dbfdc70" } }, { @@ -28,11 +28,11 @@ "route": ["adopt", "materialize", "plan"], "inputContract": { "contractId": "proofkit.adopt-materialize-plan.input.v1", - "contractSha256": "sha256:4b16fc73012d3ffc049c7fdc0103951f081ce9b5d08e86814a59c41cd08ea55b" + "contractSha256": "sha256:4c769f5f13e04c8c57c6ac67c1805c55cfa350e890541153fd5d46fd2d850e86" }, "outputContract": { "contractId": "proofkit.adopt-materialize-plan.output.v1", - "contractSha256": "sha256:aca3230f658bfd98e1f8319b78ea99abb984c7fcb572e38f2fe41c515097eb54" + "contractSha256": "sha256:84026c514413ebaa5f0b8fe91e02df63631ab87c742b1b0b9468fe8f84f97207" } }, { @@ -40,7 +40,7 @@ "route": ["adopt", "materialize", "recover"], "outputContract": { "contractId": "proofkit.adopt-materialize-recover.output.v1", - "contractSha256": "sha256:fa97d129e1a920a814e852bfa97528e85fb952c8fde710bb52cd749672becb18" + "contractSha256": "sha256:dddb3d69b6765c1549a8efc9c70962a109c60087e51db9e62b36dfa100628385" } } ], diff --git a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go index 3df473f..4aa32cc 100644 --- a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go +++ b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go @@ -16,6 +16,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) @@ -28,6 +29,10 @@ func TestMaterializationWholeChainIsCanonicalAndOwnerClosed(t *testing.T) { if err != nil { t.Fatalf("BuildPlan() error = %v", err) } + planRaw := jsonRoundTripValue(t, materialization.Plan.JSONValue()) + if admitted, err := AdmitPlanOutput(planRaw); err != nil || admitted.Transaction.TransactionID != materialization.Transaction.TransactionID { + t.Fatalf("AdmitPlanOutput() plan=%#v error=%v", admitted, err) + } planBytes, err := stablejson.Marshal(materialization.Plan.JSONValue()) if err != nil { t.Fatal(err) @@ -45,6 +50,9 @@ func TestMaterializationWholeChainIsCanonicalAndOwnerClosed(t *testing.T) { if err != nil || exitCode != 0 || receipt.State != ReceiptStatePassed || receipt.TransactionResult == nil || receipt.TransactionResult.State != repositorytransaction.StateApplied { t.Fatalf("Apply() receipt=%#v exit=%d err=%v", receipt, exitCode, err) } + if admitted, err := AdmitReceiptOutput(jsonRoundTripValue(t, receipt.JSONValue())); err != nil || admitted.ReceiptID != receipt.ReceiptID { + t.Fatalf("AdmitReceiptOutput() receipt=%#v error=%v", admitted, err) + } sourceRaw := readJSON(t, filepath.Join(root, "docs/specs/pilot/requirements.v1.json")) source, err := requirementsourceadmission.Evaluate(sourceRaw) @@ -68,6 +76,51 @@ func TestMaterializationWholeChainIsCanonicalAndOwnerClosed(t *testing.T) { } } +func TestMaterializationOutputAdmissionRejectsCrossOwnerMutants(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + materialization, err := BuildPlan(context.Background(), request, root) + if err != nil { + t.Fatal(err) + } + planMutant := jsonRoundTripValue(t, materialization.Plan.JSONValue()).(map[string]any) + transaction := planMutant["transaction"].(map[string]any) + operations := transaction["operations"].([]any) + transaction["operations"] = operations[1:] + if _, err := AdmitPlanOutput(planMutant); err == nil { + t.Fatal("AdmitPlanOutput() admitted a transaction that omitted a manifest route") + } + + receipt, exitCode, err := Apply(context.Background(), request, root, materialization.Transaction.TransactionID, materialization.Transaction.DesiredStateID) + if err != nil || exitCode != 0 { + t.Fatalf("Apply() receipt=%#v exit=%d error=%v", receipt, exitCode, err) + } + receiptMutant := jsonRoundTripValue(t, receipt.JSONValue()).(map[string]any) + receiptMutant["state"] = ReceiptStateBlocked + identity := cloneValue(t, receiptMutant).(map[string]any) + delete(identity, "receiptId") + receiptMutant["receiptId"], err = digest.StableJSONSHA256Ref(identity) + if err != nil { + t.Fatal(err) + } + if _, err := AdmitReceiptOutput(receiptMutant); err == nil { + t.Fatal("AdmitReceiptOutput() admitted a state that contradicted its transaction result") + } +} + +func jsonRoundTripValue(t *testing.T, value any) any { + t.Helper() + content, err := stablejson.Marshal(value) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + return decoded +} + func TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry(t *testing.T) { root := t.TempDir() request := validRequest(t, root) @@ -96,8 +149,13 @@ func TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry(t *testing.T if err != nil || exitCode != 0 || retry.State != ReceiptStatePassed || retry.TransactionResult == nil || retry.TransactionResult.State != repositorytransaction.StateAlreadySatisfied { t.Fatalf("retry Apply() receipt=%#v exit=%d err=%v", retry, exitCode, err) } - if retry.ExpectedTransactionID != initial.Transaction.TransactionID || retry.ExpectedDesiredStateID != initial.Transaction.DesiredStateID || retry.TransactionResult.TransactionID == initial.Transaction.TransactionID { - t.Fatalf("retry did not distinguish expected and observed transactions: %#v", retry) + if retry.ExpectedTransactionID != initial.Transaction.TransactionID || retry.ExpectedDesiredStateID != initial.Transaction.DesiredStateID || retry.TransactionResult.TransactionID != initial.Transaction.TransactionID { + t.Fatalf("retry was not bound to the retained terminal transaction: %#v", retry) + } + wrongTransaction := "sha256:" + strings.Repeat("1", 64) + blocked, exitCode, err = Apply(context.Background(), request, root, wrongTransaction, initial.Transaction.DesiredStateID) + if err != nil || exitCode != 1 || blocked.FailureClass != "transaction_identity_mismatch" { + t.Fatalf("wrong-transaction retry receipt=%#v exit=%d err=%v", blocked, exitCode, err) } wrongDesired := "sha256:" + strings.Repeat("0", 64) blocked, exitCode, err = Apply(context.Background(), request, root, initial.Transaction.TransactionID, wrongDesired) @@ -106,6 +164,36 @@ func TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry(t *testing.T } } +func TestTerminalReplayClassificationPreservesDistinctOutcomes(t *testing.T) { + transactionID := "sha256:" + strings.Repeat("a", 64) + tests := []struct { + name string + result repositorytransaction.Result + err error + wantFailureClass string + wantError error + wantState string + }{ + {name: "lost acknowledgement", result: repositorytransaction.Result{AppliedCount: 3, AppliedCountKnown: true, RecoveredBy: repositorytransaction.RecoveryResume, State: repositorytransaction.StateApplied, TransactionID: transactionID}, wantState: repositorytransaction.StateAlreadySatisfied}, + {name: "busy", err: repositorytransaction.ErrBusy, wantFailureClass: "transaction_busy"}, + {name: "cancelled", err: context.Canceled, wantError: context.Canceled}, + {name: "deadline", err: context.DeadlineExceeded, wantError: context.DeadlineExceeded}, + {name: "absent", err: errors.New("absent"), wantFailureClass: "transaction_identity_mismatch"}, + {name: "wrong terminal state", result: repositorytransaction.Result{AppliedCountKnown: true, State: repositorytransaction.StateRolledBack, TransactionID: transactionID}, wantFailureClass: "transaction_identity_mismatch"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, failureClass, err := classifyTerminalReplay(test.result, test.err) + if !errors.Is(err, test.wantError) || failureClass != test.wantFailureClass || result.State != test.wantState { + t.Fatalf("classifyTerminalReplay() result=%#v failure=%q error=%v", result, failureClass, err) + } + if test.wantState == repositorytransaction.StateAlreadySatisfied && (result.AppliedCount != 0 || !result.AppliedCountKnown || result.RecoveredBy != "" || result.TransactionID != transactionID) { + t.Fatalf("lost-ack replay result=%#v", result) + } + }) + } +} + func TestApplyDistinguishesStaleBeforeSnapshotFromDesiredState(t *testing.T) { root := t.TempDir() request := validRequest(t, root) diff --git a/internal/command/adoptionmaterialization/build.go b/internal/command/adoptionmaterialization/build.go index fe87dab..f9ec8d4 100644 --- a/internal/command/adoptionmaterialization/build.go +++ b/internal/command/adoptionmaterialization/build.go @@ -49,6 +49,9 @@ func BuildPlan(ctx context.Context, raw any, repositoryRoot string) (Materializa RequestID: request.RequestID, SourceIntent: request.SourceIntent, SourcePlanID: request.SourcePlanID, Transaction: transaction, } + if _, err := AdmitPlanOutput(plan.JSONValue()); err != nil { + return Materialization{}, fmt.Errorf("admit adoption materialization plan output: %w", err) + } encoded, err := stablejson.Marshal(plan.JSONValue()) if err != nil || len(encoded) > MaximumOutputBytes { return Materialization{}, fmt.Errorf("adoption materialization plan exceeds its output byte limit") @@ -75,8 +78,19 @@ func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, if materialization.Transaction.DesiredStateID != expectedDesired { return blockedReceipt(OperationApply, expected, expectedDesired, "desired_state_identity_mismatch", materialization.Plan.NonClaims) } - if materialization.Transaction.TransactionID != expected && transactionHasChanges(materialization.Transaction) { - return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_identity_mismatch", materialization.Plan.NonClaims) + if materialization.Transaction.TransactionID != expected { + if transactionHasChanges(materialization.Transaction) { + return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_identity_mismatch", materialization.Plan.NonClaims) + } + terminal, terminalErr := repositorytransaction.ReadTerminalResult(ctx, repositoryRoot, expected) + replay, failureClass, replayErr := classifyTerminalReplay(terminal, terminalErr) + if replayErr != nil { + return Receipt{}, 1, replayErr + } + if failureClass != "" { + return blockedReceipt(OperationApply, expected, expectedDesired, failureClass, materialization.Plan.NonClaims) + } + return resultReceipt(OperationApply, expected, expectedDesired, replay, materialization.Plan.NonClaims) } result, err := repositorytransaction.Apply(ctx, repositoryRoot, materialization.Transaction) if err != nil { @@ -91,6 +105,27 @@ func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, return resultReceipt(OperationApply, expected, expectedDesired, result, materialization.Plan.NonClaims) } +func classifyTerminalReplay(result repositorytransaction.Result, err error) (repositorytransaction.Result, string, error) { + if err != nil { + switch { + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + return repositorytransaction.Result{}, "", err + case errors.Is(err, repositorytransaction.ErrBusy): + return repositorytransaction.Result{}, "transaction_busy", nil + default: + return repositorytransaction.Result{}, "transaction_identity_mismatch", nil + } + } + if result.State != repositorytransaction.StateApplied { + return repositorytransaction.Result{}, "transaction_identity_mismatch", nil + } + result.AppliedCount = 0 + result.AppliedCountKnown = true + result.RecoveredBy = "" + result.State = repositorytransaction.StateAlreadySatisfied + return result, "", nil +} + func Recover(ctx context.Context, repositoryRoot, transactionID, action string) (Receipt, int, error) { admittedID, err := admit.SHA256Ref(transactionID, "adoption materialization transaction") if err != nil { diff --git a/internal/command/adoptionmaterialization/model.go b/internal/command/adoptionmaterialization/model.go index f4f3142..2d64b20 100644 --- a/internal/command/adoptionmaterialization/model.go +++ b/internal/command/adoptionmaterialization/model.go @@ -159,6 +159,9 @@ func newReceipt(operation, state, failureClass, expectedTransactionID, expectedD return Receipt{}, fmt.Errorf("derive adoption materialization receipt identity") } receipt.ReceiptID = id + if _, err := AdmitReceiptOutput(receipt.JSONValue()); err != nil { + return Receipt{}, fmt.Errorf("admit adoption materialization receipt output: %w", err) + } return receipt, nil } diff --git a/internal/command/adoptionmaterialization/output_admission.go b/internal/command/adoptionmaterialization/output_admission.go new file mode 100644 index 0000000..db717a4 --- /dev/null +++ b/internal/command/adoptionmaterialization/output_admission.go @@ -0,0 +1,223 @@ +package adoptionmaterialization + +import ( + "bytes" + "fmt" + "slices" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionplan" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +var receiptStateSet = map[string]struct{}{ + ReceiptStateBlocked: {}, + ReceiptStateCleanupRequired: {}, + ReceiptStateDurabilityUnknown: {}, + ReceiptStateFailed: {}, + ReceiptStatePassed: {}, + ReceiptStateRecoveryRequired: {}, +} + +func AdmitPlanOutput(raw any) (Plan, error) { + record, ok := raw.(map[string]any) + if !ok { + return Plan{}, fmt.Errorf("adoption materialization plan must be an object") + } + if err := admit.KnownKeys(record, []string{"manifest", "nonClaims", "planKind", "projectId", "requestId", "schemaVersion", "sourceIntent", "sourcePlanId", "state", "transaction"}, "adoption materialization plan"); err != nil { + return Plan{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["planKind"] != PlanKind || record["state"] != "ready" { + return Plan{}, fmt.Errorf("adoption materialization plan identity is invalid") + } + projectID, err := admit.RuleID(record["projectId"], "adoption materialization plan projectId") + if err != nil { + return Plan{}, err + } + requestID, err := admit.RuleID(record["requestId"], "adoption materialization plan requestId") + if err != nil { + return Plan{}, err + } + sourcePlanID, err := admit.SHA256Ref(record["sourcePlanId"], "adoption materialization plan sourcePlanId") + if err != nil { + return Plan{}, err + } + sourceIntent, ok := record["sourceIntent"].(string) + if !ok || !adoptionplan.IsIntent(sourceIntent) { + return Plan{}, fmt.Errorf("adoption materialization plan sourceIntent is invalid") + } + nonClaims, err := admit.PreserveSortedTextArray(record["nonClaims"], "adoption materialization plan nonClaims", false) + if err != nil || !containsEvery(nonClaims, boundaryNonClaims) { + return Plan{}, fmt.Errorf("adoption materialization plan nonClaims are invalid") + } + manifest, err := AdmitManifest(record["manifest"]) + if err != nil { + return Plan{}, err + } + transaction, err := repositorytransaction.AdmitPlanOutput(record["transaction"]) + if err != nil { + return Plan{}, err + } + if manifest.ProjectID != projectID || manifest.MaterializationRequestID != requestID || manifest.SourcePlanID != sourcePlanID { + return Plan{}, fmt.Errorf("adoption materialization plan manifest identity is inconsistent") + } + if err := validatePlanRouteClosure(manifest, transaction); err != nil { + return Plan{}, err + } + plan := Plan{ + Manifest: manifest, NonClaims: nonClaims, ProjectID: projectID, RequestID: requestID, + SourceIntent: sourceIntent, SourcePlanID: sourcePlanID, Transaction: transaction, + } + actual, err := stablejson.Marshal(record) + if err != nil { + return Plan{}, fmt.Errorf("encode adoption materialization plan") + } + expected, err := stablejson.Marshal(plan.JSONValue()) + if err != nil || !bytes.Equal(actual, expected) || len(actual) > MaximumOutputBytes { + return Plan{}, fmt.Errorf("adoption materialization plan is not canonical") + } + return plan, nil +} + +func AdmitReceiptOutput(raw any) (Receipt, error) { + record, ok := raw.(map[string]any) + if !ok { + return Receipt{}, fmt.Errorf("adoption materialization receipt must be an object") + } + if err := admit.KnownKeys(record, []string{"expectedDesiredStateId", "expectedTransactionId", "failureClass", "nonClaims", "operation", "receiptId", "receiptKind", "schemaVersion", "state", "transactionResult"}, "adoption materialization receipt"); err != nil { + return Receipt{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["receiptKind"] != ReceiptKind { + return Receipt{}, fmt.Errorf("adoption materialization receipt identity is invalid") + } + operation, err := admit.Enum(record["operation"], map[string]struct{}{OperationApply: {}, OperationRecover: {}}, "adoption materialization receipt operation") + if err != nil { + return Receipt{}, err + } + state, err := admit.Enum(record["state"], receiptStateSet, "adoption materialization receipt state") + if err != nil { + return Receipt{}, err + } + receiptID, err := admit.SHA256Ref(record["receiptId"], "adoption materialization receipt receiptId") + if err != nil { + return Receipt{}, err + } + expectedTransactionID, err := nullableSHA256Ref(record["expectedTransactionId"], "adoption materialization receipt expectedTransactionId") + if err != nil { + return Receipt{}, err + } + expectedDesiredStateID, err := nullableSHA256Ref(record["expectedDesiredStateId"], "adoption materialization receipt expectedDesiredStateId") + if err != nil { + return Receipt{}, err + } + failureClass := "" + if record["failureClass"] != nil { + failureClass, err = admit.NonEmptyText(record["failureClass"], "adoption materialization receipt failureClass") + if err != nil { + return Receipt{}, err + } + } + nonClaims, err := admit.PreserveSortedTextArray(record["nonClaims"], "adoption materialization receipt nonClaims", false) + if err != nil || !containsEvery(nonClaims, boundaryNonClaims) { + return Receipt{}, fmt.Errorf("adoption materialization receipt nonClaims are invalid") + } + var transactionResult *repositorytransaction.Result + if record["transactionResult"] != nil { + result, err := repositorytransaction.AdmitResultOutput(record["transactionResult"]) + if err != nil { + return Receipt{}, err + } + transactionResult = &result + } + receipt := Receipt{ + ExpectedDesiredStateID: expectedDesiredStateID, ExpectedTransactionID: expectedTransactionID, + FailureClass: failureClass, NonClaims: nonClaims, Operation: operation, ReceiptID: receiptID, + State: state, TransactionResult: transactionResult, + } + if err := validateReceiptRelation(receipt); err != nil { + return Receipt{}, err + } + wantID, err := digest.StableJSONSHA256Ref(receipt.identityValue()) + if err != nil || wantID != receiptID { + return Receipt{}, fmt.Errorf("adoption materialization receipt identity does not match its content") + } + actual, err := stablejson.Marshal(record) + if err != nil { + return Receipt{}, fmt.Errorf("encode adoption materialization receipt") + } + expected, err := stablejson.Marshal(receipt.JSONValue()) + if err != nil || !bytes.Equal(actual, expected) || len(actual) > MaximumOutputBytes { + return Receipt{}, fmt.Errorf("adoption materialization receipt is not canonical") + } + return receipt, nil +} + +func validatePlanRouteClosure(manifest Manifest, transaction repositorytransaction.Plan) error { + wantPaths := make([]string, 0, len(manifest.Routes)+1) + wantPaths = append(wantPaths, ProjectManifestPath) + for _, route := range manifest.Routes { + wantPaths = append(wantPaths, route.Path) + } + slices.Sort(wantPaths) + gotPaths := make([]string, 0, len(transaction.Operations)) + for _, operation := range transaction.Operations { + if !operation.After.Exists || operation.After.Mode != 0o644 { + return fmt.Errorf("adoption materialization transaction target projection is invalid") + } + gotPaths = append(gotPaths, operation.Path) + if operation.Path == ProjectManifestPath { + content, err := stablejson.Marshal(manifest.JSONValue()) + if err != nil || operation.After.ByteCount != int64(len(content)) || operation.After.SHA256 != digest.SHA256BytesRef(content) { + return fmt.Errorf("adoption materialization manifest transaction target is inconsistent") + } + } + } + if !slices.Equal(gotPaths, wantPaths) { + return fmt.Errorf("adoption materialization manifest routes do not close the transaction target set") + } + return nil +} + +func validateReceiptRelation(receipt Receipt) error { + if receipt.ExpectedTransactionID == "" { + return fmt.Errorf("adoption materialization receipt requires an expected transaction identity") + } + if receipt.Operation == OperationApply && receipt.ExpectedDesiredStateID == "" { + return fmt.Errorf("adoption materialization apply receipt requires an expected desired-state identity") + } + if receipt.Operation == OperationRecover && receipt.ExpectedDesiredStateID != "" { + return fmt.Errorf("adoption materialization recovery receipt must not claim an expected desired state") + } + if receipt.TransactionResult == nil { + if receipt.State != ReceiptStateBlocked || receipt.FailureClass == "" { + return fmt.Errorf("adoption materialization receipt without transaction result must be blocked") + } + return nil + } + wantState, _ := receiptOutcome(receipt.Operation, *receipt.TransactionResult) + if receipt.State != wantState || receipt.FailureClass != receipt.TransactionResult.FailureClass { + return fmt.Errorf("adoption materialization receipt outcome contradicts its transaction result") + } + if receipt.State == ReceiptStatePassed && receipt.TransactionResult.TransactionID != "" && receipt.TransactionResult.TransactionID != receipt.ExpectedTransactionID { + return fmt.Errorf("adoption materialization passed receipt transaction identity is inconsistent") + } + return nil +} + +func nullableSHA256Ref(raw any, context string) (string, error) { + if raw == nil { + return "", nil + } + return admit.SHA256Ref(raw, context) +} + +func containsEvery(values, required []string) bool { + for _, candidate := range required { + if !slices.Contains(values, candidate) { + return false + } + } + return true +} diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index cee607d..0c9b9f4 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "6703b4a1a4ca3499477ffb6f694a230138db40810f86e38f1950d3d4088f02b0" +const presetContractSourceSHA256 = "df6613bdfb325bba2bc2cdd6260819057635978fcf4138b01fa2dc3613169459" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/kernel/pathidentity/pathidentity.go b/internal/kernel/pathidentity/pathidentity.go index 84af432..0e48b38 100644 --- a/internal/kernel/pathidentity/pathidentity.go +++ b/internal/kernel/pathidentity/pathidentity.go @@ -57,18 +57,6 @@ func Overlaps(left, right string) (bool, error) { return leftKey == rightKey || withinKey(leftKey, rightKey) || withinKey(rightKey, leftKey), nil } -func Within(candidate, ancestor string) (bool, error) { - candidateKey, err := Key(candidate) - if err != nil { - return false, err - } - ancestorKey, err := Key(ancestor) - if err != nil { - return false, err - } - return withinKey(candidateKey, ancestorKey), nil -} - func withinKey(candidate, ancestor string) bool { return len(candidate) > len(ancestor) && candidate[:len(ancestor)] == ancestor && candidate[len(ancestor)] == '/' } diff --git a/internal/kernel/repositorytransaction/cleanup.go b/internal/kernel/repositorytransaction/cleanup.go index 4ac9093..cd9a271 100644 --- a/internal/kernel/repositorytransaction/cleanup.go +++ b/internal/kernel/repositorytransaction/cleanup.go @@ -13,25 +13,25 @@ func cleanupActive(root *os.Root, plan *Plan) error { return cleanupTransactionDirectory(root, activeDirectory, plan, false, nil) } -func (runtime engine) archiveAndCleanupTerminal(root *os.Root, plan Plan, state string) error { - tombstone, err := archiveTerminal(root, plan, state) +func (runtime engine) archiveAndCleanupTerminal(root *os.Root, plan Plan, result Result) error { + tombstone, err := archiveTerminal(root, plan, result) if err != nil { return err } return runtime.compactTerminalTombstone(root, tombstone, &plan) } -func archiveTerminal(root *os.Root, plan Plan, state string) (string, error) { - if state != StateApplied && state != StateRolledBack { - return "", fmt.Errorf("repository transaction terminal state is invalid") +func archiveTerminal(root *os.Root, plan Plan, result Result) (string, error) { + if _, err := terminalReceiptFromResult(plan, result); err != nil { + return "", err } - tombstone := terminalTombstonePath(plan.TransactionID, state) + tombstone := terminalTombstonePath(plan.TransactionID, result.State) if exists, err := pathExists(root, tombstone); err != nil { return "", err } else if exists { return "", fmt.Errorf("repository transaction terminal tombstone already exists") } - if err := ensureTerminalReceipt(root, plan, state); err != nil { + if err := ensureTerminalReceipt(root, plan, result); err != nil { return "", err } if err := root.Rename(filepath.FromSlash(activeDirectory), filepath.FromSlash(tombstone)); err != nil { @@ -63,8 +63,11 @@ func (runtime engine) compactTerminalTombstone(root *os.Root, tombstone string, if !ok || receipt.TransactionID != transactionID || receipt.State != state { return fmt.Errorf("repository transaction terminal receipt does not match its route") } - if plan != nil && (receipt.TransactionID != plan.TransactionID || receipt.AppliedCount != prefixForState(*plan, state)) { - return fmt.Errorf("repository transaction terminal receipt does not match its plan") + if plan != nil { + want, relationErr := terminalReceiptFromResult(*plan, receipt.result()) + if relationErr != nil || want != receipt { + return fmt.Errorf("repository transaction terminal receipt does not match its plan") + } } for _, entry := range entries { if entry.Name() == terminalReceiptName { @@ -91,28 +94,49 @@ func discardTerminalReceipt(root *os.Root) error { if err != nil || len(entries) == 0 { return err } - if len(entries) != 1 { - return fmt.Errorf("repository transaction control directory contains conflicting state") + terminal, found, err := findTerminalControlEntry(entries) + if err != nil { + return err } - entry := entries[0] - transactionID, state, ok := terminalEntryIdentity(entry.Name()) - if !ok || !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { - return fmt.Errorf("repository transaction terminal receipt is invalid") + if !found { + return nil } - tombstone := ControlDirectory + "/" + entry.Name() - children, err := transactionEntries(root, tombstone) - if err != nil || len(children) != 1 || children[0].Name() != terminalReceiptName { - return fmt.Errorf("repository transaction terminal receipt requires recovery") - } - receipt, err := loadTerminalReceipt(root, tombstone) - if err != nil || receipt.TransactionID != transactionID || receipt.State != state { - return fmt.Errorf("repository transaction terminal receipt is invalid") + transactionID := terminal.TransactionID + state := terminal.State + tombstone := ControlDirectory + "/" + terminal.Entry.Name() + if !terminal.Retired { + children, err := transactionEntries(root, tombstone) + if err != nil || len(children) != 1 || children[0].Name() != terminalReceiptName { + return fmt.Errorf("repository transaction terminal receipt requires recovery") + } + receipt, err := loadTerminalReceipt(root, tombstone) + if err != nil || receipt.TransactionID != transactionID || receipt.State != state { + return fmt.Errorf("repository transaction terminal receipt is invalid") + } + retiredPath := retiredTerminalTombstonePath(transactionID, state) + if err := root.Rename(filepath.FromSlash(tombstone), filepath.FromSlash(retiredPath)); err != nil { + return fmt.Errorf("retire previous repository transaction terminal receipt") + } + if err := syncDirectory(root, ControlDirectory); err != nil { + return err + } + tombstone = retiredPath } - if err := root.Remove(filepath.FromSlash(tombstone + "/" + terminalReceiptName)); err != nil { - return fmt.Errorf("remove previous repository transaction terminal receipt content") + children, err := transactionEntries(root, tombstone) + if err != nil || len(children) > 1 || len(children) == 1 && children[0].Name() != terminalReceiptName { + return fmt.Errorf("retired repository transaction terminal receipt is invalid") } - if err := syncDirectory(root, tombstone); err != nil { - return err + if len(children) == 1 { + receipt, err := loadTerminalReceipt(root, tombstone) + if err != nil || receipt.TransactionID != transactionID || receipt.State != state { + return fmt.Errorf("retired repository transaction terminal receipt is invalid") + } + if err := root.Remove(filepath.FromSlash(tombstone + "/" + terminalReceiptName)); err != nil { + return fmt.Errorf("remove previous repository transaction terminal receipt content") + } + if err := syncDirectory(root, tombstone); err != nil { + return err + } } if err := root.Remove(filepath.FromSlash(tombstone)); err != nil { return fmt.Errorf("remove previous repository transaction terminal receipt") diff --git a/internal/kernel/repositorytransaction/control_state.go b/internal/kernel/repositorytransaction/control_state.go index ee685b4..51e5923 100644 --- a/internal/kernel/repositorytransaction/control_state.go +++ b/internal/kernel/repositorytransaction/control_state.go @@ -18,6 +18,13 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) +type terminalControlIdentity struct { + Entry fs.DirEntry + Retired bool + State string + TransactionID string +} + func validateActiveState(root *os.Root, plan Plan) error { entries, err := activeEntries(root) if err != nil { @@ -28,12 +35,16 @@ func validateActiveState(root *os.Root, plan Plan) error { func validateTransactionEntries(entries []fs.DirEntry, plan *Plan, allowPartialTerminal bool) error { allowed := map[string]struct{}{ - "journal.json": {}, - "journal.tmp": {}, - "ready": {}, - "committed": {}, - "rolled-back": {}, - terminalReceiptName: {}, + "journal.json": {}, + "journal.tmp": {}, + "ready": {}, + "ready.tmp": {}, + "committed": {}, + "committed.tmp": {}, + "rolled-back": {}, + "rolled-back.tmp": {}, + terminalReceiptName: {}, + terminalReceiptTempName: {}, } if plan != nil { for index, operation := range plan.Operations { @@ -48,6 +59,7 @@ func validateTransactionEntries(entries []fs.DirEntry, plan *Plan, allowPartialT } for index := range plan.CreatedDirectories { allowed[strings.TrimPrefix(directoryOwnershipPath(index), activeDirectory+"/")] = struct{}{} + allowed[strings.TrimPrefix(directoryOwnershipTempPath(index), activeDirectory+"/")] = struct{}{} } } for _, entry := range entries { @@ -105,11 +117,19 @@ func pendingTransactionState(root *os.Root) (pendingState, error) { return pendingState{}, nil } pending := pendingState{Exists: true} - if len(entries) != 1 { - return pending, nil + active := false + for _, entry := range entries { + if entry.Name() == "active" && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + if active { + return pending, nil + } + active = true + } } - entry := entries[0] - if entry.Name() == "active" && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + if active { + if _, _, err := findTerminalControlEntry(entries); err != nil { + return pending, nil + } if err := validatePrivateDirectory(root, activeDirectory, 0o700); err != nil { return pendingState{}, err } @@ -122,15 +142,21 @@ func pendingTransactionState(root *os.Root) (pendingState, error) { } return pending, nil } - if transactionID, ok := terminalEntryTransactionID(entry.Name()); ok && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + if len(entries) != 1 { + return pending, nil + } + entry := entries[0] + if transactionID, state, retired, ok := controlTerminalEntryIdentity(entry.Name()); ok && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { children, inspectErr := transactionEntries(root, ControlDirectory+"/"+entry.Name()) if inspectErr != nil { return pendingState{}, inspectErr } + if retired && len(children) == 0 { + return pendingState{}, nil + } if len(children) == 1 && children[0].Name() == terminalReceiptName { receipt, receiptErr := loadTerminalReceipt(root, ControlDirectory+"/"+entry.Name()) - _, state, identityOK := terminalEntryIdentity(entry.Name()) - if receiptErr == nil && identityOK && receipt.TransactionID == transactionID && receipt.State == state { + if receiptErr == nil && receipt.TransactionID == transactionID && receipt.State == state { return pendingState{}, nil } } @@ -163,9 +189,36 @@ func controlEntries(root *os.Root) ([]fs.DirEntry, error) { return entries, nil } -func terminalEntryTransactionID(name string) (string, bool) { - transactionID, _, ok := terminalEntryIdentity(name) - return transactionID, ok +func controlTerminalEntryIdentity(name string) (string, string, bool, bool) { + if transactionID, state, ok := terminalEntryIdentity(name); ok { + return transactionID, state, false, true + } + if transactionID, state, ok := terminalEntryIdentity(strings.TrimPrefix(name, "retired-")); strings.HasPrefix(name, "retired-") && ok { + return transactionID, state, true, true + } + return "", "", false, false +} + +func findTerminalControlEntry(entries []fs.DirEntry) (terminalControlIdentity, bool, error) { + activeSeen := false + var terminal terminalControlIdentity + terminalSeen := false + for _, entry := range entries { + if entry.Name() == "active" { + if activeSeen || !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return terminalControlIdentity{}, false, fmt.Errorf("repository transaction active control route is invalid") + } + activeSeen = true + continue + } + transactionID, state, retired, ok := controlTerminalEntryIdentity(entry.Name()) + if !ok || terminalSeen || !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return terminalControlIdentity{}, false, fmt.Errorf("repository transaction terminal control route is invalid") + } + terminal = terminalControlIdentity{Entry: entry, Retired: retired, State: state, TransactionID: transactionID} + terminalSeen = true + } + return terminal, terminalSeen, nil } func terminalEntryIdentity(name string) (string, string, bool) { @@ -188,6 +241,10 @@ func terminalTombstonePath(transactionID, state string) string { return ControlDirectory + "/gc-" + strings.TrimPrefix(transactionID, "sha256:") + "-" + state } +func retiredTerminalTombstonePath(transactionID, state string) string { + return ControlDirectory + "/retired-gc-" + strings.TrimPrefix(transactionID, "sha256:") + "-" + state +} + func isBoundedTransactionEntryName(name string) bool { for _, prefix := range []string{"after-", "before-"} { if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".bin") { @@ -201,6 +258,11 @@ func isBoundedTransactionEntryName(name string) bool { index, err := strconv.Atoi(indexText) return err == nil && len(indexText) == 4 && index >= 0 && index < MaximumOperations*pathidentity.MaximumComponents } + if strings.HasPrefix(name, "directory-") && strings.HasSuffix(name, ".tmp") { + indexText := strings.TrimSuffix(strings.TrimPrefix(name, "directory-"), ".tmp") + index, err := strconv.Atoi(indexText) + return err == nil && len(indexText) == 4 && index >= 0 && index < MaximumOperations*pathidentity.MaximumComponents + } if strings.HasPrefix(name, "publish-") && strings.HasSuffix(name, ".tmp") { indexText := strings.TrimSuffix(strings.TrimPrefix(name, "publish-"), ".tmp") index, err := strconv.Atoi(indexText) diff --git a/internal/kernel/repositorytransaction/directory_ownership.go b/internal/kernel/repositorytransaction/directory_ownership.go index 25f0b2f..0da2cc3 100644 --- a/internal/kernel/repositorytransaction/directory_ownership.go +++ b/internal/kernel/repositorytransaction/directory_ownership.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "os" "path" @@ -36,24 +37,22 @@ func ensureTargetDirectories(root *os.Root, plan Plan) error { } continue } - if _, exists, err := inspectOwnedTargetDirectory(root, directory); err != nil { + if err := discardOwnedTemporaryFile(root, directoryOwnershipTempPath(index)); err != nil { return err - } else if exists { - return fmt.Errorf("repository target directory appeared without transaction ownership") } - if err := root.Mkdir(filepath.FromSlash(directory), 0o755); err != nil { - return fmt.Errorf("create repository target directory") - } - if err := root.Chmod(filepath.FromSlash(directory), 0o755); err != nil { - return fmt.Errorf("set repository target directory mode") - } - identity, exists, err := inspectOwnedTargetDirectory(root, directory) - if err != nil || !exists { - return fmt.Errorf("admit created repository target directory") - } - if err := syncDirectory(root, path.Dir(directory)); err != nil { + identity, exists, err := admitRecoverableTargetDirectory(root, directory) + if err != nil { return err } + if !exists { + if err := root.Mkdir(filepath.FromSlash(directory), 0o755); err != nil { + return fmt.Errorf("create repository target directory") + } + identity, exists, err = admitRecoverableTargetDirectory(root, directory) + if err != nil || !exists { + return fmt.Errorf("admit created repository target directory") + } + } record = directoryOwnership{Identity: identity, Path: directory, TransactionID: plan.TransactionID} if err := writeDirectoryOwnership(root, index, record); err != nil { _ = removeOwnedTargetDirectory(root, directory, identity) @@ -70,6 +69,23 @@ func removeCreatedDirectories(root *os.Root, plan Plan) error { if err != nil { return err } + if !recorded { + if err := discardOwnedTemporaryFile(root, directoryOwnershipTempPath(index)); err != nil { + return err + } + identity, exists, err := admitRecoverableTargetDirectory(root, directory) + if err != nil { + return err + } + if !exists { + continue + } + record = directoryOwnership{Identity: identity, Path: directory, TransactionID: plan.TransactionID} + if err := writeDirectoryOwnership(root, index, record); err != nil { + return err + } + recorded = true + } identity, exists, err := inspectOwnedTargetDirectory(root, directory) if err != nil { return err @@ -93,6 +109,62 @@ func removeCreatedDirectories(root *os.Root, plan Plan) error { return nil } +func admitRecoverableTargetDirectory(root *os.Root, relativePath string) (string, bool, error) { + native := filepath.FromSlash(relativePath) + routeInfo, err := root.Lstat(native) + if errors.Is(err, fs.ErrNotExist) { + return "", false, nil + } + if err != nil || routeInfo.Mode()&os.ModeSymlink != 0 || !routeInfo.IsDir() || routeInfo.Mode()&(fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky) != 0 || routeInfo.Mode().Perm()&^0o755 != 0 { + return "", false, fmt.Errorf("repository target directory is unsafe") + } + owned, err := platformOwnedByCurrentUser(routeInfo) + if err != nil || !owned { + return "", false, fmt.Errorf("repository target directory is not owned by the current user") + } + directory, err := root.Open(native) + if err != nil { + return "", false, fmt.Errorf("open recoverable repository target directory") + } + defer directory.Close() + handleInfo, err := directory.Stat() + if err != nil || !os.SameFile(routeInfo, handleInfo) || handleInfo.Mode().Perm() != routeInfo.Mode().Perm() { + return "", false, fmt.Errorf("inspect recoverable repository target directory") + } + owned, err = platformOwnedByCurrentUser(handleInfo) + if err != nil || !owned { + return "", false, fmt.Errorf("repository target directory ownership changed during recovery admission") + } + identity, err := platformFileIdentity(handleInfo) + if err != nil { + return "", false, fmt.Errorf("repository target directory changed during recovery admission") + } + entries, err := directory.ReadDir(1) + if err != nil && !errors.Is(err, io.EOF) { + return "", false, fmt.Errorf("inspect recoverable repository target directory content") + } + if len(entries) != 0 { + return "", false, fmt.Errorf("unrecorded repository target directory is not empty") + } + current, err := root.Lstat(native) + if err != nil || !os.SameFile(handleInfo, current) { + return "", false, fmt.Errorf("repository target directory route changed during recovery admission") + } + if handleInfo.Mode().Perm() != 0o755 { + if err := directory.Chmod(0o755); err != nil { + return "", false, fmt.Errorf("set repository target directory mode") + } + if err := syncDirectory(root, path.Dir(relativePath)); err != nil { + return "", false, err + } + } + verifiedIdentity, exists, err := inspectOwnedTargetDirectory(root, relativePath) + if err != nil || !exists || verifiedIdentity != identity { + return "", false, fmt.Errorf("repository target directory changed after recovery admission") + } + return identity, true, nil +} + func inspectOwnedTargetDirectory(root *os.Root, relativePath string) (string, bool, error) { native := filepath.FromSlash(relativePath) routeInfo, err := root.Lstat(native) @@ -142,7 +214,7 @@ func writeDirectoryOwnership(root *os.Root, index int, record directoryOwnership if err != nil || len(content) > maximumDirectoryOwnershipBytes { return fmt.Errorf("encode repository target directory ownership") } - return writeOwnedFile(root, directoryOwnershipPath(index), content, 0o600) + return writeAtomicOwnedFile(root, directoryOwnershipPath(index), directoryOwnershipTempPath(index), content, 0o600) } func loadDirectoryOwnership(root *os.Root, plan Plan, index int) (directoryOwnership, bool, error) { @@ -209,3 +281,7 @@ func directoryOwnershipValue(record directoryOwnership) map[string]any { func directoryOwnershipPath(index int) string { return fmt.Sprintf("%s/directory-%04d.json", activeDirectory, index) } + +func directoryOwnershipTempPath(index int) string { + return fmt.Sprintf("%s/directory-%04d.tmp", activeDirectory, index) +} diff --git a/internal/kernel/repositorytransaction/execution.go b/internal/kernel/repositorytransaction/execution.go index ef896a3..94e5177 100644 --- a/internal/kernel/repositorytransaction/execution.go +++ b/internal/kernel/repositorytransaction/execution.go @@ -61,13 +61,14 @@ func (runtime engine) rollbackAfterFailure(ctx context.Context, root *os.Root, p if err := writeMarker(root, rolledBackMarker); err != nil { return Result{AppliedCountKnown: true, FailureClass: "terminal_marker_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil } - if err := runtime.archiveAndCleanupTerminal(root, plan, StateRolledBack); err != nil { + terminal := Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID} + if err := runtime.archiveAndCleanupTerminal(root, plan, terminal); err != nil { if errors.Is(err, errCleanupDurabilityUnknown) { return Result{AppliedCountKnown: true, FailureClass: "rolled_back_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil } return Result{AppliedCountKnown: true, FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil } - return Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID}, nil + return terminal, nil } func (runtime engine) rollbackPrefix(ctx context.Context, root *os.Root, plan Plan, prefix int) error { diff --git a/internal/kernel/repositorytransaction/filesystem.go b/internal/kernel/repositorytransaction/filesystem.go index c44b481..9d43af9 100644 --- a/internal/kernel/repositorytransaction/filesystem.go +++ b/internal/kernel/repositorytransaction/filesystem.go @@ -262,6 +262,42 @@ func writeOwnedFile(root *os.Root, relativePath string, content []byte, mode fs. return syncDirectory(root, path.Dir(relativePath)) } +func writeAtomicOwnedFile(root *os.Root, relativePath, temporaryPath string, content []byte, mode fs.FileMode) error { + if exists, err := pathExists(root, relativePath); err != nil { + return err + } else if exists { + return fmt.Errorf("repository transaction file already exists") + } + if err := discardOwnedTemporaryFile(root, temporaryPath); err != nil { + return err + } + if err := writeOwnedFile(root, temporaryPath, content, mode); err != nil { + return err + } + if err := root.Rename(filepath.FromSlash(temporaryPath), filepath.FromSlash(relativePath)); err != nil { + return fmt.Errorf("publish repository transaction file") + } + return syncDirectory(root, path.Dir(relativePath)) +} + +func discardOwnedTemporaryFile(root *os.Root, relativePath string) error { + info, err := root.Lstat(filepath.FromSlash(relativePath)) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode()&^fs.ModePerm != 0 { + return fmt.Errorf("repository transaction temporary file is unsafe") + } + owned, ownershipErr := platformOwnedByCurrentUser(info) + if ownershipErr != nil || !owned { + return fmt.Errorf("repository transaction temporary file is not owned") + } + if err := root.Remove(filepath.FromSlash(relativePath)); err != nil { + return fmt.Errorf("remove interrupted repository transaction temporary file") + } + return syncDirectory(root, path.Dir(relativePath)) +} + func readOwnedFile(root *os.Root, relativePath string, maximum int64) ([]byte, error) { file, err := openNoFollow(root, filepath.FromSlash(relativePath)) if err != nil { diff --git a/internal/kernel/repositorytransaction/invariant_test.go b/internal/kernel/repositorytransaction/invariant_test.go index dc5b20f..8f67772 100644 --- a/internal/kernel/repositorytransaction/invariant_test.go +++ b/internal/kernel/repositorytransaction/invariant_test.go @@ -3,6 +3,7 @@ package repositorytransaction import ( "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -58,7 +59,7 @@ func TestApplyDoesNotRemoveUnownedNeighbourTemporary(t *testing.T) { } } -func TestLateDirectoryIsNeverClaimedOrRemoved(t *testing.T) { +func TestNonEmptyLateDirectoryIsNeverClaimedOrRemoved(t *testing.T) { root := t.TempDir() plan, err := BuildPlan(context.Background(), root, []Target{{Path: "new/target.json", Content: []byte("desired\n"), Mode: 0o644}}) if err != nil { @@ -66,7 +67,7 @@ func TestLateDirectoryIsNeverClaimedOrRemoved(t *testing.T) { } runtime := engine{fault: func(point failurePoint, _ int) error { if point == faultAfterReady { - if err := os.Mkdir(filepath.Join(root, "new"), 0o755); err != nil { + if err := os.Mkdir(filepath.Join(root, "new"), 0o700); err != nil { return err } return os.WriteFile(filepath.Join(root, "new", "foreign.txt"), []byte("foreign\n"), 0o644) @@ -78,6 +79,9 @@ func TestLateDirectoryIsNeverClaimedOrRemoved(t *testing.T) { t.Fatalf("apply() result=%#v error=%v", result, err) } assertTestFile(t, root, "new/foreign.txt", "foreign\n", 0o644) + if info, err := os.Stat(filepath.Join(root, "new")); err != nil || info.Mode().Perm() != 0o700 { + t.Fatalf("rejected late directory mode=%v error=%v, want 0700", infoMode(info), err) + } } func TestDirectoryOwnershipRejectsInodeSubstitution(t *testing.T) { @@ -219,3 +223,177 @@ func TestRejectedApplyPreservesPreviousTerminalReceipt(t *testing.T) { t.Fatalf("Recover(first)=%#v, %v", replayed, err) } } + +func TestPreparingReplacementPreservesPreviousTerminalReceipt(t *testing.T) { + rootPath := t.TempDir() + first, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/first.json", Content: []byte("first\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), rootPath, first); err != nil || result.State != StateApplied { + t.Fatalf("Apply(first)=%#v, %v", result, err) + } + second, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/second.json", Content: []byte("second\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := prepareJournal(root, second); err != nil { + root.Close() + t.Fatal(err) + } + if err := stageObjects(root, second); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + if _, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/third.json", Content: []byte("third\n"), Mode: 0o644}}); !errors.Is(err, ErrRecoveryRequired) { + t.Fatalf("BuildPlan() error=%v, want recovery required", err) + } else if transactionID, ok := RecoveryTransactionID(err); !ok || transactionID != second.TransactionID { + t.Fatalf("RecoveryTransactionID()=%q,%t, want %q,true", transactionID, ok, second.TransactionID) + } + retained, err := ReadTerminalResult(context.Background(), rootPath, first.TransactionID) + if err != nil || retained.State != StateApplied { + t.Fatalf("ReadTerminalResult(first)=%#v, %v", retained, err) + } + rolledBack, err := Recover(context.Background(), rootPath, second.TransactionID, RecoveryRollback) + if err != nil || rolledBack.State != StateRolledBack { + t.Fatalf("Recover(second)=%#v, %v", rolledBack, err) + } + retained, err = ReadTerminalResult(context.Background(), rootPath, first.TransactionID) + if err != nil || retained.State != StateApplied { + t.Fatalf("ReadTerminalResult(first after rollback)=%#v, %v", retained, err) + } +} + +func TestReadyReplacementRetiresPreviousReceiptAndPreservesCompleteResult(t *testing.T) { + rootPath := t.TempDir() + first, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/first.json", Content: []byte("first\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), rootPath, first); err != nil || result.State != StateApplied { + t.Fatalf("Apply(first)=%#v, %v", result, err) + } + second, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/second.json", Content: []byte("second\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, _ int) error { + if point == faultAfterReady { + return errors.New("injected") + } + return nil + }} + result, err := runtime.apply(context.Background(), rootPath, second) + if err != nil || result.State != StateRolledBack || result.FailureClass != "injected_apply_failure" { + t.Fatalf("Apply(second)=%#v, %v", result, err) + } + if _, err := ReadTerminalResult(context.Background(), rootPath, first.TransactionID); err == nil { + t.Fatal("ready replacement retained the superseded terminal receipt") + } + retained, err := ReadTerminalResult(context.Background(), rootPath, second.TransactionID) + if err != nil || retained != result { + t.Fatalf("ReadTerminalResult(second)=%#v, %v, want %#v", retained, err, result) + } +} + +func TestRecoveryCompletesReadyReplacementWithPreviousReceipt(t *testing.T) { + rootPath := t.TempDir() + first, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/first.json", Content: []byte("first\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), rootPath, first); err != nil || result.State != StateApplied { + t.Fatalf("Apply(first)=%#v, %v", result, err) + } + second, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/second.json", Content: []byte("second\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := prepareJournal(root, second); err != nil { + root.Close() + t.Fatal(err) + } + if err := stageObjects(root, second); err != nil { + root.Close() + t.Fatal(err) + } + if err := writeMarker(root, readyMarker); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + result, err := Recover(context.Background(), rootPath, second.TransactionID, RecoveryRollback) + if err != nil || result.State != StateRolledBack || result.RecoveredBy != RecoveryRollback { + t.Fatalf("Recover(second)=%#v, %v", result, err) + } + if _, err := ReadTerminalResult(context.Background(), rootPath, first.TransactionID); err == nil { + t.Fatal("ready recovery retained the superseded terminal receipt") + } + retained, err := ReadTerminalResult(context.Background(), rootPath, second.TransactionID) + if err != nil || retained != result { + t.Fatalf("ReadTerminalResult(second)=%#v, %v, want %#v", retained, err, result) + } +} + +func TestApplyCompletesInterruptedTerminalRetirement(t *testing.T) { + for _, receiptPresent := range []bool{true, false} { + t.Run(fmt.Sprintf("receipt-present=%t", receiptPresent), func(t *testing.T) { + rootPath := t.TempDir() + first, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/first.json", Content: []byte("first\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), rootPath, first); err != nil || result.State != StateApplied { + t.Fatalf("Apply(first)=%#v, %v", result, err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + terminalPath := terminalTombstonePath(first.TransactionID, StateApplied) + retiredPath := retiredTerminalTombstonePath(first.TransactionID, StateApplied) + if err := root.Rename(filepath.FromSlash(terminalPath), filepath.FromSlash(retiredPath)); err != nil { + root.Close() + t.Fatal(err) + } + if !receiptPresent { + if err := root.Remove(filepath.FromSlash(retiredPath + "/" + terminalReceiptName)); err != nil { + root.Close() + t.Fatal(err) + } + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + + if receiptPresent { + retained, err := ReadTerminalResult(context.Background(), rootPath, first.TransactionID) + if err != nil || retained.State != StateApplied { + t.Fatalf("ReadTerminalResult()=%#v, %v", retained, err) + } + } + second, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/second.json", Content: []byte("second\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), rootPath, second); err != nil || result.State != StateApplied { + t.Fatalf("Apply(second)=%#v, %v", result, err) + } + assertTestFile(t, rootPath, "proofkit/second.json", "second\n", 0o644) + assertNoPendingTransaction(t, rootPath) + }) + } +} diff --git a/internal/kernel/repositorytransaction/journal.go b/internal/kernel/repositorytransaction/journal.go index 4b5a2f9..d81f60b 100644 --- a/internal/kernel/repositorytransaction/journal.go +++ b/internal/kernel/repositorytransaction/journal.go @@ -127,5 +127,5 @@ func markerExists(root *os.Root, marker string) (bool, error) { } func writeMarker(root *os.Root, marker string) error { - return writeOwnedFile(root, marker, nil, 0o600) + return writeAtomicOwnedFile(root, marker, marker+".tmp", nil, 0o600) } diff --git a/internal/kernel/repositorytransaction/journal_admission.go b/internal/kernel/repositorytransaction/journal_admission.go index 69d2262..2d04110 100644 --- a/internal/kernel/repositorytransaction/journal_admission.go +++ b/internal/kernel/repositorytransaction/journal_admission.go @@ -185,7 +185,8 @@ func validatePlanShape(plan Plan) error { return fmt.Errorf("repository transaction exceeds the aggregate byte limit") } } - if err := validatePortablePathSet(paths); err != nil { + portablePaths := append(append([]string(nil), paths...), plan.CreatedDirectories...) + if err := validatePortablePathSet(portablePaths); err != nil { return fmt.Errorf("repository transaction paths have conflicting portable identities: %w", err) } for _, directory := range plan.CreatedDirectories { @@ -194,7 +195,7 @@ func validatePlanShape(plan Plan) error { } ownsTarget := false for _, operation := range plan.Operations { - if pathWithin(operation.Path, directory) { + if isLexicalDescendant(operation.Path, directory) { ownsTarget = true break } diff --git a/internal/kernel/repositorytransaction/output_admission.go b/internal/kernel/repositorytransaction/output_admission.go new file mode 100644 index 0000000..3e4415a --- /dev/null +++ b/internal/kernel/repositorytransaction/output_admission.go @@ -0,0 +1,147 @@ +package repositorytransaction + +import ( + "bytes" + "fmt" + "slices" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +var resultStateSet = map[string]struct{}{ + StateApplied: {}, + StateAlreadySatisfied: {}, + StateCleanupRequired: {}, + StateDurabilityUnknown: {}, + StateRecoveryRequired: {}, + StateRolledBack: {}, +} + +// AdmitPlanOutput validates the complete public projection of a repository +// transaction plan. The admitted result is descriptive and intentionally +// lacks the private payload bytes required for execution. +func AdmitPlanOutput(raw any) (Plan, error) { + record, ok := raw.(map[string]any) + if !ok { + return Plan{}, fmt.Errorf("repository transaction plan must be an object") + } + if err := admit.KnownKeys(record, []string{"createdDirectories", "desiredStateId", "nonClaims", "operations", "rootId", "schemaVersion", "transactionId", "transactionKind"}, "repository transaction plan"); err != nil { + return Plan{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["transactionKind"] != "proofkit.repository-write-plan" { + return Plan{}, fmt.Errorf("repository transaction plan identity is invalid") + } + nonClaims, err := admit.PreserveSortedTextArray(record["nonClaims"], "repository transaction plan nonClaims", false) + if err != nil || !slices.Equal(nonClaims, boundaryNonClaims) { + return Plan{}, fmt.Errorf("repository transaction plan nonClaims are invalid") + } + journal := map[string]any{ + "createdDirectories": record["createdDirectories"], + "desiredStateId": record["desiredStateId"], + "journalKind": "proofkit.repository-write-journal", + "operations": record["operations"], + "rootId": record["rootId"], + "schemaVersion": record["schemaVersion"], + "transactionId": record["transactionId"], + } + plan, err := admitJournal(journal) + if err != nil { + return Plan{}, err + } + actual, err := stablejson.Marshal(record) + if err != nil { + return Plan{}, fmt.Errorf("encode repository transaction plan") + } + expected, err := stablejson.Marshal(plan.JSONValue()) + if err != nil || !bytes.Equal(actual, expected) { + return Plan{}, fmt.Errorf("repository transaction plan is not canonical") + } + return plan, nil +} + +// AdmitResultOutput validates the public transaction-result state relation. +func AdmitResultOutput(raw any) (Result, error) { + record, ok := raw.(map[string]any) + if !ok { + return Result{}, fmt.Errorf("repository transaction result must be an object") + } + if err := admit.KnownKeys(record, []string{"appliedCount", "failureClass", "nonClaims", "recoveredBy", "schemaVersion", "state", "transactionId"}, "repository transaction result"); err != nil { + return Result{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return Result{}, fmt.Errorf("repository transaction result schemaVersion is invalid") + } + nonClaims, err := admit.PreserveSortedTextArray(record["nonClaims"], "repository transaction result nonClaims", false) + if err != nil || !slices.Equal(nonClaims, boundaryNonClaims) { + return Result{}, fmt.Errorf("repository transaction result nonClaims are invalid") + } + state, err := admit.Enum(record["state"], resultStateSet, "repository transaction result state") + if err != nil { + return Result{}, err + } + result := Result{State: state} + if record["appliedCount"] != nil { + count, err := admit.CanonicalInteger(record["appliedCount"], "repository transaction result appliedCount") + if err != nil || count < 0 || count > MaximumOperations { + return Result{}, fmt.Errorf("repository transaction result appliedCount is invalid") + } + result.AppliedCount = int(count) + result.AppliedCountKnown = true + } + if record["failureClass"] != nil { + failureClass, err := admit.NonEmptyText(record["failureClass"], "repository transaction result failureClass") + if err != nil { + return Result{}, err + } + result.FailureClass = failureClass + } + if record["recoveredBy"] != nil { + recoveredBy, err := admit.Enum(record["recoveredBy"], map[string]struct{}{RecoveryResume: {}, RecoveryRollback: {}}, "repository transaction result recoveredBy") + if err != nil { + return Result{}, err + } + result.RecoveredBy = recoveredBy + } + if record["transactionId"] != nil { + transactionID, err := admit.SHA256Ref(record["transactionId"], "repository transaction result transactionId") + if err != nil { + return Result{}, err + } + result.TransactionID = transactionID + } + if err := validateResultRelation(result); err != nil { + return Result{}, err + } + actual, err := stablejson.Marshal(record) + if err != nil { + return Result{}, fmt.Errorf("encode repository transaction result") + } + expected, err := stablejson.Marshal(result.JSONValue()) + if err != nil || !bytes.Equal(actual, expected) { + return Result{}, fmt.Errorf("repository transaction result is not canonical") + } + return result, nil +} + +func validateResultRelation(result Result) error { + switch result.State { + case StateApplied: + if !result.AppliedCountKnown || result.TransactionID == "" || result.FailureClass != "" || result.RecoveredBy == RecoveryRollback { + return fmt.Errorf("applied repository transaction result is inconsistent") + } + case StateAlreadySatisfied: + if !result.AppliedCountKnown || result.AppliedCount != 0 || result.TransactionID == "" || result.FailureClass != "" || result.RecoveredBy != "" { + return fmt.Errorf("already-satisfied repository transaction result is inconsistent") + } + case StateRolledBack: + if !result.AppliedCountKnown || result.AppliedCount != 0 || result.RecoveredBy == RecoveryResume { + return fmt.Errorf("rolled-back repository transaction result is inconsistent") + } + case StateCleanupRequired, StateDurabilityUnknown, StateRecoveryRequired: + if result.FailureClass == "" { + return fmt.Errorf("non-terminal repository transaction result requires a failure class") + } + } + return nil +} diff --git a/internal/kernel/repositorytransaction/output_admission_test.go b/internal/kernel/repositorytransaction/output_admission_test.go new file mode 100644 index 0000000..36f405b --- /dev/null +++ b/internal/kernel/repositorytransaction/output_admission_test.go @@ -0,0 +1,54 @@ +package repositorytransaction + +import ( + "context" + "strings" + "testing" +) + +func TestPlanAndResultOutputAdmissionRejectSemanticMutants(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/record.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if admitted, err := AdmitPlanOutput(plan.JSONValue()); err != nil || admitted.TransactionID != plan.TransactionID { + t.Fatalf("AdmitPlanOutput() plan=%#v error=%v", admitted, err) + } + planMutant := plan.JSONValue() + planMutant["transactionId"] = "sha256:" + strings.Repeat("0", 64) + if _, err := AdmitPlanOutput(planMutant); err == nil { + t.Fatal("AdmitPlanOutput() admitted a forged transaction identity") + } + + transactionID := plan.TransactionID + results := []Result{ + {AppliedCount: 1, AppliedCountKnown: true, State: StateApplied, TransactionID: transactionID}, + {AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: transactionID}, + {AppliedCountKnown: true, RecoveredBy: RecoveryRollback, State: StateRolledBack, TransactionID: transactionID}, + {FailureClass: "ambiguous_target_state", State: StateRecoveryRequired, TransactionID: transactionID}, + } + for _, result := range results { + if admitted, err := AdmitResultOutput(result.JSONValue()); err != nil || admitted.State != result.State { + t.Fatalf("AdmitResultOutput(%s) result=%#v error=%v", result.State, admitted, err) + } + } + mutant := results[1].JSONValue() + mutant["appliedCount"] = 1 + if _, err := AdmitResultOutput(mutant); err == nil { + t.Fatal("AdmitResultOutput() admitted applied work in an already-satisfied result") + } +} + +func TestAdmitPlanOutputRejectsPortableAliasAsLexicalParent(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "nested/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + record := plan.JSONValue() + record["createdDirectories"] = []any{"Nested"} + if _, err := AdmitPlanOutput(record); err == nil || !strings.Contains(err.Error(), "portable identities") { + t.Fatalf("AdmitPlanOutput() error=%v, want portable parent-alias rejection", err) + } +} diff --git a/internal/kernel/repositorytransaction/plan.go b/internal/kernel/repositorytransaction/plan.go index 8c76d45..a2f946a 100644 --- a/internal/kernel/repositorytransaction/plan.go +++ b/internal/kernel/repositorytransaction/plan.go @@ -8,6 +8,7 @@ import ( "io/fs" "path" "sort" + "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" @@ -142,6 +143,9 @@ func BuildPlan(ctx context.Context, rootPath string, targets []Target) (Plan, er return Plan{}, fmt.Errorf("derive repository transaction identity: %w", err) } plan.TransactionID = transactionID + if _, err := AdmitPlanOutput(plan.JSONValue()); err != nil { + return Plan{}, fmt.Errorf("admit repository transaction plan output: %w", err) + } return plan, nil } @@ -206,12 +210,11 @@ func equalSnapshot(left, right Snapshot) bool { return left.Exists == right.Exists && left.ByteCount == right.ByteCount && left.Mode == right.Mode && left.SHA256 == right.SHA256 } -func pathWithin(candidate, directory string) bool { - within, err := pathidentity.Within(candidate, directory) - return err == nil && within -} - func pathsOverlap(left, right string) bool { overlaps, err := pathidentity.Overlaps(left, right) return err != nil || overlaps } + +func isLexicalDescendant(candidate, directory string) bool { + return strings.HasPrefix(candidate, directory+"/") +} diff --git a/internal/kernel/repositorytransaction/recovery.go b/internal/kernel/repositorytransaction/recovery.go index 1bbd75c..609d41a 100644 --- a/internal/kernel/repositorytransaction/recovery.go +++ b/internal/kernel/repositorytransaction/recovery.go @@ -60,7 +60,10 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti } return Result{}, fmt.Errorf("repository transaction recovery state is absent") } - if len(entries) != 1 { + if len(entries) > 2 { + return Result{FailureClass: "conflicting_control_state", State: StateRecoveryRequired}, nil + } + if _, _, err := findTerminalControlEntry(entries); err != nil { return Result{FailureClass: "conflicting_control_state", State: StateRecoveryRequired}, nil } if err := validatePrivateDirectory(root, activeDirectory, 0o700); err != nil { @@ -117,6 +120,9 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } + if err := discardTerminalReceipt(root); err != nil { + return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } return runtime.cleanupRecovered(root, plan, StateApplied, action) } if rolledBack { @@ -130,6 +136,9 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } + if err := discardTerminalReceipt(root); err != nil { + return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } if removeCreatedDirectories(root, plan) != nil { return Result{FailureClass: "rolled_back_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil } @@ -172,6 +181,9 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } + if err := discardTerminalReceipt(root); err != nil { + return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } if err := runtime.applyForward(context.WithoutCancel(ctx), root, plan, prefix); err != nil { return resultWithObservedPrefix(root, plan, Result{FailureClass: "resume_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}), nil } @@ -183,6 +195,9 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } + if err := discardTerminalReceipt(root); err != nil { + return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } if err := runtime.rollbackPrefix(context.WithoutCancel(ctx), root, plan, prefix); err != nil { return resultWithObservedPrefix(root, plan, Result{FailureClass: "rollback_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: transactionID}), nil } @@ -202,38 +217,35 @@ func (runtime engine) cleanupRecovered(root *os.Root, plan Plan, state, action s if err := removeInterruptedTemporary(root, plan); err != nil { return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, FailureClass: "temporary_cleanup_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil } - if err := runtime.archiveAndCleanupTerminal(root, plan, state); err != nil { + terminal := Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, RecoveredBy: action, State: state, TransactionID: plan.TransactionID} + if err := runtime.archiveAndCleanupTerminal(root, plan, terminal); err != nil { if errors.Is(err, errCleanupDurabilityUnknown) { return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, FailureClass: state + "_cleanup_durability_unknown", RecoveredBy: action, State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil } return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, FailureClass: "cleanup_failed", RecoveredBy: action, State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil } - return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, RecoveredBy: action, State: state, TransactionID: plan.TransactionID}, nil + return terminal, nil } func (runtime engine) recoverTerminalTombstone(root *os.Root, entries []fs.DirEntry, transactionID, action string) (Result, bool, error) { if len(entries) != 1 { return Result{}, false, nil } - entry := entries[0] - if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + terminal, found, err := findTerminalControlEntry(entries) + if err != nil || !found || terminal.TransactionID != transactionID { return Result{}, false, nil } - appliedPath := terminalTombstonePath(transactionID, StateApplied) - rolledBackPath := terminalTombstonePath(transactionID, StateRolledBack) - path := ControlDirectory + "/" + entry.Name() - state := "" - switch path { - case appliedPath: + path := ControlDirectory + "/" + terminal.Entry.Name() + state := terminal.State + switch state { + case StateApplied: if action != RecoveryResume { return Result{FailureClass: "committed_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, true, nil } - state = StateApplied - case rolledBackPath: + case StateRolledBack: if action != RecoveryRollback { return Result{FailureClass: "rolled_back_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, true, nil } - state = StateRolledBack default: return Result{}, false, nil } @@ -247,5 +259,5 @@ func (runtime engine) recoverTerminalTombstone(root *os.Root, entries []fs.DirEn } return Result{FailureClass: "cleanup_failed", RecoveredBy: action, State: StateCleanupRequired, TransactionID: transactionID}, true, nil } - return Result{AppliedCount: receipt.AppliedCount, AppliedCountKnown: true, RecoveredBy: action, State: state, TransactionID: transactionID}, true, nil + return Result{AppliedCount: receipt.AppliedCount, AppliedCountKnown: true, FailureClass: receipt.FailureClass, RecoveredBy: action, State: state, TransactionID: transactionID}, true, nil } diff --git a/internal/kernel/repositorytransaction/state_machine_test.go b/internal/kernel/repositorytransaction/state_machine_test.go index 28b97b4..585d826 100644 --- a/internal/kernel/repositorytransaction/state_machine_test.go +++ b/internal/kernel/repositorytransaction/state_machine_test.go @@ -53,6 +53,14 @@ func TestApplyAcceptsOriginalPlanAfterLostAcknowledgement(t *testing.T) { if err != nil || result.State != StateAlreadySatisfied || result.TransactionID != plan.TransactionID { t.Fatalf("retry Apply() result=%#v error=%v", result, err) } + retained, err := ReadTerminalResult(context.Background(), root, plan.TransactionID) + if err != nil || retained.State != StateApplied || retained.TransactionID != plan.TransactionID || !retained.AppliedCountKnown || retained.AppliedCount != 1 { + t.Fatalf("ReadTerminalResult() result=%#v error=%v", retained, err) + } + wrongTransaction := "sha256:" + strings.Repeat("f", 64) + if _, err := ReadTerminalResult(context.Background(), root, wrongTransaction); err == nil { + t.Fatal("ReadTerminalResult() admitted an unrelated transaction identity") + } } func TestApplyCancellationRespectsMutationBoundary(t *testing.T) { diff --git a/internal/kernel/repositorytransaction/terminal_receipt.go b/internal/kernel/repositorytransaction/terminal_receipt.go index 7221fb2..abb5fbe 100644 --- a/internal/kernel/repositorytransaction/terminal_receipt.go +++ b/internal/kernel/repositorytransaction/terminal_receipt.go @@ -2,6 +2,7 @@ package repositorytransaction import ( "bytes" + "context" "encoding/json" "fmt" "os" @@ -14,16 +15,80 @@ import ( const ( maximumTerminalReceiptBytes = 2048 terminalReceiptName = "terminal.json" + terminalReceiptTempName = "terminal.tmp" ) type terminalReceipt struct { AppliedCount int + FailureClass string + RecoveredBy string State string TransactionID string } -func ensureTerminalReceipt(root *os.Root, plan Plan, state string) error { - want := terminalReceipt{AppliedCount: prefixForState(plan, state), State: state, TransactionID: plan.TransactionID} +// ReadTerminalResult returns the retained terminal result for one exact +// transaction without consuming it. Callers use this to distinguish a lost +// acknowledgement from an unrelated already-satisfied desired state. +func ReadTerminalResult(ctx context.Context, rootPath, transactionID string) (Result, error) { + admittedID, err := admit.SHA256Ref(transactionID, "repository transaction terminal transactionId") + if err != nil { + return Result{}, err + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("read repository transaction terminal result cancelled: %w", err) + } + root, _, err := openRepository(rootPath) + if err != nil { + return Result{}, err + } + defer root.Close() + lock, exists, err := acquireExistingTransactionLock(root) + if err != nil { + return Result{}, err + } + if !exists { + return Result{}, fmt.Errorf("repository transaction terminal result is absent") + } + defer lock.release() + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("read repository transaction terminal result cancelled: %w", err) + } + entries, err := controlEntries(root) + if err != nil { + return Result{}, err + } + terminal, found, err := findTerminalControlEntry(entries) + if err != nil || !found || terminal.TransactionID != admittedID { + return Result{}, fmt.Errorf("repository transaction terminal identity does not match retained state") + } + path := ControlDirectory + "/" + terminal.Entry.Name() + children, err := transactionEntries(root, path) + if err != nil || len(children) != 1 || children[0].Name() != terminalReceiptName { + return Result{}, fmt.Errorf("repository transaction terminal result is invalid") + } + receipt, err := loadTerminalReceipt(root, path) + if err != nil || receipt.TransactionID != admittedID || receipt.State != terminal.State { + return Result{}, fmt.Errorf("repository transaction terminal result is invalid") + } + return receipt.result(), nil +} + +func (receipt terminalReceipt) result() Result { + return Result{ + AppliedCount: receipt.AppliedCount, + AppliedCountKnown: true, + FailureClass: receipt.FailureClass, + RecoveredBy: receipt.RecoveredBy, + State: receipt.State, + TransactionID: receipt.TransactionID, + } +} + +func ensureTerminalReceipt(root *os.Root, plan Plan, result Result) error { + want, err := terminalReceiptFromResult(plan, result) + if err != nil { + return err + } path := activeDirectory + "/" + terminalReceiptName if exists, err := pathExists(root, path); err != nil { return err @@ -32,13 +97,29 @@ func ensureTerminalReceipt(root *os.Root, plan Plan, state string) error { if err != nil || got != want { return fmt.Errorf("repository transaction terminal receipt contradicts terminal state") } - return nil + return discardOwnedTemporaryFile(root, activeDirectory+"/"+terminalReceiptTempName) } content, err := stablejson.Marshal(terminalReceiptValue(want)) if err != nil || len(content) > maximumTerminalReceiptBytes { return fmt.Errorf("encode repository transaction terminal receipt") } - return writeOwnedFile(root, path, content, 0o600) + return writeAtomicOwnedFile(root, path, activeDirectory+"/"+terminalReceiptTempName, content, 0o600) +} + +func terminalReceiptFromResult(plan Plan, result Result) (terminalReceipt, error) { + if result.State != StateApplied && result.State != StateRolledBack { + return terminalReceipt{}, fmt.Errorf("repository transaction terminal state is invalid") + } + if err := validateResultRelation(result); err != nil || !result.AppliedCountKnown || result.TransactionID != plan.TransactionID || result.AppliedCount != prefixForState(plan, result.State) { + return terminalReceipt{}, fmt.Errorf("repository transaction terminal result does not match its plan") + } + return terminalReceipt{ + AppliedCount: result.AppliedCount, + FailureClass: result.FailureClass, + RecoveredBy: result.RecoveredBy, + State: result.State, + TransactionID: result.TransactionID, + }, nil } func loadTerminalReceipt(root *os.Root, directory string) (terminalReceipt, error) { @@ -66,7 +147,7 @@ func admitTerminalReceipt(raw any) (terminalReceipt, error) { if !ok { return terminalReceipt{}, fmt.Errorf("repository transaction terminal receipt must be an object") } - if err := admit.KnownKeys(record, []string{"appliedCount", "schemaVersion", "state", "terminalKind", "transactionId"}, "repository transaction terminal receipt"); err != nil { + if err := admit.KnownKeys(record, []string{"appliedCount", "failureClass", "recoveredBy", "schemaVersion", "state", "terminalKind", "transactionId"}, "repository transaction terminal receipt"); err != nil { return terminalReceipt{}, err } if record["terminalKind"] != "proofkit.repository-terminal-receipt" || !admit.JSONNumberEquals(record["schemaVersion"], 1) { @@ -87,12 +168,39 @@ func admitTerminalReceipt(raw any) (terminalReceipt, error) { if err != nil { return terminalReceipt{}, err } - return terminalReceipt{AppliedCount: int(appliedCount), State: state, TransactionID: transactionID}, nil + failureClass := "" + if record["failureClass"] != nil { + failureClass, err = admit.NonEmptyText(record["failureClass"], "repository transaction terminal receipt failureClass") + if err != nil { + return terminalReceipt{}, err + } + } + recoveredBy := "" + if record["recoveredBy"] != nil { + recoveredBy, err = admit.Enum(record["recoveredBy"], map[string]struct{}{RecoveryResume: {}, RecoveryRollback: {}}, "repository transaction terminal receipt recoveredBy") + if err != nil { + return terminalReceipt{}, err + } + } + result := Result{ + AppliedCount: int(appliedCount), + AppliedCountKnown: true, + FailureClass: failureClass, + RecoveredBy: recoveredBy, + State: state, + TransactionID: transactionID, + } + if err := validateResultRelation(result); err != nil { + return terminalReceipt{}, fmt.Errorf("repository transaction terminal receipt result is invalid") + } + return terminalReceipt{AppliedCount: int(appliedCount), FailureClass: failureClass, RecoveredBy: recoveredBy, State: state, TransactionID: transactionID}, nil } func terminalReceiptValue(receipt terminalReceipt) map[string]any { return map[string]any{ "appliedCount": json.Number(intString(receipt.AppliedCount)), + "failureClass": nullableText(receipt.FailureClass), + "recoveredBy": nullableText(receipt.RecoveredBy), "schemaVersion": json.Number("1"), "state": receipt.State, "terminalKind": "proofkit.repository-terminal-receipt", diff --git a/internal/kernel/repositorytransaction/transaction.go b/internal/kernel/repositorytransaction/transaction.go index a8afaa7..179e317 100644 --- a/internal/kernel/repositorytransaction/transaction.go +++ b/internal/kernel/repositorytransaction/transaction.go @@ -104,9 +104,6 @@ func (runtime engine) apply(ctx context.Context, rootPath string, plan Plan) (Re if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction apply cancelled: %w", err) } - if err := discardTerminalReceipt(root); err != nil { - return Result{}, err - } if err := prepareJournal(root, plan); err != nil { return runtime.finishPreparingFailure(root, plan, "journal_prepare_failed") } @@ -131,6 +128,9 @@ func (runtime engine) apply(ctx context.Context, rootPath string, plan Plan) (Re if err := writeMarker(root, readyMarker); err != nil { return runtime.finishPreparingFailure(root, plan, "ready_marker_failed") } + if err := discardTerminalReceipt(root); err != nil { + return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } if err := runtime.callFault(faultAfterReady, -1); err != nil { return runtime.rollbackAfterFailure(context.WithoutCancel(ctx), root, plan, "injected_apply_failure") } @@ -150,13 +150,14 @@ func (runtime engine) apply(ctx context.Context, rootPath string, plan Plan) (Re if err := runtime.callFault(faultBeforeCleanup, -1); err != nil { return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "injected_cleanup_failure", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil } - if err := runtime.archiveAndCleanupTerminal(root, plan, StateApplied); err != nil { + terminal := Result{AppliedCount: changed, AppliedCountKnown: true, State: StateApplied, TransactionID: plan.TransactionID} + if err := runtime.archiveAndCleanupTerminal(root, plan, terminal); err != nil { if errors.Is(err, errCleanupDurabilityUnknown) { return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "applied_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil } return Result{AppliedCount: changed, AppliedCountKnown: true, FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil } - return Result{AppliedCount: changed, AppliedCountKnown: true, State: StateApplied, TransactionID: plan.TransactionID}, nil + return terminal, nil } func (runtime engine) callFault(point failurePoint, index int) error { diff --git a/internal/kernel/repositorytransaction/transaction_test.go b/internal/kernel/repositorytransaction/transaction_test.go index 3ead351..c9ebdcd 100644 --- a/internal/kernel/repositorytransaction/transaction_test.go +++ b/internal/kernel/repositorytransaction/transaction_test.go @@ -333,7 +333,7 @@ func TestRecoverCompletesPartiallyDeletedTerminalTombstone(t *testing.T) { root.Close() t.Fatal(err) } - tombstone, err := archiveTerminal(root, plan, StateApplied) + tombstone, err := archiveTerminal(root, plan, Result{AppliedCount: 1, AppliedCountKnown: true, State: StateApplied, TransactionID: plan.TransactionID}) if err != nil { root.Close() t.Fatal(err) @@ -456,6 +456,160 @@ func TestProcessDeathAfterRenameIsRecoverable(t *testing.T) { assertNoPendingTransaction(t, rootPath) } +func TestRecoverClosesDirectoryCreationCrashGap(t *testing.T) { + for _, action := range []string{RecoveryResume, RecoveryRollback} { + t.Run(action, func(t *testing.T) { + rootPath := t.TempDir() + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "new/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := prepareJournal(root, plan); err != nil { + root.Close() + t.Fatal(err) + } + if err := stageObjects(root, plan); err != nil { + root.Close() + t.Fatal(err) + } + if err := writeMarker(root, readyMarker); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Mkdir("new", 0o700); err != nil { + root.Close() + t.Fatal(err) + } + if err := writeOwnedFile(root, directoryOwnershipTempPath(0), []byte("{"), 0o600); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + + result, err := Recover(context.Background(), rootPath, plan.TransactionID, action) + if err != nil { + t.Fatal(err) + } + wantState := StateApplied + if action == RecoveryRollback { + wantState = StateRolledBack + } + if result.State != wantState || result.RecoveredBy != action { + t.Fatalf("Recover() result=%#v", result) + } + if action == RecoveryResume { + assertTestFile(t, rootPath, "new/target.json", "desired\n", 0o644) + } else if _, err := os.Stat(filepath.Join(rootPath, "new")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rollback retained crash-created directory: %v", err) + } + assertNoPendingTransaction(t, rootPath) + }) + } +} + +func TestRecoverClosesTerminalReceiptPublicationCrashGap(t *testing.T) { + rootPath := t.TempDir() + if err := os.Mkdir(filepath.Join(rootPath, "proofkit"), 0o755); err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 1) + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := writeMarker(root, committedMarker); err != nil { + root.Close() + t.Fatal(err) + } + if err := writeOwnedFile(root, activeDirectory+"/"+terminalReceiptTempName, []byte("{"), 0o600); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil || result.State != StateApplied || result.TransactionID != plan.TransactionID { + t.Fatalf("Recover() result=%#v error=%v", result, err) + } + assertTestFile(t, rootPath, "proofkit/target.json", "desired\n", 0o644) + assertNoPendingTransaction(t, rootPath) +} + +func TestRecoverClosesMarkerPublicationCrashGaps(t *testing.T) { + tests := []struct { + action string + marker string + published bool + wantContent string + wantState string + }{ + {action: RecoveryRollback, marker: readyMarker, wantContent: "before\n", wantState: StateRolledBack}, + {action: RecoveryResume, marker: committedMarker, published: true, wantContent: "after\n", wantState: StateApplied}, + {action: RecoveryRollback, marker: rolledBackMarker, wantContent: "before\n", wantState: StateRolledBack}, + } + for _, test := range tests { + t.Run(test.marker, func(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/target.json", "before\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/target.json", Content: []byte("after\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := prepareJournal(root, plan); err != nil { + root.Close() + t.Fatal(err) + } + if err := stageObjects(root, plan); err != nil { + root.Close() + t.Fatal(err) + } + if test.marker != readyMarker { + if err := writeMarker(root, readyMarker); err != nil { + root.Close() + t.Fatal(err) + } + } + if test.published { + operation := plan.Operations[0] + if err := publishContent(root, plan, 0, operation.Before, operation.afterContent, operation.After.Mode); err != nil { + root.Close() + t.Fatal(err) + } + } + if err := writeOwnedFile(root, test.marker+".tmp", nil, 0o600); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + + result, err := Recover(context.Background(), rootPath, plan.TransactionID, test.action) + if err != nil || result.State != test.wantState { + t.Fatalf("Recover() result=%#v error=%v", result, err) + } + assertTestFile(t, rootPath, "proofkit/target.json", test.wantContent, 0o644) + assertNoPendingTransaction(t, rootPath) + }) + } +} + func runTransactionCrashHelper(t *testing.T) { rootPath := os.Getenv("PROOFKIT_TRANSACTION_CRASH_ROOT") plan, err := BuildPlan(context.Background(), rootPath, crashHelperTargets()) diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 55ff794..ab0e86c 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -640,6 +640,8 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-terminal-state"}: { "TestAppliedTerminalReceiptReplaysCompleteResult", "TestApplyExecutesFrozenPlan", + "TestPreparingReplacementPreservesPreviousTerminalReceipt", + "TestReadyReplacementRetiresPreviousReceiptAndPreservesCompleteResult", "TestRecoveryActionAndTerminalReceiptAreStable", "TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity", }, diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index e843748..df22e75 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -111,7 +111,7 @@ "rootDefinitionDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -141,7 +141,7 @@ "rootDefinitionDigest": "sha256:4b58bd4e89da98ad79c5e6faf32fe58766313117ad2558b602bfed853de0cf7e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -237,7 +237,7 @@ "rootDefinitionDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -267,7 +267,7 @@ "rootDefinitionDigest": "sha256:fc0e2d547a5fd54ebebe9d237a48ae15418b32d3d28fa5185aae64a0fba9b255", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -369,7 +369,7 @@ "rootDefinitionDigest": "sha256:06129ef857ffb11351535c769f9ca207522a08f3c5bf2da52c6f5fff0b1ac757", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:ee1adf4ad0060354205eb200e6fd8449b32b4d9257a6c06586b5d014124eda30", + "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 8993410..2a85c31 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -5914,6 +5914,14 @@ "selector": "TestAppliedTerminalReceiptReplaysCompleteResult", "command": "go test ./internal/kernel/repositorytransaction -run '^TestAppliedTerminalReceiptReplaysCompleteResult$'" }, + { + "selector": "TestPreparingReplacementPreservesPreviousTerminalReceipt", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPreparingReplacementPreservesPreviousTerminalReceipt$'" + }, + { + "selector": "TestReadyReplacementRetiresPreviousReceiptAndPreservesCompleteResult", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestReadyReplacementRetiresPreviousReceiptAndPreservesCompleteResult$'" + }, { "selector": "TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity", "command": "go test ./internal/kernel/repositorytransaction -run '^TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity$'" From dd22fc1fc8dd830bf62ad33e8cfd02de1029cf4d Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 19:41:12 +0200 Subject: [PATCH 3/5] fix: harden transaction recovery invariants --- internal/app/cli_contract_test.go | 2 +- internal/app/command_contract_generated.go | 8 +- .../app/testdata/v0.8-wire-observations.json | 12 +- .../adoptionmaterialization_test.go | 62 +++++++ .../command/adoptionmaterialization/model.go | 1 + .../output_admission.go | 9 + .../stackpreset/preset_ids_generated.go | 2 +- .../repositorytransaction/control_state.go | 31 ++-- .../directory_ownership.go | 8 + .../kernel/repositorytransaction/execution.go | 45 ++++- .../repositorytransaction/filesystem.go | 119 +++++++++++-- .../repositorytransaction/invariant_test.go | 160 +++++++++++++++++- .../kernel/repositorytransaction/journal.go | 22 ++- .../journal_admission.go | 45 +++++ .../kernel/repositorytransaction/model.go | 1 + .../repositorytransaction/output_admission.go | 4 +- .../output_admission_test.go | 47 +++++ .../kernel/repositorytransaction/plan_test.go | 37 ++++ .../kernel/repositorytransaction/recovery.go | 57 ++++--- .../repositorytransaction/recovery_action.go | 93 ++++++++++ .../kernel/repositorytransaction/state.go | 7 + .../repositorytransaction/transaction.go | 3 +- .../repositorytransaction/transaction_test.go | 70 ++++++-- internal/tools/coveragemetrics/main.go | 6 +- proofkit/cli-contract.v2.json | 10 +- proofkit/requirement-bindings.json | 20 ++- 26 files changed, 779 insertions(+), 102 deletions(-) create mode 100644 internal/kernel/repositorytransaction/recovery_action.go diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index a0074da..10687ed 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "77dfd235e5a7404101cc8588e4eea718117a0e6dd020b407e353027ad51c8369" + cliContractPublicABISHA256 = "b7cc6ef91dd06ab8bd8073337034084d0fec5110d232a8e1a1f1e77055b4bec5" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index fde2d59..4db774e 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "df6613bdfb325bba2bc2cdd6260819057635978fcf4138b01fa2dc3613169459" +const commandContractSourceSHA256 = "026a20b2a4a4844222360f100600688707faed58b53a93bcc4f1a2cf108654c6" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,9 +12,9 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-materialize-apply": {InputContractSHA256: "sha256:7782c5ec512dc383edb9fc62fb64ea6109dd1ed49d9869b230ebe96f2aeec9fa", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:a1742452337ae99102291012ea8cc1157e15f7b96c728df8f1067aa24dbfdc70", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, - "adopt-materialize-plan": {InputContractSHA256: "sha256:4c769f5f13e04c8c57c6ac67c1805c55cfa350e890541153fd5d46fd2d850e86", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:84026c514413ebaa5f0b8fe91e02df63631ab87c742b1b0b9468fe8f84f97207", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, - "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:dddb3d69b6765c1549a8efc9c70962a109c60087e51db9e62b36dfa100628385", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, + "adopt-materialize-apply": {InputContractSHA256: "sha256:5b4a8043a3a4df5a520e0dfb90408eafd3ea024b79c31fd9f769a804a12c5b3b", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:b046c5bc0535a410366c6c793516cd9f2dd20b1ea1082901a81c740658ee5321", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:c1567aad5ea3bbf26effe1ae9d393024cd3e8b99ea3a608aeecd08a2848a61b9", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:b55914700a4aa7ba48de5beb7d6aa2e4650676f3a0300cdb0830bc749b22acd7", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, + "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:e722c36007389a0471b193e642041a9fb2cd6e4286ab6d7f5f097506e8ed0f2c", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "adoption-checklist": {InputContractSHA256: "sha256:4e6c4c9b369279837a5894c0b3f842a411dce529b91c91cb2d4ec63eb5ee4c2c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-checklist.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9d0d0e60f0935407fd31007d8502459663eb4c7228dc5e3c7727ae2c9907bdc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, diff --git a/internal/app/testdata/v0.8-wire-observations.json b/internal/app/testdata/v0.8-wire-observations.json index d813206..816bcd9 100644 --- a/internal/app/testdata/v0.8-wire-observations.json +++ b/internal/app/testdata/v0.8-wire-observations.json @@ -9,18 +9,18 @@ "changeRecordRef": "release/change-record.v2.json", "changeRecordSha256": "sha256:4c8434f7b77a5c623441b021a37b50786d56aba7f65091c38be5ed902231318d", "previousPublicAbiSha256": "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7", - "currentPublicAbiSha256": "sha256:77dfd235e5a7404101cc8588e4eea718117a0e6dd020b407e353027ad51c8369", + "currentPublicAbiSha256": "sha256:b7cc6ef91dd06ab8bd8073337034084d0fec5110d232a8e1a1f1e77055b4bec5", "addedCommandContracts": [ { "command": "adopt-materialize-apply", "route": ["adopt", "materialize", "apply"], "inputContract": { "contractId": "proofkit.adopt-materialize-apply.input.v1", - "contractSha256": "sha256:7782c5ec512dc383edb9fc62fb64ea6109dd1ed49d9869b230ebe96f2aeec9fa" + "contractSha256": "sha256:5b4a8043a3a4df5a520e0dfb90408eafd3ea024b79c31fd9f769a804a12c5b3b" }, "outputContract": { "contractId": "proofkit.adopt-materialize-apply.output.v1", - "contractSha256": "sha256:a1742452337ae99102291012ea8cc1157e15f7b96c728df8f1067aa24dbfdc70" + "contractSha256": "sha256:b046c5bc0535a410366c6c793516cd9f2dd20b1ea1082901a81c740658ee5321" } }, { @@ -28,11 +28,11 @@ "route": ["adopt", "materialize", "plan"], "inputContract": { "contractId": "proofkit.adopt-materialize-plan.input.v1", - "contractSha256": "sha256:4c769f5f13e04c8c57c6ac67c1805c55cfa350e890541153fd5d46fd2d850e86" + "contractSha256": "sha256:c1567aad5ea3bbf26effe1ae9d393024cd3e8b99ea3a608aeecd08a2848a61b9" }, "outputContract": { "contractId": "proofkit.adopt-materialize-plan.output.v1", - "contractSha256": "sha256:84026c514413ebaa5f0b8fe91e02df63631ab87c742b1b0b9468fe8f84f97207" + "contractSha256": "sha256:b55914700a4aa7ba48de5beb7d6aa2e4650676f3a0300cdb0830bc749b22acd7" } }, { @@ -40,7 +40,7 @@ "route": ["adopt", "materialize", "recover"], "outputContract": { "contractId": "proofkit.adopt-materialize-recover.output.v1", - "contractSha256": "sha256:dddb3d69b6765c1549a8efc9c70962a109c60087e51db9e62b36dfa100628385" + "contractSha256": "sha256:e722c36007389a0471b193e642041a9fb2cd6e4286ab6d7f5f097506e8ed0f2c" } } ], diff --git a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go index 4aa32cc..f651175 100644 --- a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go +++ b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go @@ -108,6 +108,68 @@ func TestMaterializationOutputAdmissionRejectsCrossOwnerMutants(t *testing.T) { } } +func TestReceiptAdmissionRejectsOperationAttributionMutants(t *testing.T) { + transactionID := "sha256:" + strings.Repeat("a", 64) + desiredStateID := "sha256:" + strings.Repeat("b", 64) + tests := []Receipt{ + { + ExpectedDesiredStateID: desiredStateID, + ExpectedTransactionID: transactionID, + NonClaims: mergedNonClaims(nil), + Operation: OperationApply, + State: ReceiptStatePassed, + TransactionResult: &repositorytransaction.Result{ + AppliedCount: 1, AppliedCountKnown: true, RecoveredBy: repositorytransaction.RecoveryResume, + State: repositorytransaction.StateApplied, TransactionID: transactionID, + }, + }, + { + ExpectedTransactionID: transactionID, + NonClaims: mergedNonClaims(nil), + Operation: OperationRecover, + State: ReceiptStatePassed, + TransactionResult: &repositorytransaction.Result{ + AppliedCount: 1, AppliedCountKnown: true, State: repositorytransaction.StateApplied, TransactionID: transactionID, + }, + }, + } + for index := range tests { + identity := tests[index].identityValue() + receiptID, err := digest.StableJSONSHA256Ref(identity) + if err != nil { + t.Fatal(err) + } + tests[index].ReceiptID = receiptID + if _, err := AdmitReceiptOutput(jsonRoundTripValue(t, tests[index].JSONValue())); err == nil { + t.Fatalf("AdmitReceiptOutput() admitted operation-attribution mutant %d", index) + } + } +} + +func TestRecoveryWithUnknownJournalIdentityCannotPass(t *testing.T) { + root := t.TempDir() + active := filepath.Join(root, ".agentic-proofkit", "transactions", "active") + if err := os.MkdirAll(active, 0o700); err != nil { + t.Fatal(err) + } + for _, directory := range []string{filepath.Join(root, ".agentic-proofkit"), filepath.Join(root, ".agentic-proofkit", "transactions"), active} { + if err := os.Chmod(directory, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(active, "journal.tmp"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + transactionID := "sha256:" + strings.Repeat("c", 64) + receipt, exitCode, err := Recover(context.Background(), root, transactionID, repositorytransaction.RecoveryRollback) + if err != nil || exitCode != 1 || receipt.State != ReceiptStateRecoveryRequired || receipt.TransactionResult == nil || receipt.TransactionResult.TransactionID != "" { + t.Fatalf("Recover() receipt=%#v exit=%d error=%v", receipt, exitCode, err) + } + if _, err := AdmitReceiptOutput(jsonRoundTripValue(t, receipt.JSONValue())); err != nil { + t.Fatalf("AdmitReceiptOutput() rejected the fail-closed recovery receipt: %v", err) + } +} + func jsonRoundTripValue(t *testing.T, value any) any { t.Helper() content, err := stablejson.Marshal(value) diff --git a/internal/command/adoptionmaterialization/model.go b/internal/command/adoptionmaterialization/model.go index 2d64b20..2405b90 100644 --- a/internal/command/adoptionmaterialization/model.go +++ b/internal/command/adoptionmaterialization/model.go @@ -55,6 +55,7 @@ const ( var boundaryNonClaims = []string{ "Adoption materialization does not authenticate caller declarations or approve requirement meaning, proof adequacy, merge, release, rollout, or production readiness.", "Adoption materialization provides recoverable ordered-prefix writes for cooperative writers, not simultaneous multi-file visibility or power-loss durability.", + "Materialization apply does not execute a re-admitted standalone plan; it re-admits the candidate and recomputes the executable transaction.", "The project routing manifest names canonical records but does not replace their semantic owners or prove their continuing validity.", } diff --git a/internal/command/adoptionmaterialization/output_admission.go b/internal/command/adoptionmaterialization/output_admission.go index db717a4..cb289ab 100644 --- a/internal/command/adoptionmaterialization/output_admission.go +++ b/internal/command/adoptionmaterialization/output_admission.go @@ -203,6 +203,15 @@ func validateReceiptRelation(receipt Receipt) error { if receipt.State == ReceiptStatePassed && receipt.TransactionResult.TransactionID != "" && receipt.TransactionResult.TransactionID != receipt.ExpectedTransactionID { return fmt.Errorf("adoption materialization passed receipt transaction identity is inconsistent") } + if receipt.State == ReceiptStatePassed && receipt.TransactionResult.TransactionID == "" { + return fmt.Errorf("adoption materialization passed receipt requires an observed transaction identity") + } + if receipt.Operation == OperationApply && receipt.TransactionResult.RecoveredBy != "" { + return fmt.Errorf("adoption materialization apply receipt must not claim recovery attribution") + } + if receipt.Operation == OperationRecover && receipt.State == ReceiptStatePassed && receipt.TransactionResult.RecoveredBy == "" { + return fmt.Errorf("adoption materialization recovery receipt requires recovery attribution") + } return nil } diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 0c9b9f4..b00e4b7 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "df6613bdfb325bba2bc2cdd6260819057635978fcf4138b01fa2dc3613169459" +const presetContractSourceSHA256 = "026a20b2a4a4844222360f100600688707faed58b53a93bcc4f1a2cf108654c6" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/kernel/repositorytransaction/control_state.go b/internal/kernel/repositorytransaction/control_state.go index 51e5923..3d6b3e9 100644 --- a/internal/kernel/repositorytransaction/control_state.go +++ b/internal/kernel/repositorytransaction/control_state.go @@ -26,6 +26,9 @@ type terminalControlIdentity struct { } func validateActiveState(root *os.Root, plan Plan) error { + if err := validateActivePlan(plan); err != nil { + return err + } entries, err := activeEntries(root) if err != nil { return err @@ -43,6 +46,8 @@ func validateTransactionEntries(entries []fs.DirEntry, plan *Plan, allowPartialT "committed.tmp": {}, "rolled-back": {}, "rolled-back.tmp": {}, + "recovery-action.json": {}, + "recovery-action.tmp": {}, terminalReceiptName: {}, terminalReceiptTempName: {}, } @@ -137,8 +142,8 @@ func pendingTransactionState(root *os.Root) (pendingState, error) { pending.TransactionID = plan.TransactionID return pending, nil } - if transactionID, identityKnown, _, inspectErr := incompleteJournalCanBeDiscarded(root); inspectErr == nil && identityKnown { - pending.TransactionID = transactionID + if plan, admitted, inspectErr := loadPreparingJournal(root); inspectErr == nil && admitted { + pending.TransactionID = plan.TransactionID } return pending, nil } @@ -271,41 +276,41 @@ func isBoundedTransactionEntryName(name string) bool { return false } -func incompleteJournalCanBeDiscarded(root *os.Root) (string, bool, bool, error) { +func loadPreparingJournal(root *os.Root) (Plan, bool, error) { ready, err := markerExists(root, readyMarker) if err == nil && ready { - return "", false, false, nil + return Plan{}, false, nil } if err != nil && !errors.Is(err, fs.ErrNotExist) { - return "", false, false, err + return Plan{}, false, err } entries, err := activeEntries(root) if err != nil { - return "", false, false, err + return Plan{}, false, err } for _, entry := range entries { if entry.Name() != "journal.tmp" || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { - return "", false, false, nil + return Plan{}, false, nil } } if len(entries) == 0 { - return "", false, true, nil + return Plan{}, false, nil } content, err := readOwnedFile(root, journalTemp, MaximumJournalBytes) if err != nil { - return "", false, false, err + return Plan{}, false, err } value, err := admission.DecodeJSON(bytes.NewReader(content), MaximumJournalBytes) if err != nil { - return "", false, true, nil + return Plan{}, false, nil } plan, err := admitJournal(value) if err != nil { - return "", false, true, nil + return Plan{}, false, nil } canonical, err := stablejson.Marshal(journalValue(plan)) if err != nil || !bytes.Equal(content, canonical) { - return "", false, true, nil + return Plan{}, false, nil } - return plan.TransactionID, true, true, nil + return plan, true, nil } diff --git a/internal/kernel/repositorytransaction/directory_ownership.go b/internal/kernel/repositorytransaction/directory_ownership.go index 0da2cc3..e3c6466 100644 --- a/internal/kernel/repositorytransaction/directory_ownership.go +++ b/internal/kernel/repositorytransaction/directory_ownership.go @@ -110,6 +110,10 @@ func removeCreatedDirectories(root *os.Root, plan Plan) error { } func admitRecoverableTargetDirectory(root *os.Root, relativePath string) (string, bool, error) { + exact, err := exactRouteExists(root, relativePath) + if err != nil || !exact { + return "", false, err + } native := filepath.FromSlash(relativePath) routeInfo, err := root.Lstat(native) if errors.Is(err, fs.ErrNotExist) { @@ -166,6 +170,10 @@ func admitRecoverableTargetDirectory(root *os.Root, relativePath string) (string } func inspectOwnedTargetDirectory(root *os.Root, relativePath string) (string, bool, error) { + exact, err := exactRouteExists(root, relativePath) + if err != nil || !exact { + return "", false, err + } native := filepath.FromSlash(relativePath) routeInfo, err := root.Lstat(native) if errors.Is(err, fs.ErrNotExist) { diff --git a/internal/kernel/repositorytransaction/execution.go b/internal/kernel/repositorytransaction/execution.go index 94e5177..d38da64 100644 --- a/internal/kernel/repositorytransaction/execution.go +++ b/internal/kernel/repositorytransaction/execution.go @@ -49,6 +49,9 @@ func (runtime engine) rollbackAfterFailure(ctx context.Context, root *os.Root, p if err != nil { return Result{FailureClass: failureClass, State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil } + if err := selectRecoveryAction(root, plan.TransactionID, RecoveryRollback); err != nil { + return Result{FailureClass: "recovery_action_persistence_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } if err := runtime.rollbackPrefix(ctx, root, plan, prefix); err != nil { return resultWithObservedPrefix(root, plan, Result{FailureClass: "rollback_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}), nil } @@ -73,6 +76,7 @@ func (runtime engine) rollbackAfterFailure(ctx context.Context, root *os.Root, p func (runtime engine) rollbackPrefix(ctx context.Context, root *os.Root, plan Plan, prefix int) error { changed := changedOperationIndexes(plan) + restored := 0 for position := prefix - 1; position >= 0; position-- { if err := ctx.Err(); err != nil { return fmt.Errorf("repository transaction rollback cancelled: %w", err) @@ -83,9 +87,11 @@ func (runtime engine) rollbackPrefix(ctx context.Context, root *os.Root, plan Pl if err := removeCreatedTarget(root, operation); err != nil { return err } - continue + } else if err := publishContent(root, plan, operationIndex, operation.After, operation.beforeContent, operation.Before.Mode); err != nil { + return err } - if err := publishContent(root, plan, operationIndex, operation.After, operation.beforeContent, operation.Before.Mode); err != nil { + restored++ + if err := runtime.callFault(faultAfterRollback, restored); err != nil { return err } } @@ -98,15 +104,44 @@ func (runtime engine) finishPreparingFailure(root *os.Root, plan Plan, failureCl return Result{FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil } if !exists { - return Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID}, nil + return Result{}, fmt.Errorf("repository transaction preparing state is absent") } - if err := cleanupActive(root, &plan); err != nil { + if err := verifyTargetVector(root, plan, 0); err != nil { + return Result{FailureClass: "preparing_state_mismatch", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := selectRecoveryAction(root, plan.TransactionID, RecoveryRollback); err != nil { + return Result{FailureClass: "recovery_action_persistence_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := writeMarker(root, rolledBackMarker); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "terminal_marker_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + if err := discardTerminalReceipt(root); err != nil { + return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + terminal := Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID} + if err := runtime.archiveAndCleanupTerminal(root, plan, terminal); err != nil { if errors.Is(err, errCleanupDurabilityUnknown) { return Result{AppliedCountKnown: true, FailureClass: "preparing_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil } return Result{AppliedCountKnown: true, FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil } - return Result{AppliedCountKnown: true, FailureClass: failureClass, State: StateRolledBack, TransactionID: plan.TransactionID}, nil + return terminal, nil +} + +func (runtime engine) abortPreparingFailure(root *os.Root, plan Plan) (Result, error) { + exists, err := pathExists(root, activeDirectory) + if err != nil { + return Result{FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + if exists { + if err := cleanupActive(root, &plan); err != nil { + if errors.Is(err, errCleanupDurabilityUnknown) { + return Result{FailureClass: "preparing_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: plan.TransactionID}, nil + } + return Result{FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: plan.TransactionID}, nil + } + } + return Result{}, fmt.Errorf("repository transaction journal preparation failed") } func removeInterruptedTemporary(root *os.Root, plan Plan) error { diff --git a/internal/kernel/repositorytransaction/filesystem.go b/internal/kernel/repositorytransaction/filesystem.go index 9d43af9..c86ce90 100644 --- a/internal/kernel/repositorytransaction/filesystem.go +++ b/internal/kernel/repositorytransaction/filesystem.go @@ -13,9 +13,13 @@ import ( "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/pathidentity" ) -const activeDirectory = ControlDirectory + "/active" +const ( + activeDirectory = ControlDirectory + "/active" + maximumDirectoryEntries = 16 << 10 +) func openRepository(rootPath string) (*os.Root, string, error) { if strings.TrimSpace(rootPath) == "" { @@ -49,6 +53,67 @@ func openRepository(rootPath string) (*os.Root, string, error) { return root, digest.SHA256TextRef(filepath.Clean(absolute) + "\x00" + identity), nil } +func exactEntryExists(root *os.Root, directory, component string) (bool, error) { + wantedKey, err := pathidentity.Key(component) + if err != nil { + return false, fmt.Errorf("repository transaction path component is invalid") + } + if directory == "" { + directory = "." + } + handle, err := root.Open(filepath.FromSlash(directory)) + if err != nil { + return false, fmt.Errorf("open repository transaction parent directory") + } + defer handle.Close() + entries, err := handle.ReadDir(maximumDirectoryEntries + 1) + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("read repository transaction parent directory") + } + if len(entries) > maximumDirectoryEntries { + return false, fmt.Errorf("repository transaction parent directory exceeds its entry limit") + } + exact := false + for _, entry := range entries { + entryKey, keyErr := pathidentity.Key(entry.Name()) + if keyErr != nil || entryKey != wantedKey { + continue + } + if entry.Name() != component || exact { + return false, fmt.Errorf("repository transaction path has an ambiguous portable filesystem identity") + } + exact = true + } + return exact, nil +} + +func exactRouteExists(root *os.Root, relativePath string) (bool, error) { + current := "" + components := strings.Split(relativePath, "/") + for index, component := range components { + parent := current + if parent == "" { + parent = "." + } + exists, err := exactEntryExists(root, parent, component) + if err != nil || !exists { + return false, err + } + if current == "" { + current = component + } else { + current += "/" + component + } + if index < len(components)-1 { + info, err := root.Lstat(filepath.FromSlash(current)) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, fmt.Errorf("repository transaction path traverses a symlink or non-directory") + } + } + } + return true, nil +} + func inspectParentDirectories(root *os.Root, directory string) ([]string, error) { if directory == "." || directory == "" { return nil, nil @@ -67,12 +132,17 @@ func inspectParentDirectories(root *os.Root, directory string) ([]string, error) missing = append(missing, current) continue } - info, err := root.Lstat(filepath.FromSlash(current)) - if errors.Is(err, fs.ErrNotExist) { + parent := path.Dir(current) + exists, err := exactEntryExists(root, parent, component) + if err != nil { + return nil, err + } + if !exists { ancestorMissing = true missing = append(missing, current) continue } + info, err := root.Lstat(filepath.FromSlash(current)) if err != nil { return nil, fmt.Errorf("inspect repository transaction parent") } @@ -91,11 +161,15 @@ func inspectTarget(root *os.Root, relativePath string, maximum int64) (Snapshot, if len(missing) > 0 { return Snapshot{}, nil, nil } - native := filepath.FromSlash(relativePath) - routeInfo, err := root.Lstat(native) - if errors.Is(err, fs.ErrNotExist) { + targetExists, err := exactEntryExists(root, path.Dir(relativePath), path.Base(relativePath)) + if err != nil { + return Snapshot{}, nil, err + } + if !targetExists { return Snapshot{}, nil, nil } + native := filepath.FromSlash(relativePath) + routeInfo, err := root.Lstat(native) if err != nil { return Snapshot{}, nil, fmt.Errorf("inspect repository transaction target") } @@ -131,10 +205,11 @@ func inspectTarget(root *os.Root, relativePath string, maximum int64) (Snapshot, } func pathExists(root *os.Root, relativePath string) (bool, error) { - info, err := root.Lstat(filepath.FromSlash(relativePath)) - if errors.Is(err, fs.ErrNotExist) { - return false, nil + exact, err := exactRouteExists(root, relativePath) + if err != nil || !exact { + return false, err } + info, err := root.Lstat(filepath.FromSlash(relativePath)) if err != nil { return false, fmt.Errorf("inspect repository transaction state") } @@ -152,8 +227,12 @@ func ensureDirectory(root *os.Root, relativePath string, mode fs.FileMode) error } else { current += "/" + component } - _, err := root.Lstat(filepath.FromSlash(current)) - if errors.Is(err, fs.ErrNotExist) { + parent := path.Dir(current) + exists, err := exactEntryExists(root, parent, component) + if err != nil { + return err + } + if !exists { if err := root.Mkdir(filepath.FromSlash(current), mode); err != nil { return fmt.Errorf("create repository transaction directory") } @@ -163,8 +242,6 @@ func ensureDirectory(root *os.Root, relativePath string, mode fs.FileMode) error if err := syncDirectory(root, path.Dir(current)); err != nil { return err } - } else if err != nil { - return fmt.Errorf("inspect repository transaction directory") } if err := validatePrivateDirectory(root, current, mode); err != nil { return err @@ -174,6 +251,10 @@ func ensureDirectory(root *os.Root, relativePath string, mode fs.FileMode) error } func validatePrivateDirectory(root *os.Root, relativePath string, mode fs.FileMode) error { + exact, err := exactRouteExists(root, relativePath) + if err != nil || !exact { + return fmt.Errorf("repository transaction directory route is invalid") + } native := filepath.FromSlash(relativePath) routeInfo, err := root.Lstat(native) if err != nil || routeInfo.Mode()&os.ModeSymlink != 0 || !routeInfo.IsDir() || routeInfo.Mode().Perm() != mode.Perm() || routeInfo.Mode()&(fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky) != 0 { @@ -281,10 +362,14 @@ func writeAtomicOwnedFile(root *os.Root, relativePath, temporaryPath string, con } func discardOwnedTemporaryFile(root *os.Root, relativePath string) error { - info, err := root.Lstat(filepath.FromSlash(relativePath)) - if errors.Is(err, fs.ErrNotExist) { + exists, err := exactRouteExists(root, relativePath) + if err != nil { + return err + } + if !exists { return nil } + info, err := root.Lstat(filepath.FromSlash(relativePath)) if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode()&^fs.ModePerm != 0 { return fmt.Errorf("repository transaction temporary file is unsafe") } @@ -299,6 +384,10 @@ func discardOwnedTemporaryFile(root *os.Root, relativePath string) error { } func readOwnedFile(root *os.Root, relativePath string, maximum int64) ([]byte, error) { + exists, err := exactRouteExists(root, relativePath) + if err != nil || !exists { + return nil, fmt.Errorf("repository transaction file route is invalid") + } file, err := openNoFollow(root, filepath.FromSlash(relativePath)) if err != nil { return nil, fmt.Errorf("open repository transaction file") diff --git a/internal/kernel/repositorytransaction/invariant_test.go b/internal/kernel/repositorytransaction/invariant_test.go index 8f67772..489dba4 100644 --- a/internal/kernel/repositorytransaction/invariant_test.go +++ b/internal/kernel/repositorytransaction/invariant_test.go @@ -106,6 +106,40 @@ func TestDirectoryOwnershipRejectsInodeSubstitution(t *testing.T) { } } +func TestDirectoryOwnershipRejectsPortableRouteAlias(t *testing.T) { + rootPath := t.TempDir() + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "new/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 0) + intermediate := filepath.Join(rootPath, "renaming") + if err := os.Rename(filepath.Join(rootPath, "new"), intermediate); err != nil { + t.Fatal(err) + } + if err := os.Rename(intermediate, filepath.Join(rootPath, "New")); err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if _, _, err := inspectOwnedTargetDirectory(root, "new"); err == nil || !strings.Contains(err.Error(), "portable filesystem identity") { + root.Close() + t.Fatalf("inspectOwnedTargetDirectory() error=%v, want portable-alias rejection", err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryRollback) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "ambiguous_target_state" { + t.Fatalf("Recover() result=%#v error=%v", result, err) + } + if info, err := os.Stat(filepath.Join(rootPath, "New")); err != nil || !info.IsDir() { + t.Fatalf("portable directory alias was removed: %v", err) + } +} + func TestRecoveryActionAndTerminalReceiptAreStable(t *testing.T) { root := t.TempDir() plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/target.json", Content: []byte("desired\n"), Mode: 0o644}}) @@ -140,6 +174,117 @@ func TestRecoveryActionAndTerminalReceiptAreStable(t *testing.T) { } } +func TestRecoveryActionIsDurableBeforeDirectionalMutation(t *testing.T) { + tests := []struct { + name string + firstAction string + opposite string + prefix int + failurePoint failurePoint + wantAAfter string + wantBAfter string + wantFinalState string + }{ + {name: "resume", firstAction: RecoveryResume, opposite: RecoveryRollback, prefix: 0, failurePoint: faultAfterPublish, wantAAfter: "after-a\n", wantBAfter: "before-b\n", wantFinalState: StateApplied}, + {name: "rollback", firstAction: RecoveryRollback, opposite: RecoveryResume, prefix: 2, failurePoint: faultAfterRollback, wantAAfter: "after-a\n", wantBAfter: "before-b\n", wantFinalState: StateRolledBack}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{ + {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, + {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, + }) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, test.prefix) + runtime := engine{fault: func(point failurePoint, index int) error { + if point == test.failurePoint && index == 1 { + return errors.New("injected directional interruption") + } + return nil + }} + result, err := runtime.recover(context.Background(), rootPath, plan.TransactionID, test.firstAction) + if err != nil || result.State != StateRecoveryRequired { + t.Fatalf("first Recover()=%#v, %v", result, err) + } + assertTestFile(t, rootPath, "proofkit/a.json", test.wantAAfter, 0o644) + assertTestFile(t, rootPath, "proofkit/b.json", test.wantBAfter, 0o644) + + mismatch, err := Recover(context.Background(), rootPath, plan.TransactionID, test.opposite) + if err != nil || mismatch.State != StateRecoveryRequired || mismatch.FailureClass != "recovery_action_mismatch" { + t.Fatalf("opposite Recover()=%#v, %v", mismatch, err) + } + completed, err := Recover(context.Background(), rootPath, plan.TransactionID, test.firstAction) + if err != nil || completed.State != test.wantFinalState || completed.RecoveredBy != test.firstAction { + t.Fatalf("stable Recover()=%#v, %v", completed, err) + } + }) + } +} + +func TestMalformedRecoveryActionBlocksMutation(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/target.json", "before\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/target.json", Content: []byte("after\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 0) + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := writeOwnedFile(root, recoveryActionPath, []byte(`{"action":"resume"}`), 0o600); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "invalid_recovery_action" { + t.Fatalf("Recover()=%#v, %v", result, err) + } + assertTestFile(t, rootPath, "proofkit/target.json", "before\n", 0o644) + content, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(recoveryActionPath))) + if err != nil || string(content) != `{"action":"resume"}` { + t.Fatalf("rejected action record changed: content=%q err=%v", content, err) + } +} + +func TestPreparingFailureCannotClaimRollbackAfterTargetDivergence(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "proofkit/state.json", "before\n", 0o644) + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "proofkit/state.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, _ int) error { + if point != faultAfterStaging { + return nil + } + mustWriteTestFile(t, root, "proofkit/state.json", "concurrent\n", 0o644) + return errors.New("injected preparation failure") + }} + result, err := runtime.apply(context.Background(), root, plan) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "preparing_state_mismatch" { + t.Fatalf("apply() result=%#v error=%v", result, err) + } + assertTestFile(t, root, "proofkit/state.json", "concurrent\n", 0o644) + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(journalPath))); err != nil { + t.Fatalf("preparing mismatch removed its recovery journal: %v", err) + } + recovery, err := Recover(context.Background(), root, plan.TransactionID, RecoveryRollback) + if err != nil || recovery.State != StateRecoveryRequired || recovery.FailureClass != "preparing_state_mismatch" { + t.Fatalf("Recover() result=%#v error=%v", recovery, err) + } +} + func TestAppliedTerminalReceiptReplaysCompleteResult(t *testing.T) { root := t.TempDir() plan, err := BuildPlan(context.Background(), root, []Target{ @@ -224,7 +369,7 @@ func TestRejectedApplyPreservesPreviousTerminalReceipt(t *testing.T) { } } -func TestPreparingReplacementPreservesPreviousTerminalReceipt(t *testing.T) { +func TestPreparingRollbackAtomicallyReplacesPreviousTerminalReceipt(t *testing.T) { rootPath := t.TempDir() first, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/first.json", Content: []byte("first\n"), Mode: 0o644}}) if err != nil { @@ -265,9 +410,16 @@ func TestPreparingReplacementPreservesPreviousTerminalReceipt(t *testing.T) { if err != nil || rolledBack.State != StateRolledBack { t.Fatalf("Recover(second)=%#v, %v", rolledBack, err) } - retained, err = ReadTerminalResult(context.Background(), rootPath, first.TransactionID) - if err != nil || retained.State != StateApplied { - t.Fatalf("ReadTerminalResult(first after rollback)=%#v, %v", retained, err) + if _, err := ReadTerminalResult(context.Background(), rootPath, first.TransactionID); err == nil { + t.Fatal("terminalized preparing rollback retained the superseded receipt") + } + retained, err = ReadTerminalResult(context.Background(), rootPath, second.TransactionID) + if err != nil || retained != rolledBack { + t.Fatalf("ReadTerminalResult(second)=%#v, %v, want %#v", retained, err, rolledBack) + } + replayed, err := Recover(context.Background(), rootPath, second.TransactionID, RecoveryRollback) + if err != nil || replayed != rolledBack { + t.Fatalf("Recover(second replay)=%#v, %v, want %#v", replayed, err, rolledBack) } } diff --git a/internal/kernel/repositorytransaction/journal.go b/internal/kernel/repositorytransaction/journal.go index d81f60b..3d31f5a 100644 --- a/internal/kernel/repositorytransaction/journal.go +++ b/internal/kernel/repositorytransaction/journal.go @@ -12,14 +12,19 @@ import ( ) const ( - journalPath = activeDirectory + "/journal.json" - journalTemp = activeDirectory + "/journal.tmp" - readyMarker = activeDirectory + "/ready" - committedMarker = activeDirectory + "/committed" - rolledBackMarker = activeDirectory + "/rolled-back" + journalPath = activeDirectory + "/journal.json" + journalTemp = activeDirectory + "/journal.tmp" + readyMarker = activeDirectory + "/ready" + committedMarker = activeDirectory + "/committed" + rolledBackMarker = activeDirectory + "/rolled-back" + recoveryActionPath = activeDirectory + "/recovery-action.json" + recoveryActionTemp = activeDirectory + "/recovery-action.tmp" ) func prepareJournal(root *os.Root, plan Plan) error { + if err := validateActivePlan(plan); err != nil { + return err + } if err := ensureDirectory(root, activeDirectory, 0o700); err != nil { return err } @@ -39,6 +44,13 @@ func prepareJournal(root *os.Root, plan Plan) error { return syncDirectory(root, activeDirectory) } +func publishPreparingJournal(root *os.Root) error { + if err := root.Rename(filepath.FromSlash(journalTemp), filepath.FromSlash(journalPath)); err != nil { + return fmt.Errorf("publish repository transaction preparing journal") + } + return syncDirectory(root, activeDirectory) +} + func stageObjects(root *os.Root, plan Plan) error { for index, operation := range plan.Operations { if operation.Action == ActionUnchanged { diff --git a/internal/kernel/repositorytransaction/journal_admission.go b/internal/kernel/repositorytransaction/journal_admission.go index 2d04110..26e92be 100644 --- a/internal/kernel/repositorytransaction/journal_admission.go +++ b/internal/kernel/repositorytransaction/journal_admission.go @@ -3,9 +3,12 @@ package repositorytransaction import ( "encoding/json" "fmt" + "path" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/pathidentity" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) func journalValue(plan Plan) map[string]any { @@ -77,6 +80,9 @@ func admitJournal(raw any) (Plan, error) { if err := validatePlanShape(plan); err != nil { return Plan{}, err } + if err := validateJournalBound(plan); err != nil { + return Plan{}, err + } wantDesiredStateID, err := digest.StableJSONSHA256Ref(desiredStateIdentityValue(plan)) if err != nil || wantDesiredStateID != desiredStateID { return Plan{}, fmt.Errorf("repository transaction desired-state identity does not match its targets") @@ -204,6 +210,45 @@ func validatePlanShape(plan Plan) error { return fmt.Errorf("repository transaction created directory does not own a target") } } + created := make(map[string]struct{}, len(plan.CreatedDirectories)) + for _, directory := range plan.CreatedDirectories { + created[directory] = struct{}{} + } + for _, operation := range plan.Operations { + parent := path.Dir(operation.Path) + if parent == "." { + continue + } + prefixes, err := pathidentity.Prefixes(parent) + if err != nil { + return fmt.Errorf("repository transaction operation parent is invalid") + } + createdAncestor := false + for _, prefix := range prefixes { + _, isCreated := created[prefix.Path] + if isCreated { + createdAncestor = true + if operation.Before.Exists { + return fmt.Errorf("repository transaction created directory contradicts an existing target") + } + continue + } + if createdAncestor { + return fmt.Errorf("repository transaction created directory chain is incomplete") + } + } + } + return nil +} + +func validateJournalBound(plan Plan) error { + content, err := stablejson.Marshal(journalValue(plan)) + if err != nil { + return fmt.Errorf("encode repository transaction journal") + } + if len(content) > MaximumJournalBytes { + return fmt.Errorf("repository transaction journal exceeds the byte limit") + } return nil } diff --git a/internal/kernel/repositorytransaction/model.go b/internal/kernel/repositorytransaction/model.go index 02c61cc..71f8aff 100644 --- a/internal/kernel/repositorytransaction/model.go +++ b/internal/kernel/repositorytransaction/model.go @@ -38,6 +38,7 @@ const ( ) var boundaryNonClaims = []string{ + "Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.", "Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.", "Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.", "Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.", diff --git a/internal/kernel/repositorytransaction/output_admission.go b/internal/kernel/repositorytransaction/output_admission.go index 3e4415a..3f17e80 100644 --- a/internal/kernel/repositorytransaction/output_admission.go +++ b/internal/kernel/repositorytransaction/output_admission.go @@ -127,7 +127,7 @@ func AdmitResultOutput(raw any) (Result, error) { func validateResultRelation(result Result) error { switch result.State { case StateApplied: - if !result.AppliedCountKnown || result.TransactionID == "" || result.FailureClass != "" || result.RecoveredBy == RecoveryRollback { + if !result.AppliedCountKnown || result.AppliedCount == 0 || result.TransactionID == "" || result.FailureClass != "" || result.RecoveredBy == RecoveryRollback { return fmt.Errorf("applied repository transaction result is inconsistent") } case StateAlreadySatisfied: @@ -135,7 +135,7 @@ func validateResultRelation(result Result) error { return fmt.Errorf("already-satisfied repository transaction result is inconsistent") } case StateRolledBack: - if !result.AppliedCountKnown || result.AppliedCount != 0 || result.RecoveredBy == RecoveryResume { + if !result.AppliedCountKnown || result.AppliedCount != 0 || result.TransactionID == "" || result.RecoveredBy == RecoveryResume || result.RecoveredBy == "" && result.FailureClass == "" { return fmt.Errorf("rolled-back repository transaction result is inconsistent") } case StateCleanupRequired, StateDurabilityUnknown, StateRecoveryRequired: diff --git a/internal/kernel/repositorytransaction/output_admission_test.go b/internal/kernel/repositorytransaction/output_admission_test.go index 36f405b..67d6e65 100644 --- a/internal/kernel/repositorytransaction/output_admission_test.go +++ b/internal/kernel/repositorytransaction/output_admission_test.go @@ -4,6 +4,8 @@ import ( "context" "strings" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" ) func TestPlanAndResultOutputAdmissionRejectSemanticMutants(t *testing.T) { @@ -38,6 +40,14 @@ func TestPlanAndResultOutputAdmissionRejectSemanticMutants(t *testing.T) { if _, err := AdmitResultOutput(mutant); err == nil { t.Fatal("AdmitResultOutput() admitted applied work in an already-satisfied result") } + for _, impossible := range []Result{ + {AppliedCountKnown: true, State: StateApplied, TransactionID: transactionID}, + {AppliedCountKnown: true, State: StateRolledBack, TransactionID: transactionID}, + } { + if _, err := AdmitResultOutput(impossible.JSONValue()); err == nil { + t.Fatalf("AdmitResultOutput() admitted unreachable result %#v", impossible) + } + } } func TestAdmitPlanOutputRejectsPortableAliasAsLexicalParent(t *testing.T) { @@ -52,3 +62,40 @@ func TestAdmitPlanOutputRejectsPortableAliasAsLexicalParent(t *testing.T) { t.Fatalf("AdmitPlanOutput() error=%v, want portable parent-alias rejection", err) } } + +func TestAdmitPlanOutputRejectsImpossibleCreatedDirectoryRelations(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "a/b/record.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + + incomplete := clonePlan(plan) + incomplete.CreatedDirectories = []string{"a"} + refreshPlanIdentity(t, &incomplete) + if _, err := AdmitPlanOutput(incomplete.JSONValue()); err == nil || !strings.Contains(err.Error(), "chain is incomplete") { + t.Fatalf("AdmitPlanOutput(incomplete chain) error=%v", err) + } + + contradictory := clonePlan(plan) + contradictory.Operations[0].Before = Snapshot{ByteCount: 6, Exists: true, Mode: 0o644, SHA256: digest.SHA256BytesRef([]byte("before"))} + contradictory.Operations[0].Action = ActionReplace + refreshPlanIdentity(t, &contradictory) + if _, err := AdmitPlanOutput(contradictory.JSONValue()); err == nil || !strings.Contains(err.Error(), "contradicts an existing target") { + t.Fatalf("AdmitPlanOutput(contradictory before state) error=%v", err) + } +} + +func refreshPlanIdentity(t *testing.T, plan *Plan) { + t.Helper() + desiredStateID, err := digest.StableJSONSHA256Ref(desiredStateIdentityValue(*plan)) + if err != nil { + t.Fatal(err) + } + plan.DesiredStateID = desiredStateID + transactionID, err := digest.StableJSONSHA256Ref(planIdentityValue(*plan)) + if err != nil { + t.Fatal(err) + } + plan.TransactionID = transactionID +} diff --git a/internal/kernel/repositorytransaction/plan_test.go b/internal/kernel/repositorytransaction/plan_test.go index 977dec9..4dd97b4 100644 --- a/internal/kernel/repositorytransaction/plan_test.go +++ b/internal/kernel/repositorytransaction/plan_test.go @@ -3,6 +3,7 @@ package repositorytransaction import ( "context" "errors" + "fmt" "io/fs" "os" "path/filepath" @@ -63,6 +64,42 @@ func TestBuildPlanIsReadOnlyCanonicalAndContentBound(t *testing.T) { } } +func TestBuildPlanRejectsFilesystemPortableAliases(t *testing.T) { + tests := []struct { + actualPath string + targetPath string + }{ + {actualPath: "Proofkit/target.json", targetPath: "proofkit/target.json"}, + {actualPath: "proofkit/Target.JSON", targetPath: "proofkit/target.json"}, + {actualPath: "proofkit/caf\u00e9.json", targetPath: "proofkit/cafe\u0301.json"}, + } + for index, test := range tests { + t.Run(fmt.Sprintf("alias-%d", index), func(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, test.actualPath, "before\n", 0o644) + if _, err := BuildPlan(context.Background(), root, []Target{{Path: test.targetPath, Content: []byte("after\n"), Mode: 0o644}}); err == nil || !strings.Contains(err.Error(), "portable filesystem identity") { + t.Fatalf("BuildPlan() error=%v, want filesystem-alias rejection", err) + } + }) + } +} + +func TestBuildPlanRejectsPlanOutsideJournalBound(t *testing.T) { + root := t.TempDir() + targets := make([]Target, 0, MaximumOperations) + for targetIndex := 0; targetIndex < MaximumOperations; targetIndex++ { + components := make([]string, 0, 64) + for componentIndex := 0; componentIndex < 63; componentIndex++ { + components = append(components, fmt.Sprintf("d%02d-%02d-long", targetIndex, componentIndex)) + } + components = append(components, "record.json") + targets = append(targets, Target{Path: strings.Join(components, "/"), Content: []byte("x\n"), Mode: 0o644}) + } + if _, err := BuildPlan(context.Background(), root, targets); err == nil || !strings.Contains(err.Error(), "journal exceeds the byte limit") { + t.Fatalf("BuildPlan() error=%v, want journal-bound rejection", err) + } +} + func TestBuildPlanPreservesContextCause(t *testing.T) { tests := []struct { name string diff --git a/internal/kernel/repositorytransaction/recovery.go b/internal/kernel/repositorytransaction/recovery.go index 609d41a..7cc9323 100644 --- a/internal/kernel/repositorytransaction/recovery.go +++ b/internal/kernel/repositorytransaction/recovery.go @@ -71,26 +71,23 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti } plan, err := loadJournal(root) if err != nil { - observedTransactionID, identityKnown, discardable, inspectErr := incompleteJournalCanBeDiscarded(root) - if inspectErr != nil || !discardable { - return Result{FailureClass: "invalid_journal", State: StateRecoveryRequired, TransactionID: observedTransactionID}, nil + preparingPlan, identityKnown, inspectErr := loadPreparingJournal(root) + if inspectErr != nil || !identityKnown { + return Result{FailureClass: "invalid_journal", State: StateRecoveryRequired}, nil } - if identityKnown && observedTransactionID != transactionID { + if preparingPlan.TransactionID != transactionID || preparingPlan.RootID != rootID { return Result{}, fmt.Errorf("repository transaction recovery identity does not match preparing state") } - if action != RecoveryRollback { - return Result{FailureClass: "preparing_state_mismatch", State: StateRecoveryRequired, TransactionID: observedTransactionID}, nil + if err := validateActivePlan(preparingPlan); err != nil { + return Result{FailureClass: "invalid_control_state", State: StateRecoveryRequired, TransactionID: preparingPlan.TransactionID}, nil } - if err := ctx.Err(); err != nil { - return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) + if action != RecoveryRollback { + return Result{FailureClass: "preparing_state_mismatch", State: StateRecoveryRequired, TransactionID: preparingPlan.TransactionID}, nil } - if cleanupErr := cleanupActive(root, nil); cleanupErr != nil { - if errors.Is(cleanupErr, errCleanupDurabilityUnknown) { - return Result{AppliedCountKnown: true, FailureClass: "preparing_cleanup_durability_unknown", RecoveredBy: RecoveryRollback, State: StateDurabilityUnknown}, nil - } - return Result{FailureClass: "cleanup_failed", RecoveredBy: RecoveryRollback, State: StateCleanupRequired, TransactionID: observedTransactionID}, nil + if err := publishPreparingJournal(root); err != nil { + return Result{FailureClass: "journal_publication_failed", State: StateRecoveryRequired, TransactionID: preparingPlan.TransactionID}, nil } - return Result{AppliedCountKnown: true, RecoveredBy: RecoveryRollback, State: StateRolledBack, TransactionID: observedTransactionID}, nil + plan = preparingPlan } if plan.TransactionID != transactionID || plan.RootID != rootID { return Result{}, fmt.Errorf("repository transaction recovery identity does not match active state") @@ -98,6 +95,13 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := validateActiveState(root, plan); err != nil { return Result{FailureClass: "invalid_control_state", State: StateRecoveryRequired, TransactionID: transactionID}, nil } + selectedAction, actionSelected, err := readRecoveryAction(root) + if err != nil { + return Result{FailureClass: "invalid_recovery_action", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if actionSelected && (selectedAction.TransactionID != transactionID || selectedAction.Action != action) { + return Result{FailureClass: "recovery_action_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } committed, err := markerExists(root, committedMarker) if err != nil { return Result{}, err @@ -126,10 +130,6 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti return runtime.cleanupRecovered(root, plan, StateApplied, action) } if rolledBack { - plan, err = loadObjects(root, plan) - if err != nil { - return Result{FailureClass: "invalid_staged_objects", State: StateRecoveryRequired, TransactionID: transactionID}, nil - } if action != RecoveryRollback || verifyTargetVector(root, plan, 0) != nil { return Result{FailureClass: "rolled_back_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil } @@ -158,16 +158,19 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } + if err := selectRecoveryAction(root, transactionID, action); err != nil { + return Result{FailureClass: "recovery_action_persistence_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } if err := removeCreatedDirectories(root, plan); err != nil { return Result{FailureClass: "directory_cleanup_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil } - if err := cleanupActive(root, &plan); err != nil { - if errors.Is(err, errCleanupDurabilityUnknown) { - return Result{AppliedCountKnown: true, FailureClass: "rolled_back_cleanup_durability_unknown", RecoveredBy: RecoveryRollback, State: StateDurabilityUnknown, TransactionID: transactionID}, nil - } - return Result{AppliedCountKnown: true, FailureClass: "cleanup_failed", RecoveredBy: RecoveryRollback, State: StateCleanupRequired, TransactionID: transactionID}, nil + if err := writeMarker(root, rolledBackMarker); err != nil { + return Result{AppliedCountKnown: true, FailureClass: "terminal_marker_failed", RecoveredBy: RecoveryRollback, State: StateRecoveryRequired, TransactionID: transactionID}, nil + } + if err := discardTerminalReceipt(root); err != nil { + return Result{FailureClass: "terminal_replacement_failed", RecoveredBy: RecoveryRollback, State: StateRecoveryRequired, TransactionID: transactionID}, nil } - return Result{AppliedCountKnown: true, RecoveredBy: RecoveryRollback, State: StateRolledBack, TransactionID: transactionID}, nil + return runtime.cleanupRecovered(root, plan, StateRolledBack, RecoveryRollback) } plan, err = loadObjects(root, plan) if err != nil { @@ -181,6 +184,9 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } + if err := selectRecoveryAction(root, transactionID, action); err != nil { + return Result{FailureClass: "recovery_action_persistence_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } if err := discardTerminalReceipt(root); err != nil { return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil } @@ -195,6 +201,9 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } + if err := selectRecoveryAction(root, transactionID, action); err != nil { + return Result{FailureClass: "recovery_action_persistence_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil + } if err := discardTerminalReceipt(root); err != nil { return Result{FailureClass: "terminal_replacement_failed", State: StateRecoveryRequired, TransactionID: transactionID}, nil } diff --git a/internal/kernel/repositorytransaction/recovery_action.go b/internal/kernel/repositorytransaction/recovery_action.go new file mode 100644 index 0000000..218bb6e --- /dev/null +++ b/internal/kernel/repositorytransaction/recovery_action.go @@ -0,0 +1,93 @@ +package repositorytransaction + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const maximumRecoveryActionBytes = 512 + +type recoveryActionRecord struct { + Action string + TransactionID string +} + +func selectRecoveryAction(root *os.Root, transactionID, action string) error { + selected, exists, err := readRecoveryAction(root) + if err != nil { + return err + } + if exists { + if selected.Action != action || selected.TransactionID != transactionID { + return fmt.Errorf("repository transaction recovery action contradicts durable state") + } + return nil + } + record := recoveryActionRecord{Action: action, TransactionID: transactionID} + content, err := stablejson.Marshal(recoveryActionValue(record)) + if err != nil || len(content) > maximumRecoveryActionBytes { + return fmt.Errorf("encode repository transaction recovery action") + } + return writeAtomicOwnedFile(root, recoveryActionPath, recoveryActionTemp, content, 0o600) +} + +func readRecoveryAction(root *os.Root) (recoveryActionRecord, bool, error) { + exists, err := pathExists(root, recoveryActionPath) + if err != nil || !exists { + return recoveryActionRecord{}, false, err + } + content, err := readOwnedFile(root, recoveryActionPath, maximumRecoveryActionBytes) + if err != nil { + return recoveryActionRecord{}, false, err + } + raw, err := admission.DecodeJSON(bytes.NewReader(content), maximumRecoveryActionBytes) + if err != nil { + return recoveryActionRecord{}, false, fmt.Errorf("admit repository transaction recovery action") + } + record, err := admitRecoveryAction(raw) + if err != nil { + return recoveryActionRecord{}, false, err + } + canonical, err := stablejson.Marshal(recoveryActionValue(record)) + if err != nil || !bytes.Equal(content, canonical) { + return recoveryActionRecord{}, false, fmt.Errorf("repository transaction recovery action is not canonical") + } + return record, true, nil +} + +func admitRecoveryAction(raw any) (recoveryActionRecord, error) { + record, ok := raw.(map[string]any) + if !ok { + return recoveryActionRecord{}, fmt.Errorf("repository transaction recovery action must be an object") + } + if err := admit.KnownKeys(record, []string{"action", "actionKind", "schemaVersion", "transactionId"}, "repository transaction recovery action"); err != nil { + return recoveryActionRecord{}, err + } + if record["actionKind"] != "proofkit.repository-recovery-action" || !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return recoveryActionRecord{}, fmt.Errorf("repository transaction recovery action identity is invalid") + } + action, err := admit.Enum(record["action"], map[string]struct{}{RecoveryResume: {}, RecoveryRollback: {}}, "repository transaction recovery action") + if err != nil { + return recoveryActionRecord{}, err + } + transactionID, err := admit.SHA256Ref(record["transactionId"], "repository transaction recovery action transactionId") + if err != nil { + return recoveryActionRecord{}, err + } + return recoveryActionRecord{Action: action, TransactionID: transactionID}, nil +} + +func recoveryActionValue(record recoveryActionRecord) map[string]any { + return map[string]any{ + "action": record.Action, + "actionKind": "proofkit.repository-recovery-action", + "schemaVersion": json.Number("1"), + "transactionId": record.TransactionID, + } +} diff --git a/internal/kernel/repositorytransaction/state.go b/internal/kernel/repositorytransaction/state.go index 634a3cd..fc9bef5 100644 --- a/internal/kernel/repositorytransaction/state.go +++ b/internal/kernel/repositorytransaction/state.go @@ -89,6 +89,13 @@ func validateExecutablePlan(plan Plan, rootID string) error { return nil } +func validateActivePlan(plan Plan) error { + if changedCount(plan) == 0 { + return fmt.Errorf("repository transaction active plan requires at least one changed target") + } + return nil +} + func verifyCreatedDirectories(root *os.Root, plan Plan) error { directorySet := map[string]struct{}{} for _, operation := range plan.Operations { diff --git a/internal/kernel/repositorytransaction/transaction.go b/internal/kernel/repositorytransaction/transaction.go index 179e317..cc164f5 100644 --- a/internal/kernel/repositorytransaction/transaction.go +++ b/internal/kernel/repositorytransaction/transaction.go @@ -16,6 +16,7 @@ const ( faultAfterDirectory failurePoint = "after_directory" faultBeforePublish failurePoint = "before_publish" faultAfterPublish failurePoint = "after_publish" + faultAfterRollback failurePoint = "after_rollback" faultAfterTerminal failurePoint = "after_terminal" faultAfterStateRemoval failurePoint = "after_state_removal" faultBeforeCleanup failurePoint = "before_cleanup" @@ -105,7 +106,7 @@ func (runtime engine) apply(ctx context.Context, rootPath string, plan Plan) (Re return Result{}, fmt.Errorf("repository transaction apply cancelled: %w", err) } if err := prepareJournal(root, plan); err != nil { - return runtime.finishPreparingFailure(root, plan, "journal_prepare_failed") + return runtime.abortPreparingFailure(root, plan) } if err := ctx.Err(); err != nil { return runtime.finishPreparingFailure(root, plan, "cancelled") diff --git a/internal/kernel/repositorytransaction/transaction_test.go b/internal/kernel/repositorytransaction/transaction_test.go index c9ebdcd..315b81f 100644 --- a/internal/kernel/repositorytransaction/transaction_test.go +++ b/internal/kernel/repositorytransaction/transaction_test.go @@ -229,6 +229,10 @@ func TestRecoverAttributesOnlyCanonicalPreparingJournalIdentity(t *testing.T) { if err != nil || result.State != StateRolledBack || result.TransactionID != plan.TransactionID { t.Fatalf("Recover(canonical temp) result=%#v err=%v", result, err) } + replayed, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryRollback) + if err != nil || replayed != result { + t.Fatalf("Recover(canonical temp replay) result=%#v err=%v, want %#v", replayed, err, result) + } } func TestRecoverDoesNotInventIdentityForPartialPreparingJournal(t *testing.T) { @@ -246,13 +250,57 @@ func TestRecoverDoesNotInventIdentityForPartialPreparingJournal(t *testing.T) { if err := root.Close(); err != nil { t.Fatal(err) } - suppliedID := "sha256:" + strings.Repeat("1", 64) - result, err := Recover(context.Background(), rootPath, suppliedID, RecoveryRollback) - if err != nil || result.State != StateRolledBack || result.TransactionID != "" || !result.AppliedCountKnown || result.AppliedCount != 0 { - t.Fatalf("Recover(partial temp) result=%#v err=%v", result, err) + for _, digit := range []string{"1", "2"} { + suppliedID := "sha256:" + strings.Repeat(digit, 64) + result, err := Recover(context.Background(), rootPath, suppliedID, RecoveryRollback) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "invalid_journal" || result.TransactionID != "" { + t.Fatalf("Recover(partial temp, %s) result=%#v err=%v", digit, result, err) + } + if result.JSONValue()["transactionId"] != nil { + t.Fatalf("partial recovery attributed caller identity: %#v", result.JSONValue()) + } + content, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(journalTemp))) + if err != nil || string(content) != "{" { + t.Fatalf("identity-unknown recovery mutated retained state: content=%q err=%v", content, err) + } + } +} + +func TestRecoverRejectsCanonicalZeroChangePreparingJournal(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "same\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/a.json", Content: []byte("same\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := ensureDirectory(root, activeDirectory, 0o700); err != nil { + root.Close() + t.Fatal(err) + } + content, err := stablejson.Marshal(journalValue(plan)) + if err != nil { + root.Close() + t.Fatal(err) + } + if err := writeOwnedFile(root, journalTemp, content, 0o600); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryRollback) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "invalid_control_state" || result.TransactionID != plan.TransactionID { + t.Fatalf("Recover() result=%#v error=%v", result, err) } - if result.JSONValue()["transactionId"] != nil { - t.Fatalf("partial recovery attributed caller identity: %#v", result.JSONValue()) + assertTestFile(t, rootPath, "proofkit/a.json", "same\n", 0o644) + if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(journalTemp))); err != nil { + t.Fatalf("zero-change recovery mutated retained state: %v", err) } } @@ -635,7 +683,7 @@ func crashHelperTargets() []Target { } } -func TestPreparingFailureWithoutActiveStateIsRolledBack(t *testing.T) { +func TestPreparingFailureWithoutActiveStateIsRejected(t *testing.T) { rootPath := t.TempDir() root, rootID, err := openRepository(rootPath) if err != nil { @@ -643,12 +691,8 @@ func TestPreparingFailureWithoutActiveStateIsRolledBack(t *testing.T) { } defer root.Close() plan := Plan{RootID: rootID, TransactionID: "sha256:0000000000000000000000000000000000000000000000000000000000000000"} - result, err := (engine{}).finishPreparingFailure(root, plan, "journal_prepare_failed") - if err != nil { - t.Fatal(err) - } - if result.State != StateRolledBack || result.FailureClass != "journal_prepare_failed" { - t.Fatalf("finishPreparingFailure() result = %#v", result) + if _, err := (engine{}).finishPreparingFailure(root, plan, "journal_prepare_failed"); err == nil { + t.Fatal("finishPreparingFailure() admitted absent preparing state") } } diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index ab0e86c..f9553f6 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -633,6 +633,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestApplyFaultAfterFirstPublishRestoresExactBeforeState", "TestApplyRejectsConcurrentCooperativeWriter", "TestProcessDeathAfterRenameIsRecoverable", + "TestRecoverDoesNotInventIdentityForPartialPreparingJournal", }, {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-portable-path-identity"}: { "TestPortableEquivalenceAndContainment", @@ -640,9 +641,12 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-terminal-state"}: { "TestAppliedTerminalReceiptReplaysCompleteResult", "TestApplyExecutesFrozenPlan", - "TestPreparingReplacementPreservesPreviousTerminalReceipt", + "TestMalformedRecoveryActionBlocksMutation", + "TestPreparingFailureCannotClaimRollbackAfterTargetDivergence", + "TestPreparingRollbackAtomicallyReplacesPreviousTerminalReceipt", "TestReadyReplacementRetiresPreviousReceiptAndPreservesCompleteResult", "TestRecoveryActionAndTerminalReceiptAreStable", + "TestRecoveryActionIsDurableBeforeDirectionalMutation", "TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity", }, {"REQ-PROOFKIT-SPEC-034", "proofkit.spec-proof-core.adoption-materialization-version-edge"}: { diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index df22e75..d6a4892 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -111,7 +111,7 @@ "rootDefinitionDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", + "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -141,7 +141,7 @@ "rootDefinitionDigest": "sha256:4b58bd4e89da98ad79c5e6faf32fe58766313117ad2558b602bfed853de0cf7e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", + "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -237,7 +237,7 @@ "rootDefinitionDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", + "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -267,7 +267,7 @@ "rootDefinitionDigest": "sha256:fc0e2d547a5fd54ebebe9d237a48ae15418b32d3d28fa5185aae64a0fba9b255", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", + "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -369,7 +369,7 @@ "rootDefinitionDigest": "sha256:06129ef857ffb11351535c769f9ca207522a08f3c5bf2da52c6f5fff0b1ac757", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:461e9460ac8b7bcbfd6c5c594d3234f8411e6b18d280eb8d4205521d416c1980", + "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 2a85c31..14d59b9 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -5910,13 +5910,21 @@ "selector": "TestRecoveryActionAndTerminalReceiptAreStable", "command": "go test ./internal/kernel/repositorytransaction -run '^TestRecoveryActionAndTerminalReceiptAreStable$'" }, + { + "selector": "TestRecoveryActionIsDurableBeforeDirectionalMutation", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestRecoveryActionIsDurableBeforeDirectionalMutation$'" + }, + { + "selector": "TestMalformedRecoveryActionBlocksMutation", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestMalformedRecoveryActionBlocksMutation$'" + }, { "selector": "TestAppliedTerminalReceiptReplaysCompleteResult", "command": "go test ./internal/kernel/repositorytransaction -run '^TestAppliedTerminalReceiptReplaysCompleteResult$'" }, { - "selector": "TestPreparingReplacementPreservesPreviousTerminalReceipt", - "command": "go test ./internal/kernel/repositorytransaction -run '^TestPreparingReplacementPreservesPreviousTerminalReceipt$'" + "selector": "TestPreparingRollbackAtomicallyReplacesPreviousTerminalReceipt", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPreparingRollbackAtomicallyReplacesPreviousTerminalReceipt$'" }, { "selector": "TestReadyReplacementRetiresPreviousReceiptAndPreservesCompleteResult", @@ -5925,6 +5933,10 @@ { "selector": "TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity", "command": "go test ./internal/kernel/repositorytransaction -run '^TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity$'" + }, + { + "selector": "TestPreparingFailureCannotClaimRollbackAfterTargetDivergence", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPreparingFailureCannotClaimRollbackAfterTargetDivergence$'" } ], "commandIds": ["proofkit.go-test"], @@ -5948,6 +5960,10 @@ { "selector": "TestApplyRejectsConcurrentCooperativeWriter", "command": "go test ./internal/kernel/repositorytransaction -run '^TestApplyRejectsConcurrentCooperativeWriter$'" + }, + { + "selector": "TestRecoverDoesNotInventIdentityForPartialPreparingJournal", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestRecoverDoesNotInventIdentityForPartialPreparingJournal$'" } ], "commandIds": ["proofkit.go-test"], From 6d55e96a9986120fb1352b2a71c020199a971802 Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 20:56:15 +0200 Subject: [PATCH 4/5] fix: close transactional adoption proof boundaries --- .../adoption_front_door_version_edge_test.go | 12 +- .../app/adoption_materialization_command.go | 6 +- .../adoption_materialization_command_test.go | 69 ++++++ ...ption_materialization_version_edge_test.go | 14 ++ internal/app/cli_contract_test.go | 2 +- .../app/cli_output_witness_contract_test.go | 54 ++++ internal/app/command_contract_generated.go | 14 +- .../app/testdata/v0.7-wire-observations.json | 2 +- .../app/testdata/v0.8-wire-observations.json | 12 +- .../adoptionmaterialization_test.go | 41 +++- .../command/adoptionmaterialization/build.go | 44 ++-- .../adoptionmaterialization/closure.go | 43 +++- .../adoptionmaterialization/closure_test.go | 11 + .../command/adoptionmaterialization/model.go | 6 - .../output_admission.go | 10 +- .../adoptionmaterialization/path_roles.go | 33 ++- .../command/adoptionmaterialization/text.go | 1 + .../stackpreset/preset_ids_generated.go | 2 +- .../repositorytransaction/invariant_test.go | 28 +++ .../repositorytransaction/output_admission.go | 6 +- .../output_admission_test.go | 4 + .../repositorytransaction/transaction.go | 21 +- .../repositorytransaction/transaction_test.go | 190 +++++++++++--- internal/tools/coveragemetrics/main.go | 232 ++++++++++-------- proofkit/cli-contract.v2.json | 90 +++++-- proofkit/requirement-bindings.json | 147 ++++++++++- 26 files changed, 845 insertions(+), 249 deletions(-) diff --git a/internal/app/adoption_front_door_version_edge_test.go b/internal/app/adoption_front_door_version_edge_test.go index dc25e7c..824b276 100644 --- a/internal/app/adoption_front_door_version_edge_test.go +++ b/internal/app/adoption_front_door_version_edge_test.go @@ -16,6 +16,7 @@ import ( ) const adoptionFrontDoorVersionEdgePath = "internal/app/testdata/v0.7-wire-observations.json" +const archivedAdoptionFrontDoorChangeRecordPath = "internal/app/testdata/v0.7-release-change-record.v2.json" type adoptionFrontDoorVersionEdge struct { AddedCommandContracts []adoptionFrontDoorCommandContract `json:"addedCommandContracts"` @@ -115,7 +116,7 @@ func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { func TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction(t *testing.T) { record := readAdoptionFrontDoorVersionEdge(t) - content, err := os.ReadFile(filepath.Join(repoRoot(t), record.ChangeRecordRef)) + content, err := os.ReadFile(filepath.Join(repoRoot(t), archivedAdoptionFrontDoorChangeRecordPath)) if err != nil { t.Fatal(err) } @@ -132,7 +133,7 @@ func TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction(t } mutantContent = append(mutantContent, '\n') mutantRoot := t.TempDir() - path := filepath.Join(mutantRoot, filepath.FromSlash(record.ChangeRecordRef)) + path := filepath.Join(mutantRoot, filepath.FromSlash(archivedAdoptionFrontDoorChangeRecordPath)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } @@ -271,10 +272,11 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if !slices.Equal(record.BreakingChangeIDs, []string{"proofkit.adoption.init-retired", "proofkit.agent-route.input-contract-v2"}) || !slices.Equal(record.AdditionChangeIDs, []string{"proofkit.adoption.front-door", "proofkit.adoption.repository-inventory", "proofkit.cli.generated-adapter-command-routes", "proofkit.cli.hierarchical-command-routes", "proofkit.python-wheel.embedded-cli-contract"}) { return fmt.Errorf("adoption front-door change inventory is not exact") } - if record.ChangeRecordRef != "internal/app/testdata/v0.7-release-change-record.v2.json" { + if record.ChangeRecordRef != "release/change-record.v2.json" { return fmt.Errorf("adoption front-door change record reference is not exact") } - changeRecordContent, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(record.ChangeRecordRef))) + archivedChangeRecordPath := filepath.Join(root, archivedAdoptionFrontDoorChangeRecordPath) + changeRecordContent, err := os.ReadFile(archivedChangeRecordPath) if err != nil { return fmt.Errorf("read adoption front-door change record: %w", err) } @@ -282,7 +284,7 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if record.ChangeRecordSHA256 != fmt.Sprintf("sha256:%x", digest) { return fmt.Errorf("adoption front-door change record digest is not exact") } - changeRecord, err := releasechange.Read(filepath.Join(root, filepath.FromSlash(record.ChangeRecordRef))) + changeRecord, err := releasechange.Read(archivedChangeRecordPath) if err != nil { return fmt.Errorf("admit adoption front-door change record: %w", err) } diff --git a/internal/app/adoption_materialization_command.go b/internal/app/adoption_materialization_command.go index 4410aae..4f113ba 100644 --- a/internal/app/adoption_materialization_command.go +++ b/internal/app/adoption_materialization_command.go @@ -49,14 +49,14 @@ func runAdoptionMaterialization(ctx context.Context, command string, args []stri } } if command == "adopt-materialize-plan" { - materialization, err := adoptionmaterialization.BuildPlan(ctx, input, options.repositoryRoot) + plan, err := adoptionmaterialization.BuildPlan(ctx, input, options.repositoryRoot) if options.format == "json" { - return writeJSON(materialization.Plan.JSONValue(), 0, err, stdout, stderr) + return writeJSON(plan.JSONValue(), 0, err, stdout, stderr) } if err != nil { return writeText("", 1, err, stdout, stderr) } - plain, err := adoptionmaterialization.RenderPlanText(materialization.Plan) + plain, err := adoptionmaterialization.RenderPlanText(plan) return writeAdoptionMaterializationText(plain, 0, err, options, stdout, stderr, capabilities) } receipt, exitCode, err := adoptionmaterialization.Apply( diff --git a/internal/app/adoption_materialization_command_test.go b/internal/app/adoption_materialization_command_test.go index 2430b02..08c0171 100644 --- a/internal/app/adoption_materialization_command_test.go +++ b/internal/app/adoption_materialization_command_test.go @@ -32,6 +32,7 @@ func TestAdoptionMaterializationCLI(t *testing.T) { t.Fatalf("plan status=%d stderr=%q stdout=%q", status, stderr, stdout) } plan := decodeCLIJSON(t, stdout).(map[string]any) + assertAdoptionMaterializationPlanRoot(t, plan) if plan["planKind"] != adoptionmaterialization.PlanKind || plan["state"] != "ready" { t.Fatalf("unexpected plan identity: %#v", plan) } @@ -54,6 +55,7 @@ func TestAdoptionMaterializationCLI(t *testing.T) { t.Fatalf("apply status=%d stderr=%q stdout=%q", status, stderr, stdout) } receipt := decodeCLIJSON(t, stdout).(map[string]any) + assertAdoptionMaterializationReceiptRoot(t, receipt) if receipt["receiptKind"] != adoptionmaterialization.ReceiptKind || receipt["state"] != adoptionmaterialization.ReceiptStatePassed { t.Fatalf("unexpected apply receipt: %#v", receipt) } @@ -90,6 +92,7 @@ func TestAdoptionMaterializationCLI(t *testing.T) { t.Fatalf("recover status=%d stderr=%q stdout=%q", status, stderr, stdout) } recovered := decodeCLIJSON(t, stdout).(map[string]any) + assertAdoptionMaterializationReceiptRoot(t, recovered) if recovered["operation"] != adoptionmaterialization.OperationRecover || recovered["state"] != adoptionmaterialization.ReceiptStatePassed { t.Fatalf("unexpected recovery receipt: %#v", recovered) } @@ -150,6 +153,72 @@ func TestAdoptionMaterializationCLI(t *testing.T) { }) } +func TestAdoptMaterializePlanOutputUsesExactRootShape(t *testing.T) { + repositoryRoot := t.TempDir() + input := adoptionMaterializationCLIInput(t, repositoryRoot) + payload, err := stablejson.Marshal(input) + if err != nil { + t.Fatal(err) + } + status, stdout, stderr := executeAgentWorkflowCLI(t, []string{"adopt", "materialize", "plan", "--input", "-", "--repo-root", repositoryRoot}, bytes.NewReader(payload), PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("plan status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + assertAdoptionMaterializationPlanRoot(t, decodeCLIJSON(t, stdout).(map[string]any)) +} + +func TestAdoptMaterializeApplyOutputUsesExactRootShape(t *testing.T) { + repositoryRoot := t.TempDir() + payload, transactionID, desiredStateID := adoptionMaterializationPlanFixture(t, repositoryRoot) + args := []string{"adopt", "materialize", "apply", "--input", "-", "--repo-root", repositoryRoot, "--expect-transaction", transactionID, "--expect-desired-state", desiredStateID} + status, stdout, stderr := executeAgentWorkflowCLI(t, args, bytes.NewReader(payload), PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("apply status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + assertAdoptionMaterializationReceiptRoot(t, decodeCLIJSON(t, stdout).(map[string]any)) +} + +func TestAdoptMaterializeRecoverOutputUsesExactRootShape(t *testing.T) { + repositoryRoot := t.TempDir() + payload, transactionID, desiredStateID := adoptionMaterializationPlanFixture(t, repositoryRoot) + applyArgs := []string{"adopt", "materialize", "apply", "--input", "-", "--repo-root", repositoryRoot, "--expect-transaction", transactionID, "--expect-desired-state", desiredStateID} + if status, stdout, stderr := executeAgentWorkflowCLI(t, applyArgs, bytes.NewReader(payload), PresentationCapabilities{}); status != 0 || stderr != "" { + t.Fatalf("apply status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + recoverArgs := []string{"adopt", "materialize", "recover", "--repo-root", repositoryRoot, "--transaction", transactionID, "--action", "resume"} + status, stdout, stderr := executeAgentWorkflowCLI(t, recoverArgs, strings.NewReader(""), PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("recover status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + assertAdoptionMaterializationReceiptRoot(t, decodeCLIJSON(t, stdout).(map[string]any)) +} + +func adoptionMaterializationPlanFixture(t *testing.T, repositoryRoot string) ([]byte, string, string) { + t.Helper() + payload, err := stablejson.Marshal(adoptionMaterializationCLIInput(t, repositoryRoot)) + if err != nil { + t.Fatal(err) + } + args := []string{"adopt", "materialize", "plan", "--input", "-", "--repo-root", repositoryRoot} + status, stdout, stderr := executeAgentWorkflowCLI(t, args, bytes.NewReader(payload), PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("plan status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + plan := decodeCLIJSON(t, stdout).(map[string]any) + transaction := plan["transaction"].(map[string]any) + return payload, transaction["transactionId"].(string), transaction["desiredStateId"].(string) +} + +func assertAdoptionMaterializationPlanRoot(t *testing.T, plan map[string]any) { + t.Helper() + assertExactObjectKeys(t, plan, []string{"manifest", "nonClaims", "planKind", "projectId", "requestId", "schemaVersion", "sourceIntent", "sourcePlanId", "state", "transaction"}, "adoption materialization plan") +} + +func assertAdoptionMaterializationReceiptRoot(t *testing.T, receipt map[string]any) { + t.Helper() + assertExactObjectKeys(t, receipt, []string{"expectedDesiredStateId", "expectedTransactionId", "failureClass", "nonClaims", "operation", "receiptId", "receiptKind", "schemaVersion", "state", "transactionResult"}, "adoption materialization receipt") +} + func adoptionMaterializationCLIInput(t *testing.T, root string) map[string]any { t.Helper() if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Pilot\n"), 0o600); err != nil { diff --git a/internal/app/adoption_materialization_version_edge_test.go b/internal/app/adoption_materialization_version_edge_test.go index 7ca5c51..1cf358e 100644 --- a/internal/app/adoption_materialization_version_edge_test.go +++ b/internal/app/adoption_materialization_version_edge_test.go @@ -17,6 +17,9 @@ import ( const adoptionMaterializationVersionEdgePath = "internal/app/testdata/v0.8-wire-observations.json" +const frozenAdoptionFrontDoorVersionEdgePath = "internal/app/testdata/v0.7-wire-observations.json" +const frozenAdoptionFrontDoorVersionEdgeSHA256 = "3f3916ff3413aed42539cfd122d0796b636f6819512459a13f4143443bd2a14e" + type adoptionMaterializationVersionEdge struct { AddedCommandContracts []materializationCommandContract `json:"addedCommandContracts"` AdditionChangeIDs []string `json:"additionChangeIds"` @@ -97,6 +100,17 @@ func TestAdoptionMaterializationVersionEdgeClosesPublicCommands(t *testing.T) { } } +func TestAdoptionMaterializationVersionEdgePreservesFrozenPredecessor(t *testing.T) { + content, err := os.ReadFile(filepath.Join(repoRoot(t), frozenAdoptionFrontDoorVersionEdgePath)) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(content) + if got := fmt.Sprintf("%x", digest); got != frozenAdoptionFrontDoorVersionEdgeSHA256 { + t.Fatalf("frozen predecessor digest=%s, want %s", got, frozenAdoptionFrontDoorVersionEdgeSHA256) + } +} + func TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift(t *testing.T) { record := readAdoptionMaterializationVersionEdge(t) currentABI := "sha256:" + currentCLIContractPublicABISHA256(t) diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 10687ed..072acf9 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "b7cc6ef91dd06ab8bd8073337034084d0fec5110d232a8e1a1f1e77055b4bec5" + cliContractPublicABISHA256 = "a5801893c2a853f2de819da7463a3aee896b3a8253b62dd5bdcc2acc3d870c96" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 diff --git a/internal/app/cli_output_witness_contract_test.go b/internal/app/cli_output_witness_contract_test.go index e3683b8..fc3a777 100644 --- a/internal/app/cli_output_witness_contract_test.go +++ b/internal/app/cli_output_witness_contract_test.go @@ -362,6 +362,30 @@ func expectedRootDistinctOutputWitnessTuples() []rootDistinctOutputWitnessTuple func rootDistinctOutputContractExpectations() []rootDistinctOutputContractExpectation { return []rootDistinctOutputContractExpectation{ + { + Command: "adopt-materialize-apply", + NativeSourceForm: "nativeSources", + NativeSourcePaths: []string{"internal/app", "internal/command/adoptionmaterialization", "internal/kernel/repositorytransaction"}, + SelectorPath: "internal/app/adoption_materialization_command_test.go", + SelectorTest: "TestAdoptMaterializeApplyOutputUsesExactRootShape", + ExecutableCommand: "go test ./internal/app -run '^TestAdoptMaterializeApplyOutputUsesExactRootShape$'", + }, + { + Command: "adopt-materialize-plan", + NativeSourceForm: "nativeSources", + NativeSourcePaths: []string{"internal/app", "internal/command/adoptionmaterialization", "internal/kernel/repositorytransaction"}, + SelectorPath: "internal/app/adoption_materialization_command_test.go", + SelectorTest: "TestAdoptMaterializePlanOutputUsesExactRootShape", + ExecutableCommand: "go test ./internal/app -run '^TestAdoptMaterializePlanOutputUsesExactRootShape$'", + }, + { + Command: "adopt-materialize-recover", + NativeSourceForm: "nativeSources", + NativeSourcePaths: []string{"internal/app", "internal/command/adoptionmaterialization", "internal/kernel/repositorytransaction"}, + SelectorPath: "internal/app/adoption_materialization_command_test.go", + SelectorTest: "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + ExecutableCommand: "go test ./internal/app -run '^TestAdoptMaterializeRecoverOutputUsesExactRootShape$'", + }, { Command: "adoption-contract-envelope", NativeSourceForm: "nativeSource", @@ -407,6 +431,21 @@ func rootDistinctOutputContractExpectations() []rootDistinctOutputContractExpect func rootDistinctOutputBindingMappings() []rootDistinctOutputBindingMapping { return []rootDistinctOutputBindingMapping{ + { + RequirementID: "REQ-PROOFKIT-PACKAGE-002", + ScenarioID: "proofkit.package-boundary.adoption-materialization-output-root-witnesses", + SelectorTest: "TestAdoptMaterializeApplyOutputUsesExactRootShape", + }, + { + RequirementID: "REQ-PROOFKIT-PACKAGE-002", + ScenarioID: "proofkit.package-boundary.adoption-materialization-output-root-witnesses", + SelectorTest: "TestAdoptMaterializePlanOutputUsesExactRootShape", + }, + { + RequirementID: "REQ-PROOFKIT-PACKAGE-002", + ScenarioID: "proofkit.package-boundary.adoption-materialization-output-root-witnesses", + SelectorTest: "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + }, { RequirementID: "REQ-PROOFKIT-PACKAGE-002", ScenarioID: "proofkit.package-boundary.cli-output-root-witnesses", @@ -432,6 +471,21 @@ func rootDistinctOutputBindingMappings() []rootDistinctOutputBindingMapping { ScenarioID: "proofkit.package-boundary.cli-output-root-witnesses", SelectorTest: "TestStandaloneMultiVariantCommandsUseExactRootShapes", }, + { + RequirementID: "REQ-PROOFKIT-QUALITY-004", + ScenarioID: "proofkit.supply-chain-quality.adoption-materialization-cli-abi", + SelectorTest: "TestAdoptMaterializeApplyOutputUsesExactRootShape", + }, + { + RequirementID: "REQ-PROOFKIT-QUALITY-004", + ScenarioID: "proofkit.supply-chain-quality.adoption-materialization-cli-abi", + SelectorTest: "TestAdoptMaterializePlanOutputUsesExactRootShape", + }, + { + RequirementID: "REQ-PROOFKIT-QUALITY-004", + ScenarioID: "proofkit.supply-chain-quality.adoption-materialization-cli-abi", + SelectorTest: "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + }, { RequirementID: "REQ-PROOFKIT-QUALITY-004", ScenarioID: "proofkit.supply-chain-quality.cli-abi-golden", diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index 4db774e..4b2cadd 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "026a20b2a4a4844222360f100600688707faed58b53a93bcc4f1a2cf108654c6" +const commandContractSourceSHA256 = "196cf9436209eb79816ad2949703c50881d2b5295ed7ccd2a03fb97c672469d4" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,15 +12,15 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-materialize-apply": {InputContractSHA256: "sha256:5b4a8043a3a4df5a520e0dfb90408eafd3ea024b79c31fd9f769a804a12c5b3b", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:b046c5bc0535a410366c6c793516cd9f2dd20b1ea1082901a81c740658ee5321", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, - "adopt-materialize-plan": {InputContractSHA256: "sha256:c1567aad5ea3bbf26effe1ae9d393024cd3e8b99ea3a608aeecd08a2848a61b9", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:b55914700a4aa7ba48de5beb7d6aa2e4650676f3a0300cdb0830bc749b22acd7", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, - "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:e722c36007389a0471b193e642041a9fb2cd6e4286ab6d7f5f097506e8ed0f2c", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, + "adopt-materialize-apply": {InputContractSHA256: "sha256:244ad531c4afc32bcbaaa618803f8513a5cae79f870d0d1c7ddaca21edddfa20", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:0577bdcebea15feb360fe38d080180289eadf7465db905d3d73689a28eecc810", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:25b0d055e4ac88127b60b1aa1b1832f7609ca3ba07048dc94f0580cb5685aae7", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:f230745191996846a8d7cb16932e4959f5f8b271e740658eb28111c3af936efb", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, + "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c2a198b32f893aa352855ee6ed891d4e1dd4f3185231e214b34e4cba2afb0cd1", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "adoption-checklist": {InputContractSHA256: "sha256:4e6c4c9b369279837a5894c0b3f842a411dce529b91c91cb2d4ec63eb5ee4c2c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-checklist.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9d0d0e60f0935407fd31007d8502459663eb4c7228dc5e3c7727ae2c9907bdc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, "adoption-doctor": {InputContractSHA256: "sha256:efa9acfe32bff07f56d9dc9902530df2979794289bc2f7f547f7a108a7dd0f35", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-doctor.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8fdfc6608f197e633f042f20031ae1014872a90aa3daa66885ffcaddca994766", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-doctor"}}, "adoption-workflow-plan": {InputContractSHA256: "sha256:b32ae67179d7b6dcf1ea66cb6b2b2691c8367ce2e2be367619b65973166da55c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-workflow-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8d64cb53ebd0307e3cebc3435286a3d2a1ee8a0ad6f7514fc0fb3285db0f565b", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-workflow-plan"}}, - "agent-route": {InputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8e7b4f4847171df5d3a628ab9c806c497553fd2713d1afcb67079342d2ef2111", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, + "agent-route": {InputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a06b5434109090d8d949a900f61206274db54afdd8602e09b509b0f81e6e5a65", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, "binding-partition": {InputContractSHA256: "sha256:366ad082045af52b2ac6604f18626d0f285b2db73b45d9a82687b8d3b0d2b3fd", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.binding-partition.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:52840879e13a00ef9a4abaad6cdb33000511674d5f9003fb56f387fdf58fadc8", FlagChoices: map[string][]string{}, RouteTokens: []string{"binding-partition"}}, "branch-authority": {InputContractSHA256: "sha256:8a3ed74978898593fbdbf1f7fa684dae450fbd9019edcd60d07f818d63363ed4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.branch-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3c7dc74842299b92cd5baf57cc8666e9415963091359e5faf654e28da89561f1", FlagChoices: map[string][]string{}, RouteTokens: []string{"branch-authority"}}, "capability-map-admission": {InputContractSHA256: "sha256:e49433f295c43c34d5d660ac9d656b117ed87208406b57723d25165ffec5d486", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.capability-map-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bfa35fe1be210ab98f3620694ab63b52a9724f7b92cd9dcbfd7b01b2c6a3555e", FlagChoices: map[string][]string{}, RouteTokens: []string{"capability-map-admission"}}, @@ -44,7 +44,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "native-evidence-guidance": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c9306d800668ecea18aaced6a21334036a935570f267baed356e6a4888025c8d", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"native-evidence-guidance"}}, "obligation-decision": {InputContractSHA256: "sha256:1dea2ed5c5066451d6d49b815cea99df2cdae2ef05d42fed16c8aeb45eb7f445", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.obligation-decision.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:96dc074f611bcc12e511bc803c548e4df623e2de869d3add29a3ea6386e04330", FlagChoices: map[string][]string{}, RouteTokens: []string{"obligation-decision"}}, "package-runtime-dependency-admission": {InputContractSHA256: "sha256:fc85887af9b8fcd899d245f0db30b2f2f68609822fc268126bf999082bb4115f", InputSchemaSummary: []string{"schemaVersion=1", "reportId", "expectedDependencySpec", "expectedLockfileIntegrity", "expectedPackageName", "expectedPackageVersion", "admissibleLocations{}", "packageResolution{}", "nonClaims[]", "root-shape-only definition proofkit.package-runtime-dependency-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c012032e8c8212fd50bc2e85669cc610609ca2124ebc992c9e88f44a1ad2d5fc", FlagChoices: map[string][]string{}, RouteTokens: []string{"package-runtime-dependency-admission"}}, - "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:5e05ba3035eb3f269223abff071f64800b5cea0253442ce5923ecc2512a4bd7c", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, + "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a4bb2558c381ba413ceaf203e669893275fc0bf99e5da843354792510d22ed8c", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, "producer-policy-self-proof": {InputContractSHA256: "sha256:d48e18826000c8d415f3c44b6c686e1da6ed962ef7ca36c9f705de8c68d034f9", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.producer-policy-self-proof.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e82a3989a743f8babc6069f7af82b1dd1ea62bad8dbb18d95e105b36f74e4276", FlagChoices: map[string][]string{}, RouteTokens: []string{"producer-policy-self-proof"}}, "proof-obligation-algebra": {InputContractSHA256: "sha256:4f176b6bc9bdbd0d96d65c071d66447d246665bda7a23269e7927f1d0b80b043", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-obligation-algebra.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f9ee9e56b349756c55856a2dab198e1ad85db70a468c38e3aeca73cfe2ed66f6", FlagChoices: map[string][]string{}, RouteTokens: []string{"proof-obligation-algebra"}}, "proof-receipt-admission": {InputContractSHA256: "sha256:7cb4c4fb60c8b5a37109bbd8c00d567749f7d181bbc905d8bc58155f139c44cb", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-receipt-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3f802ac3fac6762ede51f0e0a151f16dc10b4a20344a3887b3ee8bae43ce94f2", FlagChoices: map[string][]string{}, RouteTokens: []string{"proof-receipt-admission"}}, @@ -83,7 +83,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "selective-gate-evidence": {InputContractSHA256: "sha256:8aa178ab7ca7c475c23707bc4e15fd3f9f8d57acf6f6dcf279677e7769a45586", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-evidence.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:723569262bb85d9674b2a78d3bcb6e9f4cab229b71e8c784ff1b804a7fcade71", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-evidence"}}, "selective-gate-obligation-decision-input": {InputContractSHA256: "sha256:85761fcbc0ea94239d55bf379d0592a6ca814e6612a2d609a651f6cdaf8ca10a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-obligation-decision-input.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:ab9dddabe975238d7019266c43350afa2df1a61d4c2eb7bc23afd520b588a2da", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-obligation-decision-input"}}, "selective-gate-plan": {InputContractSHA256: "sha256:5293a5a4c7d8426cf637e6f8d252095ca0eb1714365bb89bec83307b778c678a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d7bffed853af5595af08b03859be01c283a3bdff1b3502d94ddc190889977647", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-plan"}}, - "self-check": {InputContractSHA256: "sha256:f33a8575835a9b67c00ded036fdde467eaab650a7d509e1b7af5ca825a0fc671", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f0c4211fd71c4dd042bd1862a0e31d89265ae56707d78930471d0f7cb3fcd279", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, + "self-check": {InputContractSHA256: "sha256:e58bd8b13055b4430f8fb8b9db07ee2bc2101749955754d55240956ebc19cb9c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:da85234836879ca800ae37239a3b542dd56fd21fe53df0524ea480e420fd77fc", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, "spec-overview-claims": {InputContractSHA256: "sha256:2490dcd34ba7485e13f8f33e8a288a0463c4c52cc6b0d82c57777466927e49a4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-overview-claims.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:554f3a7020e9820ccb90672629fd769c52b2f298f356040aa3b0a817666cbfbf", FlagChoices: map[string][]string{}, RouteTokens: []string{"spec-overview-claims"}}, "spec-proof-bundle-admission": {InputContractSHA256: "sha256:6b6c2875b6476e63a1911e7d6112d9999df2babbee969f84abc4c9e4b470c933", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-proof-bundle-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e9e0eb66cebca3b99fe5036fb2e7327a9284934ed76f58818d18094d0546fc52", FlagChoices: map[string][]string{}, RouteTokens: []string{"spec-proof-bundle-admission"}}, "stack-preset": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:ef5920f363a4a96dcac308ea8412260a06e64ba4876460a369aefb8983130a9d", FlagChoices: map[string][]string{"--preset": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"stack-preset"}}, diff --git a/internal/app/testdata/v0.7-wire-observations.json b/internal/app/testdata/v0.7-wire-observations.json index b3fb0ab..f5166af 100644 --- a/internal/app/testdata/v0.7-wire-observations.json +++ b/internal/app/testdata/v0.7-wire-observations.json @@ -5,7 +5,7 @@ "version": "0.7.0", "evidenceClass": "owner_authored_frozen_version_edge_observation", "commandContractSelection": "declared_input_contract_id_change", - "changeRecordRef": "internal/app/testdata/v0.7-release-change-record.v2.json", + "changeRecordRef": "release/change-record.v2.json", "changeRecordSha256": "sha256:25dfeccb631449f0c1eb0d1bc6d42d483c3100b34d7ede7a0d16390f3bef3d49", "changedGeneratedArtifacts": [ { diff --git a/internal/app/testdata/v0.8-wire-observations.json b/internal/app/testdata/v0.8-wire-observations.json index 816bcd9..4f8c644 100644 --- a/internal/app/testdata/v0.8-wire-observations.json +++ b/internal/app/testdata/v0.8-wire-observations.json @@ -9,18 +9,18 @@ "changeRecordRef": "release/change-record.v2.json", "changeRecordSha256": "sha256:4c8434f7b77a5c623441b021a37b50786d56aba7f65091c38be5ed902231318d", "previousPublicAbiSha256": "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7", - "currentPublicAbiSha256": "sha256:b7cc6ef91dd06ab8bd8073337034084d0fec5110d232a8e1a1f1e77055b4bec5", + "currentPublicAbiSha256": "sha256:a5801893c2a853f2de819da7463a3aee896b3a8253b62dd5bdcc2acc3d870c96", "addedCommandContracts": [ { "command": "adopt-materialize-apply", "route": ["adopt", "materialize", "apply"], "inputContract": { "contractId": "proofkit.adopt-materialize-apply.input.v1", - "contractSha256": "sha256:5b4a8043a3a4df5a520e0dfb90408eafd3ea024b79c31fd9f769a804a12c5b3b" + "contractSha256": "sha256:244ad531c4afc32bcbaaa618803f8513a5cae79f870d0d1c7ddaca21edddfa20" }, "outputContract": { "contractId": "proofkit.adopt-materialize-apply.output.v1", - "contractSha256": "sha256:b046c5bc0535a410366c6c793516cd9f2dd20b1ea1082901a81c740658ee5321" + "contractSha256": "sha256:0577bdcebea15feb360fe38d080180289eadf7465db905d3d73689a28eecc810" } }, { @@ -28,11 +28,11 @@ "route": ["adopt", "materialize", "plan"], "inputContract": { "contractId": "proofkit.adopt-materialize-plan.input.v1", - "contractSha256": "sha256:c1567aad5ea3bbf26effe1ae9d393024cd3e8b99ea3a608aeecd08a2848a61b9" + "contractSha256": "sha256:25b0d055e4ac88127b60b1aa1b1832f7609ca3ba07048dc94f0580cb5685aae7" }, "outputContract": { "contractId": "proofkit.adopt-materialize-plan.output.v1", - "contractSha256": "sha256:b55914700a4aa7ba48de5beb7d6aa2e4650676f3a0300cdb0830bc749b22acd7" + "contractSha256": "sha256:f230745191996846a8d7cb16932e4959f5f8b271e740658eb28111c3af936efb" } }, { @@ -40,7 +40,7 @@ "route": ["adopt", "materialize", "recover"], "outputContract": { "contractId": "proofkit.adopt-materialize-recover.output.v1", - "contractSha256": "sha256:e722c36007389a0471b193e642041a9fb2cd6e4286ab6d7f5f097506e8ed0f2c" + "contractSha256": "sha256:c2a198b32f893aa352855ee6ed891d4e1dd4f3185231e214b34e4cba2afb0cd1" } } ], diff --git a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go index f651175..d432820 100644 --- a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go +++ b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go @@ -25,15 +25,15 @@ func TestMaterializationWholeChainIsCanonicalAndOwnerClosed(t *testing.T) { root := t.TempDir() request := validRequest(t, root) - materialization, err := BuildPlan(context.Background(), request, root) + plan, err := BuildPlan(context.Background(), request, root) if err != nil { t.Fatalf("BuildPlan() error = %v", err) } - planRaw := jsonRoundTripValue(t, materialization.Plan.JSONValue()) - if admitted, err := AdmitPlanOutput(planRaw); err != nil || admitted.Transaction.TransactionID != materialization.Transaction.TransactionID { + planRaw := jsonRoundTripValue(t, plan.JSONValue()) + if admitted, err := AdmitPlanOutput(planRaw); err != nil || admitted.Transaction.TransactionID != plan.Transaction.TransactionID { t.Fatalf("AdmitPlanOutput() plan=%#v error=%v", admitted, err) } - planBytes, err := stablejson.Marshal(materialization.Plan.JSONValue()) + planBytes, err := stablejson.Marshal(plan.JSONValue()) if err != nil { t.Fatal(err) } @@ -46,7 +46,7 @@ func TestMaterializationWholeChainIsCanonicalAndOwnerClosed(t *testing.T) { t.Fatalf("read-only plan created transaction state: %v", err) } - receipt, exitCode, err := Apply(context.Background(), request, root, materialization.Transaction.TransactionID, materialization.Transaction.DesiredStateID) + receipt, exitCode, err := Apply(context.Background(), request, root, plan.Transaction.TransactionID, plan.Transaction.DesiredStateID) if err != nil || exitCode != 0 || receipt.State != ReceiptStatePassed || receipt.TransactionResult == nil || receipt.TransactionResult.State != repositorytransaction.StateApplied { t.Fatalf("Apply() receipt=%#v exit=%d err=%v", receipt, exitCode, err) } @@ -79,11 +79,11 @@ func TestMaterializationWholeChainIsCanonicalAndOwnerClosed(t *testing.T) { func TestMaterializationOutputAdmissionRejectsCrossOwnerMutants(t *testing.T) { root := t.TempDir() request := validRequest(t, root) - materialization, err := BuildPlan(context.Background(), request, root) + plan, err := BuildPlan(context.Background(), request, root) if err != nil { t.Fatal(err) } - planMutant := jsonRoundTripValue(t, materialization.Plan.JSONValue()).(map[string]any) + planMutant := jsonRoundTripValue(t, plan.JSONValue()).(map[string]any) transaction := planMutant["transaction"].(map[string]any) operations := transaction["operations"].([]any) transaction["operations"] = operations[1:] @@ -91,7 +91,7 @@ func TestMaterializationOutputAdmissionRejectsCrossOwnerMutants(t *testing.T) { t.Fatal("AdmitPlanOutput() admitted a transaction that omitted a manifest route") } - receipt, exitCode, err := Apply(context.Background(), request, root, materialization.Transaction.TransactionID, materialization.Transaction.DesiredStateID) + receipt, exitCode, err := Apply(context.Background(), request, root, plan.Transaction.TransactionID, plan.Transaction.DesiredStateID) if err != nil || exitCode != 0 { t.Fatalf("Apply() receipt=%#v exit=%d error=%v", receipt, exitCode, err) } @@ -132,6 +132,27 @@ func TestReceiptAdmissionRejectsOperationAttributionMutants(t *testing.T) { AppliedCount: 1, AppliedCountKnown: true, State: repositorytransaction.StateApplied, TransactionID: transactionID, }, }, + { + ExpectedTransactionID: transactionID, + FailureClass: "cleanup_failed", + NonClaims: mergedNonClaims(nil), + Operation: OperationRecover, + State: ReceiptStateCleanupRequired, + TransactionResult: &repositorytransaction.Result{ + FailureClass: "cleanup_failed", State: repositorytransaction.StateCleanupRequired, TransactionID: transactionID, + }, + }, + { + ExpectedTransactionID: transactionID, + FailureClass: "ambiguous_target_state", + NonClaims: mergedNonClaims(nil), + Operation: OperationRecover, + State: ReceiptStateRecoveryRequired, + TransactionResult: &repositorytransaction.Result{ + FailureClass: "ambiguous_target_state", State: repositorytransaction.StateRecoveryRequired, + TransactionID: "sha256:" + strings.Repeat("c", 64), + }, + }, } for index := range tests { identity := tests[index].identityValue() @@ -350,11 +371,11 @@ func TestMaterializationRejectsCrossRecordDriftAndManifestMutation(t *testing.T) t.Fatalf("BuildPlan(drifted owner) error=%v", err) } - materialization, err := BuildPlan(context.Background(), request, root) + plan, err := BuildPlan(context.Background(), request, root) if err != nil { t.Fatal(err) } - manifest := cloneValue(t, materialization.Plan.Manifest.JSONValue()).(map[string]any) + manifest := cloneValue(t, plan.Manifest.JSONValue()).(map[string]any) manifest["routes"].([]any)[0].(map[string]any)["path"] = "../outside.json" if _, err := AdmitManifest(manifest); err == nil { t.Fatal("AdmitManifest() accepted root-escaping route") diff --git a/internal/command/adoptionmaterialization/build.go b/internal/command/adoptionmaterialization/build.go index f9ec8d4..47ae109 100644 --- a/internal/command/adoptionmaterialization/build.go +++ b/internal/command/adoptionmaterialization/build.go @@ -15,22 +15,22 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) -func BuildPlan(ctx context.Context, raw any, repositoryRoot string) (Materialization, error) { +func BuildPlan(ctx context.Context, raw any, repositoryRoot string) (Plan, error) { request, err := admitRequest(raw) if err != nil { - return Materialization{}, err + return Plan{}, err } children, err := childArtifacts(request) if err != nil { - return Materialization{}, err + return Plan{}, err } manifest, err := buildManifest(request, children) if err != nil { - return Materialization{}, err + return Plan{}, err } manifestArtifact, err := encodeArtifact(ArtifactProjectManifest, manifest.ManifestID, ProjectManifestPath, manifest.JSONValue()) if err != nil { - return Materialization{}, err + return Plan{}, err } artifacts := append(children, manifestArtifact) targets := make([]repositorytransaction.Target, 0, len(artifacts)) @@ -39,10 +39,10 @@ func BuildPlan(ctx context.Context, raw any, repositoryRoot string) (Materializa } transaction, err := repositorytransaction.BuildPlan(ctx, repositoryRoot, targets) if err != nil { - return Materialization{}, err + return Plan{}, err } if err := validateExistingArtifacts(transaction, artifacts, request.ProjectID); err != nil { - return Materialization{}, err + return Plan{}, err } plan := Plan{ Manifest: manifest, NonClaims: mergedNonClaims(request.NonClaims), ProjectID: request.ProjectID, @@ -50,13 +50,13 @@ func BuildPlan(ctx context.Context, raw any, repositoryRoot string) (Materializa Transaction: transaction, } if _, err := AdmitPlanOutput(plan.JSONValue()); err != nil { - return Materialization{}, fmt.Errorf("admit adoption materialization plan output: %w", err) + return Plan{}, fmt.Errorf("admit adoption materialization plan output: %w", err) } encoded, err := stablejson.Marshal(plan.JSONValue()) if err != nil || len(encoded) > MaximumOutputBytes { - return Materialization{}, fmt.Errorf("adoption materialization plan exceeds its output byte limit") + return Plan{}, fmt.Errorf("adoption materialization plan exceeds its output byte limit") } - return Materialization{Artifacts: artifacts, Plan: plan, Transaction: transaction}, nil + return plan, nil } func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, expectedDesiredStateID string) (Receipt, int, error) { @@ -68,19 +68,19 @@ func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, if err != nil { return Receipt{}, 1, err } - materialization, err := BuildPlan(ctx, raw, repositoryRoot) + plan, err := BuildPlan(ctx, raw, repositoryRoot) if err != nil { if errors.Is(err, repositorytransaction.ErrRecoveryRequired) { return pendingReceipt(OperationApply, expected, expectedDesired, err, nil) } return Receipt{}, 1, err } - if materialization.Transaction.DesiredStateID != expectedDesired { - return blockedReceipt(OperationApply, expected, expectedDesired, "desired_state_identity_mismatch", materialization.Plan.NonClaims) + if plan.Transaction.DesiredStateID != expectedDesired { + return blockedReceipt(OperationApply, expected, expectedDesired, "desired_state_identity_mismatch", plan.NonClaims) } - if materialization.Transaction.TransactionID != expected { - if transactionHasChanges(materialization.Transaction) { - return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_identity_mismatch", materialization.Plan.NonClaims) + if plan.Transaction.TransactionID != expected { + if transactionHasChanges(plan.Transaction) { + return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_identity_mismatch", plan.NonClaims) } terminal, terminalErr := repositorytransaction.ReadTerminalResult(ctx, repositoryRoot, expected) replay, failureClass, replayErr := classifyTerminalReplay(terminal, terminalErr) @@ -88,21 +88,21 @@ func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, return Receipt{}, 1, replayErr } if failureClass != "" { - return blockedReceipt(OperationApply, expected, expectedDesired, failureClass, materialization.Plan.NonClaims) + return blockedReceipt(OperationApply, expected, expectedDesired, failureClass, plan.NonClaims) } - return resultReceipt(OperationApply, expected, expectedDesired, replay, materialization.Plan.NonClaims) + return resultReceipt(OperationApply, expected, expectedDesired, replay, plan.NonClaims) } - result, err := repositorytransaction.Apply(ctx, repositoryRoot, materialization.Transaction) + result, err := repositorytransaction.Apply(ctx, repositoryRoot, plan.Transaction) if err != nil { if errors.Is(err, repositorytransaction.ErrBusy) || errors.Is(err, repositorytransaction.ErrRecoveryRequired) { if errors.Is(err, repositorytransaction.ErrRecoveryRequired) { - return pendingReceipt(OperationApply, expected, expectedDesired, err, materialization.Plan.NonClaims) + return pendingReceipt(OperationApply, expected, expectedDesired, err, plan.NonClaims) } - return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_busy", materialization.Plan.NonClaims) + return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_busy", plan.NonClaims) } return Receipt{}, 1, err } - return resultReceipt(OperationApply, expected, expectedDesired, result, materialization.Plan.NonClaims) + return resultReceipt(OperationApply, expected, expectedDesired, result, plan.NonClaims) } func classifyTerminalReplay(result repositorytransaction.Result, err error) (repositorytransaction.Result, string, error) { diff --git a/internal/command/adoptionmaterialization/closure.go b/internal/command/adoptionmaterialization/closure.go index edebe5b..107e7fc 100644 --- a/internal/command/adoptionmaterialization/closure.go +++ b/internal/command/adoptionmaterialization/closure.go @@ -70,6 +70,7 @@ func sameRequirementProjection(source requirementsourceadmission.Requirement, bi } func validateInventoryReferences(request Request, requirements map[string]requirementsourceadmission.Requirement) error { + routes := newBindingRouteIndex(request.Binding.Bindings) for _, entry := range request.Inventory.Entries { requirementRefs := stringSet(entry.RequirementRefs) witnessRefs := stringSet(entry.WitnessRefs) @@ -78,17 +79,17 @@ func validateInventoryReferences(request Request, requirements map[string]requir if _, ok := requirements[requirementID]; !ok { return fmt.Errorf("adoption materialization test inventory references an unknown requirement") } - if !hasBindingRoute(request.Binding.Bindings, stringSet([]string{requirementID}), witnessRefs, commandRefs, entry.SourcePath) { + if !routes.hasRequirementRoute(requirementID, witnessRefs, commandRefs, entry.SourcePath) { return fmt.Errorf("adoption materialization test inventory requirement reference is not connected to its witness route") } } for _, witnessID := range entry.WitnessRefs { - if !hasBindingRoute(request.Binding.Bindings, requirementRefs, stringSet([]string{witnessID}), commandRefs, entry.SourcePath) { + if !routes.hasWitnessRoute(witnessID, requirementRefs, commandRefs, entry.SourcePath) { return fmt.Errorf("adoption materialization test inventory witness reference is not connected to its requirement route") } } for _, commandID := range entry.CommandRefs { - if !hasBindingRoute(request.Binding.Bindings, requirementRefs, witnessRefs, stringSet([]string{commandID}), entry.SourcePath) { + if !routes.hasCommandRoute(commandID, requirementRefs, witnessRefs, entry.SourcePath) { return fmt.Errorf("adoption materialization test inventory command reference is not connected to its requirement route") } } @@ -104,7 +105,41 @@ func stringSet(values []string) map[string]struct{} { return result } -func hasBindingRoute(bindings []requirementbinding.Binding, requirementRefs, witnessRefs, commandRefs map[string]struct{}, sourcePath string) bool { +type bindingRouteIndex struct { + byCommand map[string][]requirementbinding.Binding + byRequirement map[string][]requirementbinding.Binding + byWitness map[string][]requirementbinding.Binding +} + +func newBindingRouteIndex(bindings []requirementbinding.Binding) bindingRouteIndex { + index := bindingRouteIndex{ + byCommand: map[string][]requirementbinding.Binding{}, + byRequirement: map[string][]requirementbinding.Binding{}, + byWitness: map[string][]requirementbinding.Binding{}, + } + for _, binding := range bindings { + index.byRequirement[binding.RequirementID] = append(index.byRequirement[binding.RequirementID], binding) + index.byWitness[binding.WitnessID] = append(index.byWitness[binding.WitnessID], binding) + for _, commandID := range binding.CommandIDs { + index.byCommand[commandID] = append(index.byCommand[commandID], binding) + } + } + return index +} + +func (index bindingRouteIndex) hasRequirementRoute(requirementID string, witnessRefs, commandRefs map[string]struct{}, sourcePath string) bool { + return bindingsContainRoute(index.byRequirement[requirementID], nil, witnessRefs, commandRefs, sourcePath) +} + +func (index bindingRouteIndex) hasWitnessRoute(witnessID string, requirementRefs, commandRefs map[string]struct{}, sourcePath string) bool { + return bindingsContainRoute(index.byWitness[witnessID], requirementRefs, stringSet([]string{witnessID}), commandRefs, sourcePath) +} + +func (index bindingRouteIndex) hasCommandRoute(commandID string, requirementRefs, witnessRefs map[string]struct{}, sourcePath string) bool { + return bindingsContainRoute(index.byCommand[commandID], requirementRefs, witnessRefs, nil, sourcePath) +} + +func bindingsContainRoute(bindings []requirementbinding.Binding, requirementRefs, witnessRefs, commandRefs map[string]struct{}, sourcePath string) bool { for _, binding := range bindings { if len(requirementRefs) > 0 { if _, ok := requirementRefs[binding.RequirementID]; !ok { diff --git a/internal/command/adoptionmaterialization/closure_test.go b/internal/command/adoptionmaterialization/closure_test.go index c4724c4..f51f0b7 100644 --- a/internal/command/adoptionmaterialization/closure_test.go +++ b/internal/command/adoptionmaterialization/closure_test.go @@ -1,6 +1,7 @@ package adoptionmaterialization import ( + "context" "testing" "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" @@ -50,6 +51,16 @@ func TestPathRoleLedgerRejectsWriteReferenceCollisions(t *testing.T) { } } +func TestRequirementProjectionRequiresClaimLevelParity(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + binding := request["requirementProofBinding"].(map[string]any)["record"].(map[string]any) + binding["requirements"].([]any)[0].(map[string]any)["claimLevel"] = "advisory" + if _, err := BuildPlan(context.Background(), request, root); err == nil { + t.Fatal("BuildPlan() admitted claim-level drift between requirement owners") + } +} + func TestInventoryReferencesMustResolveThroughBindingEdges(t *testing.T) { bindings := []requirementbinding.Binding{ {RequirementID: "REQ-A", WitnessID: "witness.a", WitnessPath: "a_test.go", CommandIDs: []string{"command.a"}}, diff --git a/internal/command/adoptionmaterialization/model.go b/internal/command/adoptionmaterialization/model.go index 2405b90..df3aa34 100644 --- a/internal/command/adoptionmaterialization/model.go +++ b/internal/command/adoptionmaterialization/model.go @@ -100,12 +100,6 @@ type Receipt struct { TransactionResult *repositorytransaction.Result } -type Materialization struct { - Artifacts []artifact - Plan Plan - Transaction repositorytransaction.Plan -} - func (plan Plan) JSONValue() map[string]any { return map[string]any{ "manifest": plan.Manifest.JSONValue(), diff --git a/internal/command/adoptionmaterialization/output_admission.go b/internal/command/adoptionmaterialization/output_admission.go index cb289ab..b96050d 100644 --- a/internal/command/adoptionmaterialization/output_admission.go +++ b/internal/command/adoptionmaterialization/output_admission.go @@ -200,8 +200,11 @@ func validateReceiptRelation(receipt Receipt) error { if receipt.State != wantState || receipt.FailureClass != receipt.TransactionResult.FailureClass { return fmt.Errorf("adoption materialization receipt outcome contradicts its transaction result") } - if receipt.State == ReceiptStatePassed && receipt.TransactionResult.TransactionID != "" && receipt.TransactionResult.TransactionID != receipt.ExpectedTransactionID { - return fmt.Errorf("adoption materialization passed receipt transaction identity is inconsistent") + observedPendingTransaction := receipt.Operation == OperationApply && + receipt.TransactionResult.State == repositorytransaction.StateRecoveryRequired && + receipt.TransactionResult.FailureClass == "pending_transaction_state" + if receipt.TransactionResult.TransactionID != "" && receipt.TransactionResult.TransactionID != receipt.ExpectedTransactionID && !observedPendingTransaction { + return fmt.Errorf("adoption materialization receipt transaction identity is inconsistent") } if receipt.State == ReceiptStatePassed && receipt.TransactionResult.TransactionID == "" { return fmt.Errorf("adoption materialization passed receipt requires an observed transaction identity") @@ -212,6 +215,9 @@ func validateReceiptRelation(receipt Receipt) error { if receipt.Operation == OperationRecover && receipt.State == ReceiptStatePassed && receipt.TransactionResult.RecoveredBy == "" { return fmt.Errorf("adoption materialization recovery receipt requires recovery attribution") } + if receipt.Operation == OperationRecover && (receipt.TransactionResult.State == repositorytransaction.StateCleanupRequired || receipt.TransactionResult.State == repositorytransaction.StateDurabilityUnknown) && receipt.TransactionResult.RecoveredBy == "" { + return fmt.Errorf("adoption materialization recovery cleanup receipt requires recovery attribution") + } return nil } diff --git a/internal/command/adoptionmaterialization/path_roles.go b/internal/command/adoptionmaterialization/path_roles.go index 0b2846f..dc3d25b 100644 --- a/internal/command/adoptionmaterialization/path_roles.go +++ b/internal/command/adoptionmaterialization/path_roles.go @@ -27,7 +27,9 @@ type pathUse struct { } func validatePathRoles(uses []pathUse) error { - for index, use := range uses { + targets := make([]pathUse, 0, repositorytransaction.MaximumOperations) + references := make([]pathUse, 0, len(uses)) + for _, use := range uses { if _, err := pathidentity.Key(use.Path); err != nil { return fmt.Errorf("adoption materialization %s path identity is invalid", use.Role) } @@ -35,19 +37,38 @@ func validatePathRoles(uses []pathUse) error { if err != nil || overlapsControl { return fmt.Errorf("adoption materialization %s path overlaps the transaction control namespace", use.Role) } + if use.Target { + targets = append(targets, use) + } else { + references = append(references, use) + } + } + for index, target := range targets { for prior := 0; prior < index; prior++ { - overlaps, err := pathidentity.Overlaps(use.Path, uses[prior].Path) - if err != nil { - return fmt.Errorf("adoption materialization path identity is invalid") + if err := validatePathRolePair(targets[prior], target); err != nil { + return err } - if overlaps && !compatiblePathUses(use, uses[prior]) { - return fmt.Errorf("adoption materialization path roles conflict: %s and %s", uses[prior].Role, use.Role) + } + for _, reference := range references { + if err := validatePathRolePair(target, reference); err != nil { + return err } } } return nil } +func validatePathRolePair(left, right pathUse) error { + overlaps, err := pathidentity.Overlaps(left.Path, right.Path) + if err != nil { + return fmt.Errorf("adoption materialization path identity is invalid") + } + if overlaps && !compatiblePathUses(left, right) { + return fmt.Errorf("adoption materialization path roles conflict: %s and %s", left.Role, right.Role) + } + return nil +} + func compatiblePathUses(left, right pathUse) bool { if !left.Target && !right.Target { return true diff --git a/internal/command/adoptionmaterialization/text.go b/internal/command/adoptionmaterialization/text.go index 9c27ecc..704ea74 100644 --- a/internal/command/adoptionmaterialization/text.go +++ b/internal/command/adoptionmaterialization/text.go @@ -11,6 +11,7 @@ func RenderPlanText(plan Plan) (string, error) { "State: ready", "Project: " + plan.ProjectID, "Transaction: " + plan.Transaction.TransactionID, + "Desired state: " + plan.Transaction.DesiredStateID, fmt.Sprintf("Operations: %d", len(plan.Transaction.Operations)), } for _, operation := range plan.Transaction.Operations { diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index b00e4b7..1f9a31d 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "026a20b2a4a4844222360f100600688707faed58b53a93bcc4f1a2cf108654c6" +const presetContractSourceSHA256 = "196cf9436209eb79816ad2949703c50881d2b5295ed7ccd2a03fb97c672469d4" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/kernel/repositorytransaction/invariant_test.go b/internal/kernel/repositorytransaction/invariant_test.go index 489dba4..8b0fe49 100644 --- a/internal/kernel/repositorytransaction/invariant_test.go +++ b/internal/kernel/repositorytransaction/invariant_test.go @@ -327,6 +327,34 @@ func TestCommittedRecoveryRejectsRollback(t *testing.T) { } } +func TestPreparingRecoveryRejectsResumeBeforeActionSelection(t *testing.T) { + rootPath := t.TempDir() + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/target.json", Content: []byte("desired\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := prepareJournal(root, plan); err != nil { + root.Close() + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + + result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) + if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "preparing_state_mismatch" || result.RecoveredBy != "" { + t.Fatalf("Recover(resume preparing)=%#v, %v", result, err) + } + rolledBack, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryRollback) + if err != nil || rolledBack.State != StateRolledBack || rolledBack.RecoveredBy != RecoveryRollback { + t.Fatalf("Recover(rollback preparing)=%#v, %v", rolledBack, err) + } +} + func TestUnknownRecoveryStateDoesNotAdoptExpectedIdentity(t *testing.T) { root := t.TempDir() unknown := filepath.Join(root, ControlDirectory, "unknown") diff --git a/internal/kernel/repositorytransaction/output_admission.go b/internal/kernel/repositorytransaction/output_admission.go index 3f17e80..5c5e32b 100644 --- a/internal/kernel/repositorytransaction/output_admission.go +++ b/internal/kernel/repositorytransaction/output_admission.go @@ -138,7 +138,11 @@ func validateResultRelation(result Result) error { if !result.AppliedCountKnown || result.AppliedCount != 0 || result.TransactionID == "" || result.RecoveredBy == RecoveryResume || result.RecoveredBy == "" && result.FailureClass == "" { return fmt.Errorf("rolled-back repository transaction result is inconsistent") } - case StateCleanupRequired, StateDurabilityUnknown, StateRecoveryRequired: + case StateCleanupRequired, StateDurabilityUnknown: + if result.FailureClass == "" || result.TransactionID == "" { + return fmt.Errorf("cleanup-pending repository transaction result is inconsistent") + } + case StateRecoveryRequired: if result.FailureClass == "" { return fmt.Errorf("non-terminal repository transaction result requires a failure class") } diff --git a/internal/kernel/repositorytransaction/output_admission_test.go b/internal/kernel/repositorytransaction/output_admission_test.go index 67d6e65..294b316 100644 --- a/internal/kernel/repositorytransaction/output_admission_test.go +++ b/internal/kernel/repositorytransaction/output_admission_test.go @@ -28,6 +28,8 @@ func TestPlanAndResultOutputAdmissionRejectSemanticMutants(t *testing.T) { {AppliedCount: 1, AppliedCountKnown: true, State: StateApplied, TransactionID: transactionID}, {AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: transactionID}, {AppliedCountKnown: true, RecoveredBy: RecoveryRollback, State: StateRolledBack, TransactionID: transactionID}, + {FailureClass: "cleanup_failed", State: StateCleanupRequired, TransactionID: transactionID}, + {FailureClass: "applied_cleanup_durability_unknown", State: StateDurabilityUnknown, TransactionID: transactionID}, {FailureClass: "ambiguous_target_state", State: StateRecoveryRequired, TransactionID: transactionID}, } for _, result := range results { @@ -43,6 +45,8 @@ func TestPlanAndResultOutputAdmissionRejectSemanticMutants(t *testing.T) { for _, impossible := range []Result{ {AppliedCountKnown: true, State: StateApplied, TransactionID: transactionID}, {AppliedCountKnown: true, State: StateRolledBack, TransactionID: transactionID}, + {FailureClass: "cleanup_failed", State: StateCleanupRequired}, + {FailureClass: "applied_cleanup_durability_unknown", State: StateDurabilityUnknown}, } { if _, err := AdmitResultOutput(impossible.JSONValue()); err == nil { t.Fatalf("AdmitResultOutput() admitted unreachable result %#v", impossible) diff --git a/internal/kernel/repositorytransaction/transaction.go b/internal/kernel/repositorytransaction/transaction.go index cc164f5..81a4b54 100644 --- a/internal/kernel/repositorytransaction/transaction.go +++ b/internal/kernel/repositorytransaction/transaction.go @@ -48,30 +48,21 @@ func (runtime engine) apply(ctx context.Context, rootPath string, plan Plan) (Re return Result{}, err } defer root.Close() - if pending, err := pendingTransactionState(root); err != nil { - return Result{}, err - } else if pending.Exists { - return Result{}, &RecoveryRequiredError{TransactionID: pending.TransactionID} - } if err := validateExecutablePlan(plan, rootID); err != nil { return Result{}, err } + changed := changedCount(plan) prefix, err := classifyPrefix(root, plan) if err != nil { return Result{}, fmt.Errorf("repository transaction target snapshot changed") } - changed := changedCount(plan) - if prefix == changed { - return Result{AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: plan.TransactionID}, nil - } - if prefix != 0 { + if prefix != 0 && prefix != changed { return Result{}, fmt.Errorf("repository transaction target snapshot is a partial prefix without recovery state") } - if err := verifyCreatedDirectories(root, plan); err != nil { - return Result{}, err - } - if changed == 0 { - return Result{AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: plan.TransactionID}, nil + if prefix != changed { + if err := verifyCreatedDirectories(root, plan); err != nil { + return Result{}, err + } } if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction apply cancelled: %w", err) diff --git a/internal/kernel/repositorytransaction/transaction_test.go b/internal/kernel/repositorytransaction/transaction_test.go index 315b81f..490d2b2 100644 --- a/internal/kernel/repositorytransaction/transaction_test.go +++ b/internal/kernel/repositorytransaction/transaction_test.go @@ -1,6 +1,7 @@ package repositorytransaction import ( + "bufio" "bytes" "context" "errors" @@ -472,36 +473,74 @@ func TestRecoverRejectsUnknownActiveEntryBeforeMutation(t *testing.T) { assertTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) } -func TestProcessDeathAfterRenameIsRecoverable(t *testing.T) { - if os.Getenv("PROOFKIT_TRANSACTION_CRASH_HELPER") == "1" { - runTransactionCrashHelper(t) +func TestProcessInterruptionAtEveryMutationBoundaryIsRecoverable(t *testing.T) { + if point := os.Getenv("PROOFKIT_TRANSACTION_BOUNDARY_POINT"); point != "" { + runMutationBoundaryCrashHelper(t, failurePoint(point)) return } - rootPath := t.TempDir() - mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) - mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) - targets := crashHelperTargets() - plan, err := BuildPlan(context.Background(), rootPath, targets) - if err != nil { - t.Fatal(err) - } - command := exec.Command(os.Args[0], "-test.run=^TestProcessDeathAfterRenameIsRecoverable$") - command.Env = append(os.Environ(), "PROOFKIT_TRANSACTION_CRASH_HELPER=1", "PROOFKIT_TRANSACTION_CRASH_ROOT="+rootPath) - err = command.Run() - var exitError *exec.ExitError - if !errors.As(err, &exitError) || exitError.ExitCode() != 73 { - t.Fatalf("crash helper error = %v", err) - } - result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryResume) - if err != nil { - t.Fatal(err) + tests := []struct { + point failurePoint + action string + finalState string + }{ + {point: faultAfterJournal, action: RecoveryRollback, finalState: StateRolledBack}, + {point: faultAfterStaging, action: RecoveryRollback, finalState: StateRolledBack}, + {point: faultAfterReady, action: RecoveryRollback, finalState: StateRolledBack}, + {point: faultAfterDirectory, action: RecoveryRollback, finalState: StateRolledBack}, + {point: faultBeforePublish, action: RecoveryRollback, finalState: StateRolledBack}, + {point: faultAfterPublish, action: RecoveryRollback, finalState: StateRolledBack}, + {point: faultAfterRollback, action: RecoveryRollback, finalState: StateRolledBack}, + {point: faultAfterTerminal, action: RecoveryResume, finalState: StateApplied}, + {point: faultBeforeCleanup, action: RecoveryResume, finalState: StateApplied}, + {point: faultAfterStateRemoval, action: RecoveryResume, finalState: StateApplied}, } - if result.State != StateApplied || !result.AppliedCountKnown || result.AppliedCount != 2 { - t.Fatalf("Recover() result = %#v", result) + for _, test := range tests { + t.Run(string(test.point), func(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + mustWriteTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, mutationBoundaryTargets()) + if err != nil { + t.Fatal(err) + } + command := exec.Command(os.Args[0], "-test.run=^TestProcessInterruptionAtEveryMutationBoundaryIsRecoverable$") + command.Env = append(os.Environ(), "PROOFKIT_TRANSACTION_BOUNDARY_POINT="+string(test.point), "PROOFKIT_TRANSACTION_CRASH_ROOT="+rootPath) + err = command.Run() + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 73 { + t.Fatalf("crash helper error = %v", err) + } + + result, err := Recover(context.Background(), rootPath, plan.TransactionID, test.action) + if err != nil || result.State != test.finalState || result.RecoveredBy != test.action || result.TransactionID != plan.TransactionID { + t.Fatalf("Recover(%s) result=%#v error=%v", test.action, result, err) + } + replay, err := Recover(context.Background(), rootPath, plan.TransactionID, test.action) + if err != nil || replay.State != test.finalState || replay.RecoveredBy != test.action || replay.TransactionID != plan.TransactionID { + t.Fatalf("replayed Recover(%s) result=%#v error=%v", test.action, replay, err) + } + opposite := RecoveryResume + if test.action == RecoveryResume { + opposite = RecoveryRollback + } + mismatch, err := Recover(context.Background(), rootPath, plan.TransactionID, opposite) + if err != nil || mismatch.State != StateRecoveryRequired { + t.Fatalf("opposite Recover(%s) result=%#v error=%v", opposite, mismatch, err) + } + if test.finalState == StateApplied { + assertTestFile(t, rootPath, "proofkit/a.json", "after-a\n", 0o644) + assertTestFile(t, rootPath, "proofkit/b.json", "after-b\n", 0o644) + assertTestFile(t, rootPath, "generated/nested/c.json", "after-c\n", 0o644) + } else { + assertTestFile(t, rootPath, "proofkit/a.json", "before-a\n", 0o644) + assertTestFile(t, rootPath, "proofkit/b.json", "before-b\n", 0o644) + if _, err := os.Stat(filepath.Join(rootPath, "generated")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rollback retained generated directory: %v", err) + } + } + assertNoPendingTransaction(t, rootPath) + }) } - assertTestFile(t, rootPath, "proofkit/a.json", "after-a\n", 0o644) - assertTestFile(t, rootPath, "proofkit/b.json", "after-b\n", 0o644) - assertNoPendingTransaction(t, rootPath) } func TestRecoverClosesDirectoryCreationCrashGap(t *testing.T) { @@ -658,14 +697,17 @@ func TestRecoverClosesMarkerPublicationCrashGaps(t *testing.T) { } } -func runTransactionCrashHelper(t *testing.T) { +func runMutationBoundaryCrashHelper(t *testing.T, target failurePoint) { rootPath := os.Getenv("PROOFKIT_TRANSACTION_CRASH_ROOT") - plan, err := BuildPlan(context.Background(), rootPath, crashHelperTargets()) + plan, err := BuildPlan(context.Background(), rootPath, mutationBoundaryTargets()) if err != nil { t.Fatal(err) } runtime := engine{fault: func(point failurePoint, index int) error { - if point == faultAfterPublish && index == 1 { + if target == faultAfterRollback && point == faultBeforePublish && index == 2 { + return errors.New("enter rollback") + } + if point == target { os.Exit(73) } return nil @@ -676,8 +718,9 @@ func runTransactionCrashHelper(t *testing.T) { t.Fatal("crash helper did not terminate") } -func crashHelperTargets() []Target { +func mutationBoundaryTargets() []Target { return []Target{ + {Path: "generated/nested/c.json", Content: []byte("after-c\n"), Mode: 0o644}, {Path: "proofkit/a.json", Content: []byte("after-a\n"), Mode: 0o644}, {Path: "proofkit/b.json", Content: []byte("after-b\n"), Mode: 0o644}, } @@ -717,6 +760,93 @@ func TestApplyRejectsConcurrentCooperativeWriter(t *testing.T) { } } +func TestApplyAlreadySatisfiedRejectsConcurrentCooperativeWriter(t *testing.T) { + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "a\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/a.json", Content: []byte("a\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + lock, err := acquireTransactionLock(root) + if err != nil { + t.Fatal(err) + } + defer lock.release() + if _, err := Apply(context.Background(), rootPath, plan); !errors.Is(err, ErrBusy) { + t.Fatalf("Apply() error = %v, want ErrBusy", err) + } +} + +func TestTransactionLockIsInterprocess(t *testing.T) { + if os.Getenv("PROOFKIT_TRANSACTION_LOCK_HELPER") == "1" { + runTransactionLockHelper(t) + return + } + rootPath := t.TempDir() + mustWriteTestFile(t, rootPath, "proofkit/a.json", "a\n", 0o644) + plan, err := BuildPlan(context.Background(), rootPath, []Target{{Path: "proofkit/a.json", Content: []byte("a\n"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + command := exec.Command(os.Args[0], "-test.run=^TestTransactionLockIsInterprocess$") + command.Env = append(os.Environ(), "PROOFKIT_TRANSACTION_LOCK_HELPER=1", "PROOFKIT_TRANSACTION_LOCK_ROOT="+rootPath) + stdin, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := command.Start(); err != nil { + t.Fatal(err) + } + if line, err := bufio.NewReader(stdout).ReadString('\n'); err != nil || line != "locked\n" { + _ = command.Process.Kill() + t.Fatalf("lock helper readiness=%q error=%v", line, err) + } + if _, err := Apply(context.Background(), rootPath, plan); !errors.Is(err, ErrBusy) { + _ = command.Process.Kill() + t.Fatalf("Apply() error = %v, want interprocess ErrBusy", err) + } + if _, err := fmt.Fprintln(stdin, "release"); err != nil { + _ = command.Process.Kill() + t.Fatal(err) + } + if err := stdin.Close(); err != nil { + _ = command.Process.Kill() + t.Fatal(err) + } + if err := command.Wait(); err != nil { + t.Fatal(err) + } +} + +func runTransactionLockHelper(t *testing.T) { + root, _, err := openRepository(os.Getenv("PROOFKIT_TRANSACTION_LOCK_ROOT")) + if err != nil { + t.Fatal(err) + } + defer root.Close() + lock, err := acquireTransactionLock(root) + if err != nil { + t.Fatal(err) + } + defer lock.release() + if _, err := fmt.Fprintln(os.Stdout, "locked"); err != nil { + t.Fatal(err) + } + var release string + if _, err := fmt.Fscanln(os.Stdin, &release); err != nil || release != "release" { + t.Fatalf("lock helper release=%q error=%v", release, err) + } +} + func leaveInterruptedPrefix(t *testing.T, rootPath string, plan Plan, prefix int) { t.Helper() root, rootID, err := openRepository(rootPath) diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index f9553f6..4ffaeb0 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -294,6 +294,11 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestSelfCheckOutputUsesExactRootShape", "TestStandaloneMultiVariantCommandsUseExactRootShapes", }, + {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.adoption-materialization-output-root-witnesses"}: { + "TestAdoptMaterializeApplyOutputUsesExactRootShape", + "TestAdoptMaterializePlanOutputUsesExactRootShape", + "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + }, {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: { "TestExactTarballOnboardingTrace", "TestInstalledCommandRouteBijectionBindsCommandIdentity", @@ -327,6 +332,11 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestSelfCheckOutputUsesExactRootShape", "TestStandaloneMultiVariantCommandsUseExactRootShapes", }, + {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.adoption-materialization-cli-abi"}: { + "TestAdoptMaterializeApplyOutputUsesExactRootShape", + "TestAdoptMaterializePlanOutputUsesExactRootShape", + "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + }, {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.release-attestation-wiring"}: { "TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure", }, @@ -618,22 +628,36 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { }, {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-owner-closure"}: { "TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry", + "TestMaterializationOutputAdmissionRejectsCrossOwnerMutants", "TestMaterializationRejectsCrossRecordDriftAndManifestMutation", "TestMaterializationWholeChainIsCanonicalAndOwnerClosed", + "TestReceiptAdmissionRejectsOperationAttributionMutants", }, {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-reference-closure"}: { "TestInventoryReferencesMustResolveThroughBindingEdges", "TestManifestAdmissionEqualsProducerImage", "TestPathRoleLedgerRejectsWriteReferenceCollisions", + "TestRequirementProjectionRequiresClaimLevelParity", }, {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-whole-cli"}: { "TestAdoptionMaterializationCLI", }, {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-fault-recovery"}: { + "TestApplyAlreadySatisfiedRejectsConcurrentCooperativeWriter", "TestApplyFaultAfterFirstPublishRestoresExactBeforeState", "TestApplyRejectsConcurrentCooperativeWriter", - "TestProcessDeathAfterRenameIsRecoverable", + "TestProcessInterruptionAtEveryMutationBoundaryIsRecoverable", "TestRecoverDoesNotInventIdentityForPartialPreparingJournal", + "TestTransactionLockIsInterprocess", + }, + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-output-relations"}: { + "TestPlanAndResultOutputAdmissionRejectSemanticMutants", + }, + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-cleanup-state-matrix"}: { + "TestCleanupDurabilityFailureDoesNotClaimRecoverableState", + }, + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-filesystem-portable-path-identity"}: { + "TestBuildPlanRejectsFilesystemPortableAliases", }, {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-portable-path-identity"}: { "TestPortableEquivalenceAndContainment", @@ -641,8 +665,10 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-terminal-state"}: { "TestAppliedTerminalReceiptReplaysCompleteResult", "TestApplyExecutesFrozenPlan", + "TestCommittedRecoveryRejectsRollback", "TestMalformedRecoveryActionBlocksMutation", "TestPreparingFailureCannotClaimRollbackAfterTargetDivergence", + "TestPreparingRecoveryRejectsResumeBeforeActionSelection", "TestPreparingRollbackAtomicallyReplacesPreviousTerminalReceipt", "TestReadyReplacementRetiresPreviousReceiptAndPreservesCompleteResult", "TestRecoveryActionAndTerminalReceiptAreStable", @@ -651,6 +677,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { }, {"REQ-PROOFKIT-SPEC-034", "proofkit.spec-proof-core.adoption-materialization-version-edge"}: { "TestAdoptionMaterializationVersionEdgeClosesPublicCommands", + "TestAdoptionMaterializationVersionEdgePreservesFrozenPredecessor", "TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift", }, {"REQ-PROOFKIT-RETIRE-006", "proofkit.consumer-infra-retirement.migration-parity-admission"}: { @@ -658,105 +685,110 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { }, } requiredPaths := map[inventoryKey]string{ - {"REQ-PROOFKIT-WORKFLOW-001", "proofkit.agent-workflow.pure-single-admission-owner"}: "internal/command/changeworkflowplan/change_workflow_plan_test.go", - {"REQ-PROOFKIT-WORKFLOW-002", "proofkit.agent-workflow.stage-prefix-and-terminal-relation"}: "internal/command/changeworkflowplan/state_test.go", - {"REQ-PROOFKIT-WORKFLOW-003", "proofkit.agent-workflow.total-checkpoint-successor-relation"}: "internal/command/changeworkflowplan/state_test.go", - {"REQ-PROOFKIT-WORKFLOW-004", "proofkit.agent-workflow.review-identity-closure"}: "internal/command/changeworkflowplan/admission_test.go", - {"REQ-PROOFKIT-WORKFLOW-005", "proofkit.agent-workflow.reference-closed-bounded-context"}: "internal/command/changeworkflowplan/context_closure_test.go", - {"REQ-PROOFKIT-WORKFLOW-006", "proofkit.agent-workflow.no-ambient-authority"}: "internal/command/changeworkflowplan/dependency_test.go", - {"REQ-PROOFKIT-WORKFLOW-007", "proofkit.agent-workflow.native-evidence-guidance-purity"}: "internal/command/nativeevidenceguidance/dependency_test.go", - {"REQ-PROOFKIT-WORKFLOW-007", "proofkit.agent-workflow.native-evidence-guidance-slot-closure"}: "internal/command/nativeevidenceguidance/guidance_test.go", - {"REQ-PROOFKIT-WORKFLOW-008", "proofkit.agent-workflow.bounded-safe-text"}: "internal/command/changeworkflowplan/text_test.go", - {"REQ-PROOFKIT-WORKFLOW-008", "proofkit.agent-workflow.prompt-coordinate-and-escalation-closure"}: "internal/command/changeworkflowplan/prompt_test.go", - {"REQ-PROOFKIT-WORKFLOW-009", "proofkit.agent-workflow.cli-presentation-capability-product"}: "internal/app/agent_workflow_command_test.go", - {"REQ-PROOFKIT-WORKFLOW-009", "proofkit.agent-workflow.style-strip-parity"}: "internal/command/changeworkflowplan/text_test.go", - {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.catalog-prerequisite-causality"}: "internal/command/changeworkflowplan/state_test.go", - {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.semantic-owner-minimality"}: "internal/command/nativeevidenceguidance/guidance_test.go", - {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.semantic-owner-topology"}: "internal/app/agent_workflow_topology_test.go", - {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.installed-carrier-smoke-closure"}: "internal/tools/workflowsmoke/workflow_smoke_test.go", - {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.public-cli-relation-closure"}: "internal/app/agent_workflow_command_test.go", - {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.version-edge-wire-observation"}: "internal/app/agent_workflow_version_edge_test.go", - {"REQ-PROOFKIT-PACKAGE-001", "proofkit.package-boundary.root-export-and-deep-import-denial"}: "internal/tools/packageverify/main_test.go", - {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.launcher-profile-admission"}: "internal/kernel/cliexec/cliexec_test.go", - {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.generated-command-field-inventory"}: "internal/app/invocation_profile_test.go", - {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.generated-command-caller-preservation"}: "internal/command/gradualadoption/gradualadoption_test.go", - {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.cli-output-root-witnesses"}: "internal/app/cli_abi_test.go", - {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: "internal/tools/packageverify/main_test.go", - {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.ci-receipt-anchor"}: "scripts/validate-self-hosting-receipts_test.go", - {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.self-hosting-report-verdict"}: "scripts/validate-self-hosting-receipts_test.go", - {"REQ-PROOFKIT-PACKAGE-005", "proofkit.package-boundary.merge-critical-runtime-preconditions"}: "scripts/workflow_runtime_preconditions_test.go", - {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-candidate"}: "scripts/validate-self-hosting-receipts_test.go", - {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-generated-continuation"}: "internal/tools/pythonpackage/continuation_test.go", - {"REQ-PROOFKIT-PACKAGE-007", "proofkit.package-boundary.package-public-docs-no-mutable-release-facts"}: "internal/tools/packageverify/main_test.go", - {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.release-attestation-wiring"}: "scripts/validate-self-hosting-receipts_test.go", - {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.retained-evidence-manifest"}: "internal/tools/retainedevidence/manifest_test.go", - {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-abi-golden"}: "internal/app/cli_abi_test.go", - {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-contract-topology"}: "internal/app/cli_contract_test.go", - {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-output-witness-contract"}: "internal/app/cli_output_witness_contract_test.go", - {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-output-schema-evolution"}: "internal/app/cli_contract_test.go", - {"REQ-PROOFKIT-QUALITY-005", "proofkit.supply-chain-quality.codeql-permission-separation"}: "scripts/workflow_security_scanner_oracles_test.go", - {"REQ-PROOFKIT-QUALITY-006", "proofkit.supply-chain-quality.osv-permission-separation"}: "scripts/workflow_security_scanner_oracles_test.go", - {"REQ-PROOFKIT-QUALITY-007", "proofkit.supply-chain-quality.scorecard-permission-and-publication-inputs"}: "scripts/workflow_security_scanner_oracles_test.go", - {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.artifact-file-boundary"}: "internal/tools/artifactfile/file_test.go", - {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.artifact-file-nonblocking-open"}: "internal/tools/artifactfile/file_unix_test.go", - {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.binding-selector-executability"}: "internal/tools/coveragemetrics/main_test.go", - {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.coverage-metrics"}: "internal/tools/coveragemetrics/main_test.go", - {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.command-oracle-execution-ledger"}: "internal/tools/commandoracle/execute_test.go", - {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.command-oracle-counterfeit-corpus"}: "internal/tools/commandoracle/corpus_test.go", - {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.command-oracle-source-snapshot"}: "internal/tools/repositorysnapshot/snapshot_test.go", - {"REQ-PROOFKIT-QUALITY-011", "proofkit.supply-chain-quality.ci-required-aggregate-exactness"}: "scripts/workflow_package_gate_oracle_test.go", - {"REQ-PROOFKIT-QUALITY-013", "proofkit.supply-chain-quality.workflow-package-gate-oracle"}: "scripts/workflow_package_gate_oracle_test.go", - {"REQ-PROOFKIT-QUALITY-016", "proofkit.supply-chain-quality.release-platform-python-wheels"}: "internal/tools/pythonpackage/metadata_test.go", - {"REQ-PROOFKIT-QUALITY-019", "proofkit.supply-chain-quality.installed-package-json-abi-smoke"}: "internal/tools/packageverify/main_test.go", - {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.release-closeout-npm-byte-admission"}: "internal/tools/releasecloseoutinput/main_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-manifest-json-abi-registry-evidence"}: "internal/tools/releasemanifest/main_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-authority-producer"}: "internal/tools/npmregistry/main_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-workflow-delegation"}: "scripts/validate-self-hosting-receipts_test.go", - {"REQ-PROOFKIT-QUALITY-022", "proofkit.supply-chain-quality.browser-failure-diagnostics-retention"}: "scripts/workflow_browser_runtime_oracle_test.go", - {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.python-wheel-platform-byte-compatibility"}: "internal/tools/pythonpackage/metadata_test.go", - {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.python-wheel-resource-bounds"}: "internal/tools/pythonpackage/metadata_test.go", - {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.wrapper-platform-bijection"}: "internal/tools/packagebuild/main_test.go", - {"REQ-PROOFKIT-QUALITY-015", "proofkit.supply-chain-quality.release-closeout-completion-criteria"}: "internal/tools/releasecloseoutinput/main_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-change-record-projection"}: "internal/tools/releasechange/record_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.retained-evidence-artifact-topology"}: "internal/tools/retainedevidence/manifest_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-closeout-change-record"}: "internal/tools/releasecloseoutinput/main_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage"}: "internal/tools/releasepreflight/main_test.go", - {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage-workflow"}: "scripts/validate-self-hosting-receipts_test.go", - {"REQ-PROOFKIT-QUALITY-025", "proofkit.supply-chain-quality.workflow-source-oracles"}: "scripts/workflow_source_oracles_test.go", - {"REQ-PROOFKIT-RETIRE-006", "proofkit.consumer-infra-retirement.migration-parity-admission"}: "internal/command/migrationparityadmission/migrationparityadmission_test.go", - {"REQ-PROOFKIT-SPEC-011", "proofkit.spec-proof-core.adoption-contract-envelope-cli-abi"}: "internal/app/cli_abi_test.go", - {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-command-input-admission"}: "internal/app/command_coverage_test.go", - {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-input-admission"}: "internal/kernel/admission/json_test.go", - {"REQ-PROOFKIT-SPEC-013", "proofkit.spec-proof-core.receipt-trust-status-vocabulary-admission"}: "internal/command/receipttrustclass/receipt_trust_class_test.go", - {"REQ-PROOFKIT-SPEC-021", "proofkit.spec-proof-core.requirement-browser-one-shot-cleanup"}: "internal/command/requirementbrowser/server_test.go", - {"REQ-PROOFKIT-SPEC-006", "proofkit.spec-proof-core.test-inventory-and-coverage-view"}: "internal/command/requirementcoverageview/output_closure_test.go", - {"REQ-PROOFKIT-SPEC-006", "proofkit.spec-proof-core.declared-route-mapping-without-assurance"}: "internal/command/requirementcoverageview/requirementcoverageview_test.go", - {"REQ-PROOFKIT-SPEC-012", "proofkit.spec-proof-core.requirement-authoring-ref-provenance"}: "internal/command/requirementauthoringplan/requirement_authoring_plan_test.go", - {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-brief-cli-abi"}: "internal/app/cli_abi_test.go", - {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-brief-projection"}: "internal/command/agentroute/brief_test.go", - {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-brief-version-edge"}: "internal/app/agent_route_version_edge_test.go", - {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-flag-pre-read-admission"}: "internal/app/app_test.go", - {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-materialized-ref-admission"}: "internal/command/agentroute/agentroute_test.go", - {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-report-contract-closure"}: "internal/app/cli_contract_test.go", - {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-boundary"}: "internal/command/repositoryinventory/repositoryinventory_test.go", - {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: "internal/command/repositoryinventory/fifo_unix_test.go", - {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", - {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: "internal/command/adoptionplan/repository_classes_test.go", - {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: "internal/command/nativeevidenceguidance/guidance_test.go", - {"REQ-PROOFKIT-SPEC-030", "proofkit.spec-proof-core.adoption-plan-presentation-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-front-door-whole-cli"}: "internal/app/adoption_front_door_command_test.go", - {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-contract-closure"}: "internal/tools/commandcontractgen/main_test.go", - {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-generated-adapter"}: "internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go", - {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-installed-contract"}: "internal/tools/installedclicontract/contract_test.go", - {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-kernel-owner"}: "internal/kernel/commandroute/route_test.go", - {"REQ-PROOFKIT-SPEC-031", "proofkit.spec-proof-core.adoption-version-edge-closure"}: "internal/app/adoption_front_door_version_edge_test.go", - {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-owner-closure"}: "internal/command/adoptionmaterialization/adoptionmaterialization_test.go", - {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-reference-closure"}: "internal/command/adoptionmaterialization/closure_test.go", - {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-whole-cli"}: "internal/app/adoption_materialization_command_test.go", - {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-fault-recovery"}: "internal/kernel/repositorytransaction/transaction_test.go", - {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-portable-path-identity"}: "internal/kernel/pathidentity/pathidentity_test.go", - {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-terminal-state"}: "internal/kernel/repositorytransaction/invariant_test.go", - {"REQ-PROOFKIT-SPEC-034", "proofkit.spec-proof-core.adoption-materialization-version-edge"}: "internal/app/adoption_materialization_version_edge_test.go", + {"REQ-PROOFKIT-WORKFLOW-001", "proofkit.agent-workflow.pure-single-admission-owner"}: "internal/command/changeworkflowplan/change_workflow_plan_test.go", + {"REQ-PROOFKIT-WORKFLOW-002", "proofkit.agent-workflow.stage-prefix-and-terminal-relation"}: "internal/command/changeworkflowplan/state_test.go", + {"REQ-PROOFKIT-WORKFLOW-003", "proofkit.agent-workflow.total-checkpoint-successor-relation"}: "internal/command/changeworkflowplan/state_test.go", + {"REQ-PROOFKIT-WORKFLOW-004", "proofkit.agent-workflow.review-identity-closure"}: "internal/command/changeworkflowplan/admission_test.go", + {"REQ-PROOFKIT-WORKFLOW-005", "proofkit.agent-workflow.reference-closed-bounded-context"}: "internal/command/changeworkflowplan/context_closure_test.go", + {"REQ-PROOFKIT-WORKFLOW-006", "proofkit.agent-workflow.no-ambient-authority"}: "internal/command/changeworkflowplan/dependency_test.go", + {"REQ-PROOFKIT-WORKFLOW-007", "proofkit.agent-workflow.native-evidence-guidance-purity"}: "internal/command/nativeevidenceguidance/dependency_test.go", + {"REQ-PROOFKIT-WORKFLOW-007", "proofkit.agent-workflow.native-evidence-guidance-slot-closure"}: "internal/command/nativeevidenceguidance/guidance_test.go", + {"REQ-PROOFKIT-WORKFLOW-008", "proofkit.agent-workflow.bounded-safe-text"}: "internal/command/changeworkflowplan/text_test.go", + {"REQ-PROOFKIT-WORKFLOW-008", "proofkit.agent-workflow.prompt-coordinate-and-escalation-closure"}: "internal/command/changeworkflowplan/prompt_test.go", + {"REQ-PROOFKIT-WORKFLOW-009", "proofkit.agent-workflow.cli-presentation-capability-product"}: "internal/app/agent_workflow_command_test.go", + {"REQ-PROOFKIT-WORKFLOW-009", "proofkit.agent-workflow.style-strip-parity"}: "internal/command/changeworkflowplan/text_test.go", + {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.catalog-prerequisite-causality"}: "internal/command/changeworkflowplan/state_test.go", + {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.semantic-owner-minimality"}: "internal/command/nativeevidenceguidance/guidance_test.go", + {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.semantic-owner-topology"}: "internal/app/agent_workflow_topology_test.go", + {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.installed-carrier-smoke-closure"}: "internal/tools/workflowsmoke/workflow_smoke_test.go", + {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.public-cli-relation-closure"}: "internal/app/agent_workflow_command_test.go", + {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.version-edge-wire-observation"}: "internal/app/agent_workflow_version_edge_test.go", + {"REQ-PROOFKIT-PACKAGE-001", "proofkit.package-boundary.root-export-and-deep-import-denial"}: "internal/tools/packageverify/main_test.go", + {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.launcher-profile-admission"}: "internal/kernel/cliexec/cliexec_test.go", + {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.generated-command-field-inventory"}: "internal/app/invocation_profile_test.go", + {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.generated-command-caller-preservation"}: "internal/command/gradualadoption/gradualadoption_test.go", + {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.cli-output-root-witnesses"}: "internal/app/cli_abi_test.go", + {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.adoption-materialization-output-root-witnesses"}: "internal/app/adoption_materialization_command_test.go", + {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: "internal/tools/packageverify/main_test.go", + {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.ci-receipt-anchor"}: "scripts/validate-self-hosting-receipts_test.go", + {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.self-hosting-report-verdict"}: "scripts/validate-self-hosting-receipts_test.go", + {"REQ-PROOFKIT-PACKAGE-005", "proofkit.package-boundary.merge-critical-runtime-preconditions"}: "scripts/workflow_runtime_preconditions_test.go", + {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-candidate"}: "scripts/validate-self-hosting-receipts_test.go", + {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-generated-continuation"}: "internal/tools/pythonpackage/continuation_test.go", + {"REQ-PROOFKIT-PACKAGE-007", "proofkit.package-boundary.package-public-docs-no-mutable-release-facts"}: "internal/tools/packageverify/main_test.go", + {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.release-attestation-wiring"}: "scripts/validate-self-hosting-receipts_test.go", + {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.retained-evidence-manifest"}: "internal/tools/retainedevidence/manifest_test.go", + {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-abi-golden"}: "internal/app/cli_abi_test.go", + {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.adoption-materialization-cli-abi"}: "internal/app/adoption_materialization_command_test.go", + {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-contract-topology"}: "internal/app/cli_contract_test.go", + {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-output-witness-contract"}: "internal/app/cli_output_witness_contract_test.go", + {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-output-schema-evolution"}: "internal/app/cli_contract_test.go", + {"REQ-PROOFKIT-QUALITY-005", "proofkit.supply-chain-quality.codeql-permission-separation"}: "scripts/workflow_security_scanner_oracles_test.go", + {"REQ-PROOFKIT-QUALITY-006", "proofkit.supply-chain-quality.osv-permission-separation"}: "scripts/workflow_security_scanner_oracles_test.go", + {"REQ-PROOFKIT-QUALITY-007", "proofkit.supply-chain-quality.scorecard-permission-and-publication-inputs"}: "scripts/workflow_security_scanner_oracles_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.artifact-file-boundary"}: "internal/tools/artifactfile/file_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.artifact-file-nonblocking-open"}: "internal/tools/artifactfile/file_unix_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.binding-selector-executability"}: "internal/tools/coveragemetrics/main_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.coverage-metrics"}: "internal/tools/coveragemetrics/main_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.command-oracle-execution-ledger"}: "internal/tools/commandoracle/execute_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.command-oracle-counterfeit-corpus"}: "internal/tools/commandoracle/corpus_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.command-oracle-source-snapshot"}: "internal/tools/repositorysnapshot/snapshot_test.go", + {"REQ-PROOFKIT-QUALITY-011", "proofkit.supply-chain-quality.ci-required-aggregate-exactness"}: "scripts/workflow_package_gate_oracle_test.go", + {"REQ-PROOFKIT-QUALITY-013", "proofkit.supply-chain-quality.workflow-package-gate-oracle"}: "scripts/workflow_package_gate_oracle_test.go", + {"REQ-PROOFKIT-QUALITY-016", "proofkit.supply-chain-quality.release-platform-python-wheels"}: "internal/tools/pythonpackage/metadata_test.go", + {"REQ-PROOFKIT-QUALITY-019", "proofkit.supply-chain-quality.installed-package-json-abi-smoke"}: "internal/tools/packageverify/main_test.go", + {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.release-closeout-npm-byte-admission"}: "internal/tools/releasecloseoutinput/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-manifest-json-abi-registry-evidence"}: "internal/tools/releasemanifest/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-authority-producer"}: "internal/tools/npmregistry/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-workflow-delegation"}: "scripts/validate-self-hosting-receipts_test.go", + {"REQ-PROOFKIT-QUALITY-022", "proofkit.supply-chain-quality.browser-failure-diagnostics-retention"}: "scripts/workflow_browser_runtime_oracle_test.go", + {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.python-wheel-platform-byte-compatibility"}: "internal/tools/pythonpackage/metadata_test.go", + {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.python-wheel-resource-bounds"}: "internal/tools/pythonpackage/metadata_test.go", + {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.wrapper-platform-bijection"}: "internal/tools/packagebuild/main_test.go", + {"REQ-PROOFKIT-QUALITY-015", "proofkit.supply-chain-quality.release-closeout-completion-criteria"}: "internal/tools/releasecloseoutinput/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-change-record-projection"}: "internal/tools/releasechange/record_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.retained-evidence-artifact-topology"}: "internal/tools/retainedevidence/manifest_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-closeout-change-record"}: "internal/tools/releasecloseoutinput/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage"}: "internal/tools/releasepreflight/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage-workflow"}: "scripts/validate-self-hosting-receipts_test.go", + {"REQ-PROOFKIT-QUALITY-025", "proofkit.supply-chain-quality.workflow-source-oracles"}: "scripts/workflow_source_oracles_test.go", + {"REQ-PROOFKIT-RETIRE-006", "proofkit.consumer-infra-retirement.migration-parity-admission"}: "internal/command/migrationparityadmission/migrationparityadmission_test.go", + {"REQ-PROOFKIT-SPEC-011", "proofkit.spec-proof-core.adoption-contract-envelope-cli-abi"}: "internal/app/cli_abi_test.go", + {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-command-input-admission"}: "internal/app/command_coverage_test.go", + {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-input-admission"}: "internal/kernel/admission/json_test.go", + {"REQ-PROOFKIT-SPEC-013", "proofkit.spec-proof-core.receipt-trust-status-vocabulary-admission"}: "internal/command/receipttrustclass/receipt_trust_class_test.go", + {"REQ-PROOFKIT-SPEC-021", "proofkit.spec-proof-core.requirement-browser-one-shot-cleanup"}: "internal/command/requirementbrowser/server_test.go", + {"REQ-PROOFKIT-SPEC-006", "proofkit.spec-proof-core.test-inventory-and-coverage-view"}: "internal/command/requirementcoverageview/output_closure_test.go", + {"REQ-PROOFKIT-SPEC-006", "proofkit.spec-proof-core.declared-route-mapping-without-assurance"}: "internal/command/requirementcoverageview/requirementcoverageview_test.go", + {"REQ-PROOFKIT-SPEC-012", "proofkit.spec-proof-core.requirement-authoring-ref-provenance"}: "internal/command/requirementauthoringplan/requirement_authoring_plan_test.go", + {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-brief-cli-abi"}: "internal/app/cli_abi_test.go", + {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-brief-projection"}: "internal/command/agentroute/brief_test.go", + {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-brief-version-edge"}: "internal/app/agent_route_version_edge_test.go", + {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-flag-pre-read-admission"}: "internal/app/app_test.go", + {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-materialized-ref-admission"}: "internal/command/agentroute/agentroute_test.go", + {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-report-contract-closure"}: "internal/app/cli_contract_test.go", + {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-boundary"}: "internal/command/repositoryinventory/repositoryinventory_test.go", + {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: "internal/command/repositoryinventory/fifo_unix_test.go", + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: "internal/command/adoptionplan/repository_classes_test.go", + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: "internal/command/nativeevidenceguidance/guidance_test.go", + {"REQ-PROOFKIT-SPEC-030", "proofkit.spec-proof-core.adoption-plan-presentation-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-front-door-whole-cli"}: "internal/app/adoption_front_door_command_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-contract-closure"}: "internal/tools/commandcontractgen/main_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-generated-adapter"}: "internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-installed-contract"}: "internal/tools/installedclicontract/contract_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-kernel-owner"}: "internal/kernel/commandroute/route_test.go", + {"REQ-PROOFKIT-SPEC-031", "proofkit.spec-proof-core.adoption-version-edge-closure"}: "internal/app/adoption_front_door_version_edge_test.go", + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-owner-closure"}: "internal/command/adoptionmaterialization/adoptionmaterialization_test.go", + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-reference-closure"}: "internal/command/adoptionmaterialization/closure_test.go", + {"REQ-PROOFKIT-SPEC-032", "proofkit.spec-proof-core.adoption-materialization-whole-cli"}: "internal/app/adoption_materialization_command_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-fault-recovery"}: "internal/kernel/repositorytransaction/transaction_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-output-relations"}: "internal/kernel/repositorytransaction/output_admission_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-cleanup-state-matrix"}: "internal/kernel/repositorytransaction/state_machine_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-filesystem-portable-path-identity"}: "internal/kernel/repositorytransaction/plan_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-portable-path-identity"}: "internal/kernel/pathidentity/pathidentity_test.go", + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-terminal-state"}: "internal/kernel/repositorytransaction/invariant_test.go", + {"REQ-PROOFKIT-SPEC-034", "proofkit.spec-proof-core.adoption-materialization-version-edge"}: "internal/app/adoption_materialization_version_edge_test.go", } if len(requiredPaths) != len(required) { return fmt.Errorf("required selector path inventory=%d, selector inventory=%d", len(requiredPaths), len(required)) diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index d6a4892..45ff0c5 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -111,7 +111,7 @@ "rootDefinitionDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", + "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -139,15 +139,27 @@ "closed": true, "rootDefinitionRef": "proofkit.adoption-materialization.apply-output.v1.root-shape", "rootDefinitionDigest": "sha256:4b58bd4e89da98ad79c5e6faf32fe58766313117ad2558b602bfed853de0cf7e", - "nativeSource": { - "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", - "evidenceClass": "source_checkout" - }, + "nativeSources": [ + { + "path": "internal/app", + "canonicalDigest": "sha256:0195a152392ad4cecd99a2d0c147ee8e25a034a32e4f730033766399a030c5c3", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/kernel/repositorytransaction", + "canonicalDigest": "sha256:534ea025b0bf51ec99a1b44d755abb433ab687b572c4f5a0737440176d334db8", + "evidenceClass": "source_checkout" + } + ], "nativeOutputWitnessSelector": { "path": "internal/app/adoption_materialization_command_test.go", - "test": "TestAdoptionMaterializationCLI", - "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "test": "TestAdoptMaterializeApplyOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializeApplyOutputUsesExactRootShape$'", "evidenceClass": "source_checkout" }, "compatibilitySummary": [ @@ -237,7 +249,7 @@ "rootDefinitionDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", + "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -265,15 +277,27 @@ "closed": true, "rootDefinitionRef": "proofkit.adoption-materialization.plan-output.v1.root-shape", "rootDefinitionDigest": "sha256:fc0e2d547a5fd54ebebe9d237a48ae15418b32d3d28fa5185aae64a0fba9b255", - "nativeSource": { - "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", - "evidenceClass": "source_checkout" - }, + "nativeSources": [ + { + "path": "internal/app", + "canonicalDigest": "sha256:0195a152392ad4cecd99a2d0c147ee8e25a034a32e4f730033766399a030c5c3", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/kernel/repositorytransaction", + "canonicalDigest": "sha256:534ea025b0bf51ec99a1b44d755abb433ab687b572c4f5a0737440176d334db8", + "evidenceClass": "source_checkout" + } + ], "nativeOutputWitnessSelector": { "path": "internal/app/adoption_materialization_command_test.go", - "test": "TestAdoptionMaterializationCLI", - "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "test": "TestAdoptMaterializePlanOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializePlanOutputUsesExactRootShape$'", "evidenceClass": "source_checkout" }, "compatibilitySummary": [ @@ -367,15 +391,27 @@ "closed": true, "rootDefinitionRef": "proofkit.adoption-materialization.recover-output.v1.root-shape", "rootDefinitionDigest": "sha256:06129ef857ffb11351535c769f9ca207522a08f3c5bf2da52c6f5fff0b1ac757", - "nativeSource": { - "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d347606bfd64457b6eeb50b35b64b432ec096d1f22710f4d3a70eea4bce9a3e9", - "evidenceClass": "source_checkout" - }, + "nativeSources": [ + { + "path": "internal/app", + "canonicalDigest": "sha256:0195a152392ad4cecd99a2d0c147ee8e25a034a32e4f730033766399a030c5c3", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/command/adoptionmaterialization", + "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/kernel/repositorytransaction", + "canonicalDigest": "sha256:534ea025b0bf51ec99a1b44d755abb433ab687b572c4f5a0737440176d334db8", + "evidenceClass": "source_checkout" + } + ], "nativeOutputWitnessSelector": { "path": "internal/app/adoption_materialization_command_test.go", - "test": "TestAdoptionMaterializationCLI", - "command": "go test ./internal/app -run '^TestAdoptionMaterializationCLI$'", + "test": "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializeRecoverOutputUsesExactRootShape$'", "evidenceClass": "source_checkout" }, "compatibilitySummary": [ @@ -1149,7 +1185,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", + "canonicalDigest": "sha256:0195a152392ad4cecd99a2d0c147ee8e25a034a32e4f730033766399a030c5c3", "evidenceClass": "source_checkout" }, { @@ -2927,7 +2963,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", + "canonicalDigest": "sha256:0195a152392ad4cecd99a2d0c147ee8e25a034a32e4f730033766399a030c5c3", "evidenceClass": "source_checkout" }, { @@ -6217,7 +6253,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", + "canonicalDigest": "sha256:0195a152392ad4cecd99a2d0c147ee8e25a034a32e4f730033766399a030c5c3", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -6246,7 +6282,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:e14fcf184779e35040645a0b8b9036beda6c9855b45b4ea733c343d4b852166d", + "canonicalDigest": "sha256:0195a152392ad4cecd99a2d0c147ee8e25a034a32e4f730033766399a030c5c3", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 14d59b9..449eff8 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -1169,6 +1169,33 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-PACKAGE-002", + "scenarioId": "proofkit.package-boundary.adoption-materialization-output-root-witnesses", + "witnessId": "proofkit.adoption-materialization.cli-output-root.exact-witnesses", + "witnessKind": "contract", + "witnessPath": "internal/app/adoption_materialization_command_test.go", + "witnessSelectors": [ + { + "selector": "TestAdoptMaterializeApplyOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializeApplyOutputUsesExactRootShape$'" + }, + { + "selector": "TestAdoptMaterializePlanOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializePlanOutputUsesExactRootShape$'" + }, + { + "selector": "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializeRecoverOutputUsesExactRootShape$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-PACKAGE-002", "scenarioId": "proofkit.package-boundary.cli-output-root-witnesses", @@ -2431,6 +2458,33 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-004", + "scenarioId": "proofkit.supply-chain-quality.adoption-materialization-cli-abi", + "witnessId": "proofkit.adoption-materialization.cli-abi.exact-output", + "witnessKind": "contract", + "witnessPath": "internal/app/adoption_materialization_command_test.go", + "witnessSelectors": [ + { + "selector": "TestAdoptMaterializeApplyOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializeApplyOutputUsesExactRootShape$'" + }, + { + "selector": "TestAdoptMaterializePlanOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializePlanOutputUsesExactRootShape$'" + }, + { + "selector": "TestAdoptMaterializeRecoverOutputUsesExactRootShape", + "command": "go test ./internal/app -run '^TestAdoptMaterializeRecoverOutputUsesExactRootShape$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-004", "scenarioId": "proofkit.supply-chain-quality.cli-abi-golden", @@ -5845,6 +5899,14 @@ "selector": "TestMaterializationWholeChainIsCanonicalAndOwnerClosed", "command": "go test ./internal/command/adoptionmaterialization -run '^TestMaterializationWholeChainIsCanonicalAndOwnerClosed$'" }, + { + "selector": "TestMaterializationOutputAdmissionRejectsCrossOwnerMutants", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestMaterializationOutputAdmissionRejectsCrossOwnerMutants$'" + }, + { + "selector": "TestReceiptAdmissionRejectsOperationAttributionMutants", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestReceiptAdmissionRejectsOperationAttributionMutants$'" + }, { "selector": "TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry", "command": "go test ./internal/command/adoptionmaterialization -run '^TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry$'" @@ -5868,6 +5930,10 @@ "selector": "TestPathRoleLedgerRejectsWriteReferenceCollisions", "command": "go test ./internal/command/adoptionmaterialization -run '^TestPathRoleLedgerRejectsWriteReferenceCollisions$'" }, + { + "selector": "TestRequirementProjectionRequiresClaimLevelParity", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestRequirementProjectionRequiresClaimLevelParity$'" + }, { "selector": "TestInventoryReferencesMustResolveThroughBindingEdges", "command": "go test ./internal/command/adoptionmaterialization -run '^TestInventoryReferencesMustResolveThroughBindingEdges$'" @@ -5937,6 +6003,14 @@ { "selector": "TestPreparingFailureCannotClaimRollbackAfterTargetDivergence", "command": "go test ./internal/kernel/repositorytransaction -run '^TestPreparingFailureCannotClaimRollbackAfterTargetDivergence$'" + }, + { + "selector": "TestCommittedRecoveryRejectsRollback", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestCommittedRecoveryRejectsRollback$'" + }, + { + "selector": "TestPreparingRecoveryRejectsResumeBeforeActionSelection", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPreparingRecoveryRejectsResumeBeforeActionSelection$'" } ], "commandIds": ["proofkit.go-test"], @@ -5954,13 +6028,21 @@ "command": "go test ./internal/kernel/repositorytransaction -run '^TestApplyFaultAfterFirstPublishRestoresExactBeforeState$'" }, { - "selector": "TestProcessDeathAfterRenameIsRecoverable", - "command": "go test ./internal/kernel/repositorytransaction -run '^TestProcessDeathAfterRenameIsRecoverable$'" + "selector": "TestProcessInterruptionAtEveryMutationBoundaryIsRecoverable", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestProcessInterruptionAtEveryMutationBoundaryIsRecoverable$'" }, { "selector": "TestApplyRejectsConcurrentCooperativeWriter", "command": "go test ./internal/kernel/repositorytransaction -run '^TestApplyRejectsConcurrentCooperativeWriter$'" }, + { + "selector": "TestApplyAlreadySatisfiedRejectsConcurrentCooperativeWriter", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestApplyAlreadySatisfiedRejectsConcurrentCooperativeWriter$'" + }, + { + "selector": "TestTransactionLockIsInterprocess", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestTransactionLockIsInterprocess$'" + }, { "selector": "TestRecoverDoesNotInventIdentityForPartialPreparingJournal", "command": "go test ./internal/kernel/repositorytransaction -run '^TestRecoverDoesNotInventIdentityForPartialPreparingJournal$'" @@ -5969,6 +6051,63 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.spec-proof-core.repository-transaction-output-relations", + "witnessId": "proofkit.repository-transaction.output-relation-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/output_admission_test.go", + "witnessSelectors": [ + { + "selector": "TestPlanAndResultOutputAdmissionRejectSemanticMutants", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPlanAndResultOutputAdmissionRejectSemanticMutants$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.spec-proof-core.repository-transaction-cleanup-state-matrix", + "witnessId": "proofkit.repository-transaction.cleanup-state-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/state_machine_test.go", + "witnessSelectors": [ + { + "selector": "TestCleanupDurabilityFailureDoesNotClaimRecoverableState", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestCleanupDurabilityFailureDoesNotClaimRecoverableState$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.spec-proof-core.repository-transaction-filesystem-portable-path-identity", + "witnessId": "proofkit.repository-transaction.filesystem-portable-path-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/plan_test.go", + "witnessSelectors": [ + { + "selector": "TestBuildPlanRejectsFilesystemPortableAliases", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestBuildPlanRejectsFilesystemPortableAliases$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-SPEC-033", "scenarioId": "proofkit.spec-proof-core.repository-transaction-portable-path-identity", @@ -5998,6 +6137,10 @@ { "selector": "TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift", "command": "go test ./internal/app -run '^TestAdoptionMaterializationVersionEdgeRejectsCoordinatedChangeRecordDrift$'" + }, + { + "selector": "TestAdoptionMaterializationVersionEdgePreservesFrozenPredecessor", + "command": "go test ./internal/app -run '^TestAdoptionMaterializationVersionEdgePreservesFrozenPredecessor$'" } ], "commandIds": ["proofkit.go-test"], From c8f70d157c5e90afce138944b7f1509adf4a9f59 Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 21:23:19 +0200 Subject: [PATCH 5/5] fix: bind materialization output identities --- .../adoption_front_door_version_edge_test.go | 38 +++++++++--- internal/app/cli_contract_test.go | 2 +- internal/app/command_contract_generated.go | 8 +-- .../v0.7.0/release/change-record.v2.json} | 0 .../app/testdata/v0.8-wire-observations.json | 12 ++-- .../adoptionmaterialization_test.go | 60 +++++++++++++++++-- .../adoptionmaterialization/closure_test.go | 18 +++--- .../adoptionmaterialization/manifest.go | 6 +- .../output_admission.go | 4 ++ .../stackpreset/preset_ids_generated.go | 2 +- .../repositorytransaction/output_admission.go | 3 + .../output_admission_test.go | 2 + proofkit/cli-contract.v2.json | 16 ++--- 13 files changed, 128 insertions(+), 43 deletions(-) rename internal/app/testdata/{v0.7-release-change-record.v2.json => releases/v0.7.0/release/change-record.v2.json} (100%) diff --git a/internal/app/adoption_front_door_version_edge_test.go b/internal/app/adoption_front_door_version_edge_test.go index 824b276..c48decb 100644 --- a/internal/app/adoption_front_door_version_edge_test.go +++ b/internal/app/adoption_front_door_version_edge_test.go @@ -16,7 +16,7 @@ import ( ) const adoptionFrontDoorVersionEdgePath = "internal/app/testdata/v0.7-wire-observations.json" -const archivedAdoptionFrontDoorChangeRecordPath = "internal/app/testdata/v0.7-release-change-record.v2.json" +const archivedAdoptionFrontDoorReleaseRoot = "internal/app/testdata/releases/v0.7.0" type adoptionFrontDoorVersionEdge struct { AddedCommandContracts []adoptionFrontDoorCommandContract `json:"addedCommandContracts"` @@ -70,7 +70,7 @@ type adoptionChangedCommandContract struct { func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { record := readAdoptionFrontDoorVersionEdge(t) - if err := validateAdoptionFrontDoorVersionEdge(record, repoRoot(t)); err != nil { + if err := validateAdoptionFrontDoorVersionEdge(record, archivedAdoptionFrontDoorRoot(t)); err != nil { t.Fatal(err) } @@ -107,7 +107,7 @@ func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { t.Run(fmt.Sprintf("mutant-%d", index), func(t *testing.T) { value := cloneAdoptionFrontDoorVersionEdge(record) mutate(&value) - if err := validateAdoptionFrontDoorVersionEdge(value, repoRoot(t)); err == nil { + if err := validateAdoptionFrontDoorVersionEdge(value, archivedAdoptionFrontDoorRoot(t)); err == nil { t.Fatal("version-edge mutant was admitted") } }) @@ -116,7 +116,7 @@ func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { func TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction(t *testing.T) { record := readAdoptionFrontDoorVersionEdge(t) - content, err := os.ReadFile(filepath.Join(repoRoot(t), archivedAdoptionFrontDoorChangeRecordPath)) + content, err := os.ReadFile(filepath.Join(archivedAdoptionFrontDoorRoot(t), filepath.FromSlash(record.ChangeRecordRef))) if err != nil { t.Fatal(err) } @@ -133,7 +133,7 @@ func TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction(t } mutantContent = append(mutantContent, '\n') mutantRoot := t.TempDir() - path := filepath.Join(mutantRoot, filepath.FromSlash(archivedAdoptionFrontDoorChangeRecordPath)) + path := filepath.Join(mutantRoot, filepath.FromSlash(record.ChangeRecordRef)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } @@ -148,6 +148,25 @@ func TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction(t } } +func TestAdoptionFrontDoorVersionEdgeResolvesDeclaredReferenceWithinFrozenReleaseRoot(t *testing.T) { + record := readAdoptionFrontDoorVersionEdge(t) + content, err := os.ReadFile(filepath.Join(archivedAdoptionFrontDoorRoot(t), filepath.FromSlash(record.ChangeRecordRef))) + if err != nil { + t.Fatal(err) + } + frozenRoot := t.TempDir() + path := filepath.Join(frozenRoot, filepath.FromSlash(record.ChangeRecordRef)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + if err := validateAdoptionFrontDoorVersionEdge(record, frozenRoot); err != nil { + t.Fatalf("validateAdoptionFrontDoorVersionEdge() failed to resolve declared reference: %v", err) + } +} + func TestRetiredInitRouteHasNoPublicDispatcher(t *testing.T) { status, stdout, stderr := executeAgentWorkflowCLI(t, []string{"init"}, panicReader{}, PresentationCapabilities{}) if status != 1 || stdout != "" || !strings.Contains(stderr, "unsupported command: init") { @@ -218,7 +237,7 @@ func readAdoptionFrontDoorVersionEdge(t *testing.T) adoptionFrontDoorVersionEdge return record } -func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, root string) error { +func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, releaseSnapshotRoot string) error { if record.SchemaVersion != 1 || record.EdgeID != "proofkit.public-wire.0.6.0-to-0.7.0" || record.EvidenceClass != "owner_authored_frozen_version_edge_observation" { return fmt.Errorf("adoption front-door version-edge identity is invalid") } @@ -275,7 +294,7 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if record.ChangeRecordRef != "release/change-record.v2.json" { return fmt.Errorf("adoption front-door change record reference is not exact") } - archivedChangeRecordPath := filepath.Join(root, archivedAdoptionFrontDoorChangeRecordPath) + archivedChangeRecordPath := filepath.Join(releaseSnapshotRoot, filepath.FromSlash(record.ChangeRecordRef)) changeRecordContent, err := os.ReadFile(archivedChangeRecordPath) if err != nil { return fmt.Errorf("read adoption front-door change record: %w", err) @@ -336,3 +355,8 @@ func cloneAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge) adop record.NonClaims = append([]string(nil), record.NonClaims...) return record } + +func archivedAdoptionFrontDoorRoot(t *testing.T) string { + t.Helper() + return filepath.Join(repoRoot(t), filepath.FromSlash(archivedAdoptionFrontDoorReleaseRoot)) +} diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 072acf9..fdcfe85 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "a5801893c2a853f2de819da7463a3aee896b3a8253b62dd5bdcc2acc3d870c96" + cliContractPublicABISHA256 = "b5ea707ee5851cea6b75442e4faf20e93879371faf3636e96a98ccd23b527463" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index 4b2cadd..9945217 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "196cf9436209eb79816ad2949703c50881d2b5295ed7ccd2a03fb97c672469d4" +const commandContractSourceSHA256 = "ea2fbade9c0651e7742b852a3f11433afee58a5a26d997a2cb00400db777c488" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,9 +12,9 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-materialize-apply": {InputContractSHA256: "sha256:244ad531c4afc32bcbaaa618803f8513a5cae79f870d0d1c7ddaca21edddfa20", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:0577bdcebea15feb360fe38d080180289eadf7465db905d3d73689a28eecc810", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, - "adopt-materialize-plan": {InputContractSHA256: "sha256:25b0d055e4ac88127b60b1aa1b1832f7609ca3ba07048dc94f0580cb5685aae7", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:f230745191996846a8d7cb16932e4959f5f8b271e740658eb28111c3af936efb", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, - "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c2a198b32f893aa352855ee6ed891d4e1dd4f3185231e214b34e4cba2afb0cd1", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, + "adopt-materialize-apply": {InputContractSHA256: "sha256:073bc4b983038d593e6a655104af51bb2a105974946ef5e2c9b857c68db6577f", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:a3ff3ab6f2835ce6eb6bd69351adec171d722663d7d33aabaeade33e52f411af", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:e7f2b1339f8ab11f577872d409b4fec66c79a79febe8cef7cfd15b969c0c4de4", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:02fc198c3a8eb0d03505f008c27e51c103b7b608eb3870e1757de529f697909a", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, + "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:a24c3c8710e000e83f068a3d98bc33f0239076687c04d1d72d2457d8a389bef3", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "adoption-checklist": {InputContractSHA256: "sha256:4e6c4c9b369279837a5894c0b3f842a411dce529b91c91cb2d4ec63eb5ee4c2c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-checklist.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9d0d0e60f0935407fd31007d8502459663eb4c7228dc5e3c7727ae2c9907bdc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, diff --git a/internal/app/testdata/v0.7-release-change-record.v2.json b/internal/app/testdata/releases/v0.7.0/release/change-record.v2.json similarity index 100% rename from internal/app/testdata/v0.7-release-change-record.v2.json rename to internal/app/testdata/releases/v0.7.0/release/change-record.v2.json diff --git a/internal/app/testdata/v0.8-wire-observations.json b/internal/app/testdata/v0.8-wire-observations.json index 4f8c644..99ea963 100644 --- a/internal/app/testdata/v0.8-wire-observations.json +++ b/internal/app/testdata/v0.8-wire-observations.json @@ -9,18 +9,18 @@ "changeRecordRef": "release/change-record.v2.json", "changeRecordSha256": "sha256:4c8434f7b77a5c623441b021a37b50786d56aba7f65091c38be5ed902231318d", "previousPublicAbiSha256": "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7", - "currentPublicAbiSha256": "sha256:a5801893c2a853f2de819da7463a3aee896b3a8253b62dd5bdcc2acc3d870c96", + "currentPublicAbiSha256": "sha256:b5ea707ee5851cea6b75442e4faf20e93879371faf3636e96a98ccd23b527463", "addedCommandContracts": [ { "command": "adopt-materialize-apply", "route": ["adopt", "materialize", "apply"], "inputContract": { "contractId": "proofkit.adopt-materialize-apply.input.v1", - "contractSha256": "sha256:244ad531c4afc32bcbaaa618803f8513a5cae79f870d0d1c7ddaca21edddfa20" + "contractSha256": "sha256:073bc4b983038d593e6a655104af51bb2a105974946ef5e2c9b857c68db6577f" }, "outputContract": { "contractId": "proofkit.adopt-materialize-apply.output.v1", - "contractSha256": "sha256:0577bdcebea15feb360fe38d080180289eadf7465db905d3d73689a28eecc810" + "contractSha256": "sha256:a3ff3ab6f2835ce6eb6bd69351adec171d722663d7d33aabaeade33e52f411af" } }, { @@ -28,11 +28,11 @@ "route": ["adopt", "materialize", "plan"], "inputContract": { "contractId": "proofkit.adopt-materialize-plan.input.v1", - "contractSha256": "sha256:25b0d055e4ac88127b60b1aa1b1832f7609ca3ba07048dc94f0580cb5685aae7" + "contractSha256": "sha256:e7f2b1339f8ab11f577872d409b4fec66c79a79febe8cef7cfd15b969c0c4de4" }, "outputContract": { "contractId": "proofkit.adopt-materialize-plan.output.v1", - "contractSha256": "sha256:f230745191996846a8d7cb16932e4959f5f8b271e740658eb28111c3af936efb" + "contractSha256": "sha256:02fc198c3a8eb0d03505f008c27e51c103b7b608eb3870e1757de529f697909a" } }, { @@ -40,7 +40,7 @@ "route": ["adopt", "materialize", "recover"], "outputContract": { "contractId": "proofkit.adopt-materialize-recover.output.v1", - "contractSha256": "sha256:c2a198b32f893aa352855ee6ed891d4e1dd4f3185231e214b34e4cba2afb0cd1" + "contractSha256": "sha256:a24c3c8710e000e83f068a3d98bc33f0239076687c04d1d72d2457d8a389bef3" } } ], diff --git a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go index d432820..d951e3a 100644 --- a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go +++ b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go @@ -91,6 +91,43 @@ func TestMaterializationOutputAdmissionRejectsCrossOwnerMutants(t *testing.T) { t.Fatal("AdmitPlanOutput() admitted a transaction that omitted a manifest route") } + requestRecord, err := admitRequest(request) + if err != nil { + t.Fatal(err) + } + children, err := childArtifacts(requestRecord) + if err != nil { + t.Fatal(err) + } + forgedManifest := plan.Manifest + forgedManifest.Routes = append([]Route(nil), plan.Manifest.Routes...) + forgedManifest.Routes[0].ArtifactID = digest.SHA256BytesRef([]byte("forged artifact identity")) + forgedManifest.ManifestID, err = digest.StableJSONSHA256Ref(forgedManifest.identityValue()) + if err != nil { + t.Fatal(err) + } + manifestContent, err := stablejson.Marshal(forgedManifest.JSONValue()) + if err != nil { + t.Fatal(err) + } + artifacts := append(append([]artifact(nil), children...), artifact{ + Content: manifestContent, ID: forgedManifest.ManifestID, Kind: ArtifactProjectManifest, Path: ProjectManifestPath, + }) + targets := make([]repositorytransaction.Target, 0, len(artifacts)) + for _, item := range artifacts { + targets = append(targets, repositorytransaction.Target{Content: item.Content, Mode: 0o644, Path: item.Path}) + } + forgedTransaction, err := repositorytransaction.BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + forgedPlan := plan + forgedPlan.Manifest = forgedManifest + forgedPlan.Transaction = forgedTransaction + if _, err := AdmitPlanOutput(forgedPlan.JSONValue()); err == nil { + t.Fatal("AdmitPlanOutput() admitted a route identity that did not match its target bytes") + } + receipt, exitCode, err := Apply(context.Background(), request, root, plan.Transaction.TransactionID, plan.Transaction.DesiredStateID) if err != nil || exitCode != 0 { t.Fatalf("Apply() receipt=%#v exit=%d error=%v", receipt, exitCode, err) @@ -153,6 +190,17 @@ func TestReceiptAdmissionRejectsOperationAttributionMutants(t *testing.T) { TransactionID: "sha256:" + strings.Repeat("c", 64), }, }, + { + ExpectedTransactionID: transactionID, + FailureClass: "ambiguous_target_state", + NonClaims: mergedNonClaims(nil), + Operation: OperationRecover, + State: ReceiptStateRecoveryRequired, + TransactionResult: &repositorytransaction.Result{ + AppliedCount: 1, AppliedCountKnown: true, FailureClass: "ambiguous_target_state", + State: repositorytransaction.StateRecoveryRequired, + }, + }, } for index := range tests { identity := tests[index].identityValue() @@ -381,10 +429,14 @@ func TestMaterializationRejectsCrossRecordDriftAndManifestMutation(t *testing.T) t.Fatal("AdmitManifest() accepted root-escaping route") } - colliding := cloneRequest(t, request) - colliding["requirementSources"].([]any)[0].(map[string]any)["sourceId"] = "pilot.bindings" - if _, err := BuildPlan(context.Background(), colliding, root); err == nil || !strings.Contains(err.Error(), "artifactIds must be unique") { - t.Fatalf("BuildPlan(colliding artifact IDs) error=%v", err) +} + +func TestMaterializationIdentifiersAreScopedByChildOwner(t *testing.T) { + root := t.TempDir() + request := validRequest(t, root) + request["requirementSources"].([]any)[0].(map[string]any)["sourceId"] = "pilot.bindings" + if _, err := BuildPlan(context.Background(), request, root); err != nil { + t.Fatalf("BuildPlan(cross-owner identifier reuse) error=%v", err) } } diff --git a/internal/command/adoptionmaterialization/closure_test.go b/internal/command/adoptionmaterialization/closure_test.go index f51f0b7..e8cb3ee 100644 --- a/internal/command/adoptionmaterialization/closure_test.go +++ b/internal/command/adoptionmaterialization/closure_test.go @@ -107,25 +107,25 @@ func TestManifestAdmissionEqualsProducerImage(t *testing.T) { { name: "missing inventory", routes: []Route{ - {ArtifactID: "source.a", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/a/requirements.v1.json"}, - {ArtifactID: "source.b", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/b/requirements.v1.json"}, - {ArtifactID: "binding", ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("source-a")), ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/a/requirements.v1.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("source-b")), ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/b/requirements.v1.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("binding")), ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, }, }, { name: "duplicate artifact identity", routes: []Route{ - {ArtifactID: "duplicate", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/a/requirements.v1.json"}, - {ArtifactID: "duplicate", ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, - {ArtifactID: "inventory", ArtifactKind: ArtifactTestInventory, Path: "proofkit/inventory.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("duplicate")), ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/a/requirements.v1.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("duplicate")), ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("inventory")), ArtifactKind: ArtifactTestInventory, Path: "proofkit/inventory.json"}, }, }, { name: "requirement source outside producer route language", routes: []Route{ - {ArtifactID: "source", ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/arbitrary.json"}, - {ArtifactID: "binding", ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, - {ArtifactID: "inventory", ArtifactKind: ArtifactTestInventory, Path: "proofkit/inventory.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("source")), ArtifactKind: ArtifactRequirementSource, Path: "docs/specs/arbitrary.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("binding")), ArtifactKind: ArtifactRequirementBinding, Path: "proofkit/binding.json"}, + {ArtifactID: digest.SHA256BytesRef([]byte("inventory")), ArtifactKind: ArtifactTestInventory, Path: "proofkit/inventory.json"}, }, }, } diff --git a/internal/command/adoptionmaterialization/manifest.go b/internal/command/adoptionmaterialization/manifest.go index 797826c..f507da7 100644 --- a/internal/command/adoptionmaterialization/manifest.go +++ b/internal/command/adoptionmaterialization/manifest.go @@ -19,7 +19,7 @@ var manifestNonClaims = []string{ } type Route struct { - ArtifactID string + ArtifactID string // SHA-256 identity of the canonical child bytes. ArtifactKind string Path string } @@ -35,7 +35,7 @@ type Manifest struct { func buildManifest(request Request, childArtifacts []artifact) (Manifest, error) { routes := make([]Route, 0, len(childArtifacts)) for _, child := range childArtifacts { - routes = append(routes, Route{ArtifactID: child.ID, ArtifactKind: child.Kind, Path: child.Path}) + routes = append(routes, Route{ArtifactID: digest.SHA256BytesRef(child.Content), ArtifactKind: child.Kind, Path: child.Path}) } sort.Slice(routes, func(left, right int) bool { return routes[left].Path < routes[right].Path }) manifest := Manifest{ @@ -152,7 +152,7 @@ func admitRoutes(raw any) ([]Route, error) { if err := admit.KnownKeys(record, []string{"artifactId", "artifactKind", "path"}, "project routing manifest route"); err != nil { return nil, err } - artifactID, err := admit.RuleID(record["artifactId"], "project routing manifest artifactId") + artifactID, err := admit.SHA256Ref(record["artifactId"], "project routing manifest artifactId") if err != nil { return nil, err } diff --git a/internal/command/adoptionmaterialization/output_admission.go b/internal/command/adoptionmaterialization/output_admission.go index b96050d..c8841b0 100644 --- a/internal/command/adoptionmaterialization/output_admission.go +++ b/internal/command/adoptionmaterialization/output_admission.go @@ -157,8 +157,10 @@ func AdmitReceiptOutput(raw any) (Receipt, error) { func validatePlanRouteClosure(manifest Manifest, transaction repositorytransaction.Plan) error { wantPaths := make([]string, 0, len(manifest.Routes)+1) wantPaths = append(wantPaths, ProjectManifestPath) + routesByPath := make(map[string]Route, len(manifest.Routes)) for _, route := range manifest.Routes { wantPaths = append(wantPaths, route.Path) + routesByPath[route.Path] = route } slices.Sort(wantPaths) gotPaths := make([]string, 0, len(transaction.Operations)) @@ -172,6 +174,8 @@ func validatePlanRouteClosure(manifest Manifest, transaction repositorytransacti if err != nil || operation.After.ByteCount != int64(len(content)) || operation.After.SHA256 != digest.SHA256BytesRef(content) { return fmt.Errorf("adoption materialization manifest transaction target is inconsistent") } + } else if route, ok := routesByPath[operation.Path]; !ok || route.ArtifactID != operation.After.SHA256 { + return fmt.Errorf("adoption materialization route identity does not match its transaction target") } } if !slices.Equal(gotPaths, wantPaths) { diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 1f9a31d..6b16584 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "196cf9436209eb79816ad2949703c50881d2b5295ed7ccd2a03fb97c672469d4" +const presetContractSourceSHA256 = "ea2fbade9c0651e7742b852a3f11433afee58a5a26d997a2cb00400db777c488" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/kernel/repositorytransaction/output_admission.go b/internal/kernel/repositorytransaction/output_admission.go index 5c5e32b..c4810c5 100644 --- a/internal/kernel/repositorytransaction/output_admission.go +++ b/internal/kernel/repositorytransaction/output_admission.go @@ -125,6 +125,9 @@ func AdmitResultOutput(raw any) (Result, error) { } func validateResultRelation(result Result) error { + if result.TransactionID == "" && (result.AppliedCountKnown || result.RecoveredBy != "") { + return fmt.Errorf("repository transaction progress requires a transaction identity") + } switch result.State { case StateApplied: if !result.AppliedCountKnown || result.AppliedCount == 0 || result.TransactionID == "" || result.FailureClass != "" || result.RecoveredBy == RecoveryRollback { diff --git a/internal/kernel/repositorytransaction/output_admission_test.go b/internal/kernel/repositorytransaction/output_admission_test.go index 294b316..579b29c 100644 --- a/internal/kernel/repositorytransaction/output_admission_test.go +++ b/internal/kernel/repositorytransaction/output_admission_test.go @@ -47,6 +47,8 @@ func TestPlanAndResultOutputAdmissionRejectSemanticMutants(t *testing.T) { {AppliedCountKnown: true, State: StateRolledBack, TransactionID: transactionID}, {FailureClass: "cleanup_failed", State: StateCleanupRequired}, {FailureClass: "applied_cleanup_durability_unknown", State: StateDurabilityUnknown}, + {AppliedCount: 1, AppliedCountKnown: true, FailureClass: "ambiguous_target_state", State: StateRecoveryRequired}, + {FailureClass: "ambiguous_target_state", RecoveredBy: RecoveryResume, State: StateRecoveryRequired}, } { if _, err := AdmitResultOutput(impossible.JSONValue()); err == nil { t.Fatalf("AdmitResultOutput() admitted unreachable result %#v", impossible) diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index 45ff0c5..4076ba6 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -111,7 +111,7 @@ "rootDefinitionDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "canonicalDigest": "sha256:25fde227d9a26bc6fac85be5963b4f227e5cac06b876b14565a863ff45b40f11", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -147,12 +147,12 @@ }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "canonicalDigest": "sha256:25fde227d9a26bc6fac85be5963b4f227e5cac06b876b14565a863ff45b40f11", "evidenceClass": "source_checkout" }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:534ea025b0bf51ec99a1b44d755abb433ab687b572c4f5a0737440176d334db8", + "canonicalDigest": "sha256:a0c1d96df99382bd334cd7ddaa3de2d191c5c7f166ece0c720e3791fec87350f", "evidenceClass": "source_checkout" } ], @@ -249,7 +249,7 @@ "rootDefinitionDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "canonicalDigest": "sha256:25fde227d9a26bc6fac85be5963b4f227e5cac06b876b14565a863ff45b40f11", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -285,12 +285,12 @@ }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "canonicalDigest": "sha256:25fde227d9a26bc6fac85be5963b4f227e5cac06b876b14565a863ff45b40f11", "evidenceClass": "source_checkout" }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:534ea025b0bf51ec99a1b44d755abb433ab687b572c4f5a0737440176d334db8", + "canonicalDigest": "sha256:a0c1d96df99382bd334cd7ddaa3de2d191c5c7f166ece0c720e3791fec87350f", "evidenceClass": "source_checkout" } ], @@ -399,12 +399,12 @@ }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:d23fbb07608afc3b9e5c1f83b0f7d374cec5ffacc1ae4d9351d011b30fe306b4", + "canonicalDigest": "sha256:25fde227d9a26bc6fac85be5963b4f227e5cac06b876b14565a863ff45b40f11", "evidenceClass": "source_checkout" }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:534ea025b0bf51ec99a1b44d755abb433ab687b572c4f5a0737440176d334db8", + "canonicalDigest": "sha256:a0c1d96df99382bd334cd7ddaa3de2d191c5c7f166ece0c720e3791fec87350f", "evidenceClass": "source_checkout" } ],