From ee0de703c840c4a30637d4149fb8820be2a77c3e Mon Sep 17 00:00:00 2001 From: iperev Date: Sat, 5 Sep 2026 13:00:43 +0200 Subject: [PATCH 1/2] fix: keep descriptive transaction plans non-executable --- .../requirements.v1.json | 2 +- internal/app/cli_contract_test.go | 2 +- internal/app/command_contract_generated.go | 10 +- internal/app/integration_version_edge_test.go | 24 +- .../v0.10.0/release/change-record.v2.json | 38 +++ .../stackpreset/preset_ids_generated.go | 2 +- .../construction_test.go | 275 ++++++++++++++++++ .../kernel/repositorytransaction/model.go | 2 + .../repositorytransaction/output_admission.go | 2 +- internal/kernel/repositorytransaction/plan.go | 1 + .../kernel/repositorytransaction/plan_test.go | 2 + .../kernel/repositorytransaction/state.go | 5 + .../coveragemetrics/required_inventory.go | 9 + package-lock.json | 4 +- package.json | 2 +- proofkit/cli-contract.v2.json | 8 +- proofkit/requirement-bindings.json | 31 ++ release/change-record.v2.json | 12 +- 18 files changed, 391 insertions(+), 40 deletions(-) create mode 100644 internal/app/testdata/releases/v0.10.0/release/change-record.v2.json create mode 100644 internal/kernel/repositorytransaction/construction_test.go diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index 14edccb..aec7b6e 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -676,7 +676,7 @@ { "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.", + "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. Native construction binds Apply to the complete transaction identity; public JSON re-admission remains descriptive even for empty payloads, while recovery retains its separate confined journal and staged-object admission. 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"], diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index b333ea1..b25e16c 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "0e246c0d26bebd39ca6d6747ee7e325cb73aaca4dc01d80fa63cb6dc4899155f" + cliContractPublicABISHA256 = "cc1fc5a55e00ea13e92d82edc3a3e3115cd9e69a00d08618fe2b1cefd25216d2" 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 16bd69b..5a9abc3 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 = "c9322a64711d906f2330ae225c08c69042ad244d9f4fe00d9e8ae0e4815ab7f5" +const commandContractSourceSHA256 = "846a642fbe1bfb9a59502c7018788667b5b6bd711e5fc45534f1846bc440e344" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,9 +12,9 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-materialize-apply": {InputContractSHA256: "sha256:98539b75bf6d4caebc75d724e9201925a1235290791adcebc865c1b3c74976ee", 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:cd6f4a60c4539b453b5e3e48cb67507ed3402c425ecc7bb5007469e48abad96e", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, - "adopt-materialize-plan": {InputContractSHA256: "sha256:65925b56d0332349c5048e9b1450ff0b7a3381224342df1a630128e3f4a2cde2", 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:67c56553cdbb4000b1df58871c148683e322b5fb2076381c30c531fabb23d645", 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:e1d51c61e787021cd5dad1a92e66d2d576c0f0c88d3a2855d9034cf96da7278c", 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:98539b75bf6d4caebc75d724e9201925a1235290791adcebc865c1b3c74976ee", 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:d24edde933193c61dd369cebd6505782fbf5b4f13187516563ad3517d823cc1e", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:65925b56d0332349c5048e9b1450ff0b7a3381224342df1a630128e3f4a2cde2", 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:64e5520b06f9de268fe9c21e4877a383033b1c7dd94496637b87935e33b9165a", 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:794fcd1398fffb3167bf639606a3298629f7c2b8fec77a9da8154d69d6db57a0", 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"}}, @@ -38,7 +38,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "gradual-adoption-guidance": {InputContractSHA256: "sha256:4752cbac81c864cb3e18a39facfd666a9707314233d54798c7f71e67d7f2800c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption-guidance.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:171fed4bb8d32a47fc5ec49796f5b0b55ed666feaccc2fbbfeb12da31d80ecc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"gradual-adoption-guidance"}}, "help": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "", FlagChoices: map[string][]string{}, RouteTokens: []string{"help"}}, "impact": {InputContractSHA256: "sha256:41d3107414837955ee408d5ce94949a4c1a6b76f6949e6c1dc224bd06f6b09bc", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.impact.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:73066e9a5ca48f21936111ffb7223900fb629875997f4e7b16d7fef9c4177972", FlagChoices: map[string][]string{}, RouteTokens: []string{"impact"}}, - "integration-check": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:a43909ee05b82a33fa87b700714279991972b1bc158339dd4ed459d766921477", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "check"}}, + "integration-check": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:cdb9240e3dadf6ab5aa38e7107dbf618935c2278d8cb381e4b3cdfec9bdbdb75", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "check"}}, "integration-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:7c0ff8d63ac58eeb5dbc40fa4b6be341d2e9814e6ad39805926de26414761303", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "source"}}, "json-report-cli-adapter-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:6c3dd1c8507a90e055cf2c886089446d8560ff3e0d3ca9cc6360a3377d2d85da", FlagChoices: map[string][]string{}, RouteTokens: []string{"json-report-cli-adapter-source"}}, "migration-parity-admission": {InputContractSHA256: "sha256:0b36c0e68da3b857dac4b13e7b3bd523052459106133aa8c908a4352682e6c05", InputSchemaSummary: []string{"schemaVersion=1", "paritySetId", "sourceProofOwners[]", "targetProofkitRefs[]", "parityRecords[]", "nonClaims[]", "root-shape-only definition proofkit.migration-parity-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8e0f8af2b205817f018b0fe133fe789661caa29007695e036bfcab63c1830f47", FlagChoices: map[string][]string{}, RouteTokens: []string{"migration-parity-admission"}}, diff --git a/internal/app/integration_version_edge_test.go b/internal/app/integration_version_edge_test.go index aeaf1a5..bde3003 100644 --- a/internal/app/integration_version_edge_test.go +++ b/internal/app/integration_version_edge_test.go @@ -1,12 +1,13 @@ package app import ( + "crypto/sha256" + "fmt" "os" "path/filepath" "slices" "testing" - "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" ) @@ -37,27 +38,18 @@ func TestIntegrationVersionEdgeClosesCompletePublicABIDiff(t *testing.T) { t.Fatal("new public commands retained the previous complete ABI identity") } - change, err := releasechange.Read(filepath.Join(repoRoot(t), releasechange.RecordPath)) + changePath := filepath.Join(repoRoot(t), "internal/app/testdata/releases/v0.10.0", releasechange.RecordPath) + content, err := os.ReadFile(changePath) + if err != nil || fmt.Sprintf("%x", sha256.Sum256(content)) != "9240098569e1fcc1d9cd8137e1184a97a9ad31f10649dfff901f2d66d7f2b81b" { + t.Fatalf("archived integration release bytes changed: %v", err) + } + change, err := releasechange.Read(changePath) if err != nil { t.Fatal(err) } if change.PreviousVersion != frozen.ReleaseVersion || change.Version != "0.10.0" || change.ChangeClass != "compatible" || len(change.BreakingChanges) != 0 || change.Migration.Required || len(change.Migration.Steps) != 0 || !slices.Equal(releaseChangeIDs(change.Additions), []string{"proofkit.agent-integration.freshness", "proofkit.agent-integration.source"}) { t.Fatal("integration release record does not describe the exact compatible addition") } - file, err := os.Open(filepath.Join(repoRoot(t), "package.json")) - if err != nil { - t.Fatal(err) - } - defer file.Close() - manifest, err := admission.DecodeTypedJSON[struct { - Version string `json:"version"` - }](file, 64<<10) - if err != nil { - t.Fatal(err) - } - if manifest.Version != change.Version { - t.Fatal("integration package version differs from its release record") - } contracts, err := currentVersionEdgeCommandContracts(repoRoot(t), []string{"integration-check", "integration-source"}) if err != nil { t.Fatal(err) diff --git a/internal/app/testdata/releases/v0.10.0/release/change-record.v2.json b/internal/app/testdata/releases/v0.10.0/release/change-record.v2.json new file mode 100644 index 0000000..850899a --- /dev/null +++ b/internal/app/testdata/releases/v0.10.0/release/change-record.v2.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 2, + "previousVersion": "0.9.0", + "version": "0.10.0", + "changeClass": "compatible", + "breakingChanges": [], + "additions": [ + { + "changeId": "proofkit.agent-integration.freshness", + "summary": "Add integration check for explicit Codex or Claude repository paths; compare bounded current template bytes through confined read-only inspection without granting installation or host-activation authority." + }, + { + "changeId": "proofkit.agent-integration.source", + "summary": "Add integration source to generate one bounded portable skill body and descriptor-owned path, with an identity bound to consumed registered CLI invocation contracts rather than package version or a host-specific executable." + } + ], + "migration": { + "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.", + "Project status and next classify materialized repository structure only; they do not execute native verification, validate receipt currentness or trust, or declare workflow completion.", + "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.", + "Integration source and check do not install, update, remove, activate, or execute a host skill. Managed lifecycle and observed host activation remain separate open work." + ], + "rollback": { + "strategy": "previous_admitted_version" + } +} diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 4503e9e..29d7b66 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 = "c9322a64711d906f2330ae225c08c69042ad244d9f4fe00d9e8ae0e4815ab7f5" +const presetContractSourceSHA256 = "846a642fbe1bfb9a59502c7018788667b5b6bd711e5fc45534f1846bc440e344" 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/construction_test.go b/internal/kernel/repositorytransaction/construction_test.go new file mode 100644 index 0000000..bad7477 --- /dev/null +++ b/internal/kernel/repositorytransaction/construction_test.go @@ -0,0 +1,275 @@ +package repositorytransaction + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +type constructionTarget struct { + path, before, after string + exists bool + beforeMode, afterMode fs.FileMode +} + +func buildConstructionPlan(t *testing.T, root string, items []constructionTarget) Plan { + t.Helper() + targets := make([]Target, 0, len(items)) + for _, item := range items { + if item.exists { + mustWriteTestFile(t, root, item.path, item.before, item.beforeMode) + if err := os.Chmod(filepath.Join(root, filepath.FromSlash(item.path)), item.beforeMode); err != nil { + t.Fatal(err) + } + assertTestFile(t, root, item.path, item.before, item.beforeMode) + } + targets = append(targets, Target{Path: item.path, Content: []byte(item.after), Mode: item.afterMode}) + } + plan, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + return plan +} + +func readmittedConstructionPlan(t *testing.T, plan Plan) Plan { + t.Helper() + wire, err := stablejson.Marshal(plan.JSONValue()) + if err != nil { + t.Fatal(err) + } + raw, err := admission.DecodeJSON(bytes.NewReader(wire), MaximumJournalBytes) + if err != nil { + t.Fatal(err) + } + descriptive, err := AdmitPlanOutput(raw) + if err != nil { + t.Fatal(err) + } + reencoded, err := stablejson.Marshal(descriptive.JSONValue()) + if err != nil || !bytes.Equal(wire, reencoded) || descriptive.TransactionID != plan.TransactionID || descriptive.DesiredStateID != plan.DesiredStateID { + t.Fatalf("wire identity changed: %v", err) + } + return descriptive +} + +func assertConstructionRejected(t *testing.T, root string, plan Plan) { + t.Helper() + before := snapshotTestTree(t, root) + result, err := Apply(context.Background(), root, plan) + if err == nil { + t.Errorf("descriptive or transplanted plan executed: %#v", result) + } + if !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Error("rejected Apply changed target/control tree") + } +} + +func TestPlanConstructionSurvivesOnlyNativeCopies(t *testing.T) { + cases := []struct { + name string + count int + items []constructionTarget + }{ + {"create-empty", 1, []constructionTarget{{path: "new/a", afterMode: 0o644}}}, + {"create-nonempty", 1, []constructionTarget{{path: "new/a", after: "new", afterMode: 0o644}}}, + {"replace-empty-mode", 1, []constructionTarget{{path: "old/a", exists: true, beforeMode: 0o600, afterMode: 0o644}}}, + {"replace-to-empty", 1, []constructionTarget{{path: "old/a", exists: true, before: "old", beforeMode: 0o600, afterMode: 0o644}}}, + {"replace-from-empty", 1, []constructionTarget{{path: "old/a", exists: true, after: "new", beforeMode: 0o600, afterMode: 0o644}}}, + {"replace-nonempty", 1, []constructionTarget{{path: "old/a", exists: true, before: "old", after: "new", beforeMode: 0o600, afterMode: 0o644}}}, + {"unchanged-empty", 0, []constructionTarget{{path: "old/a", exists: true, beforeMode: 0o644, afterMode: 0o644}}}, + {"unchanged-nonempty", 0, []constructionTarget{{path: "old/a", exists: true, before: "same", after: "same", beforeMode: 0o644, afterMode: 0o644}}}, + {"mixed-empty", 2, []constructionTarget{{path: "new/a", afterMode: 0o644}, {path: "old/b", exists: true, beforeMode: 0o600, afterMode: 0o644}, {path: "old/c", exists: true, beforeMode: 0o644, afterMode: 0o644}}}, + {"mixed-bytes", 2, []constructionTarget{{path: "new/a", afterMode: 0o644}, {path: "old/b", exists: true, before: "old", beforeMode: 0o600, afterMode: 0o644}, {path: "old/c", exists: true, before: "same", after: "same", beforeMode: 0o644, afterMode: 0o644}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + plan := buildConstructionPlan(t, root, tc.items) + descriptive := readmittedConstructionPlan(t, plan) + assertConstructionRejected(t, root, descriptive) + copy := clonePlan(plan) + result, err := Apply(context.Background(), root, copy) + want := StateApplied + if tc.count == 0 { + want = StateAlreadySatisfied + } + if err != nil || result.State != want || !result.AppliedCountKnown || result.AppliedCount != tc.count || result.TransactionID != plan.TransactionID { + t.Fatalf("native: %#v %v", result, err) + } + for _, item := range tc.items { + assertTestFile(t, root, item.path, item.after, item.afterMode) + } + assertConstructionRejected(t, root, descriptive) + for _, candidate := range []Plan{plan, copy} { + replay, err := Apply(context.Background(), root, candidate) + if err != nil || replay.State != StateAlreadySatisfied || replay.AppliedCount != 0 || !replay.AppliedCountKnown || replay.TransactionID != plan.TransactionID { + t.Fatalf("native replay: %#v %v", replay, err) + } + } + }) + } +} + +func TestPlanConstructionRejectsPublicFieldTransplant(t *testing.T) { + for _, sameDesired := range []bool{false, true} { + t.Run(fmt.Sprintf("same-desired=%t", sameDesired), func(t *testing.T) { + root := t.TempDir() + first := constructionTarget{path: "a/one", afterMode: 0o644} + second := constructionTarget{path: "b/two", afterMode: 0o644} + if sameDesired { + first.exists, first.beforeMode = true, 0o600 + second = first + second.beforeMode = 0o640 + } + a := buildConstructionPlan(t, root, []constructionTarget{first}) + b := buildConstructionPlan(t, root, []constructionTarget{second}) + if a.TransactionID == b.TransactionID || (a.DesiredStateID == b.DesiredStateID) != sameDesired { + t.Fatal("transplant does not isolate the declared identity relation") + } + descriptiveB := readmittedConstructionPlan(t, b) + forged := a + forged.CreatedDirectories = descriptiveB.CreatedDirectories + forged.DesiredStateID = descriptiveB.DesiredStateID + forged.Operations = descriptiveB.Operations + forged.RootID = descriptiveB.RootID + forged.TransactionID = descriptiveB.TransactionID + if _, err := AdmitPlanOutput(forged.JSONValue()); err != nil { + t.Fatalf("transplant not canonical: %v", err) + } + assertConstructionRejected(t, root, forged) + if result, err := Apply(context.Background(), root, b); err != nil || result.State != StateApplied { + t.Fatalf("native transplant control failed: %#v %v", result, err) + } + }) + } +} + +func TestPlanConstructionRetainsIndependentExecutionChecks(t *testing.T) { + for _, defect := range []string{"before-payload", "after-payload", "root", "target-state"} { + t.Run(defect, func(t *testing.T) { + root := t.TempDir() + item := constructionTarget{path: "old/a", exists: true, before: "before", after: "after", beforeMode: 0o600, afterMode: 0o644} + plan := buildConstructionPlan(t, root, []constructionTarget{item}) + mutated := clonePlan(plan) + switch defect { + case "before-payload": + mutated.Operations[0].beforeContent[0] = 'X' + case "after-payload": + mutated.Operations[0].afterContent[0] = 'X' + case "root": + assertConstructionRejected(t, t.TempDir(), mutated) + case "target-state": + mustWriteTestFile(t, root, item.path, "foreign", item.beforeMode) + } + if mutated.constructedTransactionID != mutated.TransactionID { + t.Fatal("counterexample lost its native construction binding") + } + if defect != "root" { + assertConstructionRejected(t, root, mutated) + } + if defect == "target-state" { + mustWriteTestFile(t, root, item.path, item.before, item.beforeMode) + } + result, err := Apply(context.Background(), root, plan) + if err != nil || result.State != StateApplied || result.AppliedCount != 1 || !result.AppliedCountKnown { + t.Fatalf("native control failed: %#v %v", result, err) + } + assertTestFile(t, root, item.path, item.after, item.afterMode) + }) + } +} + +func constructionRecoveryItems() []constructionTarget { + return []constructionTarget{ + {path: "new/a", afterMode: 0o644}, + {path: "old/b", exists: true, before: "same", after: "same", beforeMode: 0o644, afterMode: 0o644}, + {path: "old/c", exists: true, beforeMode: 0o600, afterMode: 0o644}, + {path: "old/d", exists: true, before: "old", beforeMode: 0o600, afterMode: 0o644}, + {path: "old/e", exists: true, after: "new", beforeMode: 0o600, afterMode: 0o644}, + {path: "old/f", exists: true, beforeMode: 0o644, afterMode: 0o644}, + } +} + +func TestRecoveryDoesNotRequirePublicPlanConstruction(t *testing.T) { + for _, stage := range []string{"preparing", "staged", "partial", "terminal"} { + for _, action := range []string{RecoveryResume, RecoveryRollback} { + if stage == "preparing" && action == RecoveryResume { + continue + } + t.Run(fmt.Sprintf("%s/%s", stage, action), func(t *testing.T) { + rootPath := t.TempDir() + items := constructionRecoveryItems() + plan := buildConstructionPlan(t, rootPath, items) + if stage == "preparing" { + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + if err := prepareJournal(root, plan); err != nil { + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + } else { + prefix := 0 + if stage == "partial" { + prefix = 2 + } + if stage == "terminal" && action == RecoveryResume { + prefix = 4 + } + leaveInterruptedPrefix(t, rootPath, plan, prefix) + if stage == "terminal" { + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + marker := committedMarker + if action == RecoveryRollback { + marker = rolledBackMarker + } + if err := writeMarker(root, marker); err != nil { + t.Fatal(err) + } + if err := root.Close(); err != nil { + t.Fatal(err) + } + } + } + first, err := Recover(context.Background(), rootPath, plan.TransactionID, action) + wantState, wantCount := StateApplied, 4 + if action == RecoveryRollback { + wantState, wantCount = StateRolledBack, 0 + } + if err != nil || first.State != wantState || first.TransactionID != plan.TransactionID || first.RecoveredBy != action || !first.AppliedCountKnown || first.AppliedCount != wantCount || first.FailureClass != "" { + t.Fatalf("recover: %#v %v", first, err) + } + for _, item := range items { + if action == RecoveryResume { + assertTestFile(t, rootPath, item.path, item.after, item.afterMode) + } else if item.exists { + assertTestFile(t, rootPath, item.path, item.before, item.beforeMode) + } else if _, err := os.Lstat(filepath.Join(rootPath, item.path)); !os.IsNotExist(err) { + t.Fatalf("created target retained: %v", err) + } + } + before := snapshotTestTree(t, rootPath) + repeated, err := Recover(context.Background(), rootPath, plan.TransactionID, action) + if err != nil || repeated != first || !reflect.DeepEqual(before, snapshotTestTree(t, rootPath)) { + t.Fatalf("replay differs: %#v %v", repeated, err) + } + assertNoPendingTransaction(t, rootPath) + }) + } + } +} diff --git a/internal/kernel/repositorytransaction/model.go b/internal/kernel/repositorytransaction/model.go index 71f8aff..cef141b 100644 --- a/internal/kernel/repositorytransaction/model.go +++ b/internal/kernel/repositorytransaction/model.go @@ -72,6 +72,8 @@ type Plan struct { Operations []Operation RootID string TransactionID string + + constructedTransactionID string } type Result struct { diff --git a/internal/kernel/repositorytransaction/output_admission.go b/internal/kernel/repositorytransaction/output_admission.go index c4810c5..320e2eb 100644 --- a/internal/kernel/repositorytransaction/output_admission.go +++ b/internal/kernel/repositorytransaction/output_admission.go @@ -20,7 +20,7 @@ var resultStateSet = map[string]struct{}{ // 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. +// lacks native construction identity and private execution payloads. func AdmitPlanOutput(raw any) (Plan, error) { record, ok := raw.(map[string]any) if !ok { diff --git a/internal/kernel/repositorytransaction/plan.go b/internal/kernel/repositorytransaction/plan.go index a2f946a..04e8d65 100644 --- a/internal/kernel/repositorytransaction/plan.go +++ b/internal/kernel/repositorytransaction/plan.go @@ -146,6 +146,7 @@ func BuildPlan(ctx context.Context, rootPath string, targets []Target) (Plan, er if _, err := AdmitPlanOutput(plan.JSONValue()); err != nil { return Plan{}, fmt.Errorf("admit repository transaction plan output: %w", err) } + plan.constructedTransactionID = plan.TransactionID return plan, nil } diff --git a/internal/kernel/repositorytransaction/plan_test.go b/internal/kernel/repositorytransaction/plan_test.go index 4dd97b4..31e669f 100644 --- a/internal/kernel/repositorytransaction/plan_test.go +++ b/internal/kernel/repositorytransaction/plan_test.go @@ -191,6 +191,8 @@ func TestApplyRejectsMutatedPlanBeforeControlMutation(t *testing.T) { if err != nil { t.Fatal(err) } + // Isolate semantic admission from the independent construction guard. + plan.constructedTransactionID = plan.TransactionID if _, err := Apply(context.Background(), root, plan); err == nil { t.Fatal("Apply() admitted mutated plan") } diff --git a/internal/kernel/repositorytransaction/state.go b/internal/kernel/repositorytransaction/state.go index fc9bef5..b004eb0 100644 --- a/internal/kernel/repositorytransaction/state.go +++ b/internal/kernel/repositorytransaction/state.go @@ -66,6 +66,11 @@ 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") } + // Empty payloads survive wire projection; only native construction binds + // execution to the complete immutable transaction identity. + if plan.constructedTransactionID != plan.TransactionID { + return fmt.Errorf("repository transaction plan lacks native construction for this identity") + } if _, err := admitJournal(journalValue(plan)); err != nil { return fmt.Errorf("repository transaction plan semantic admission failed") } diff --git a/internal/tools/coveragemetrics/required_inventory.go b/internal/tools/coveragemetrics/required_inventory.go index 00501b3..f2318a0 100644 --- a/internal/tools/coveragemetrics/required_inventory.go +++ b/internal/tools/coveragemetrics/required_inventory.go @@ -839,6 +839,15 @@ func requiredBindingWitnessInventory() map[inventoryKey]requiredInventoryEntry { witnessPath: "internal/kernel/repositorytransaction/output_admission_test.go", selectors: []string{"TestPlanAndResultOutputAdmissionRejectSemanticMutants"}, }, + {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-native-construction"}: { + witnessPath: "internal/kernel/repositorytransaction/construction_test.go", + selectors: []string{ + "TestPlanConstructionRejectsPublicFieldTransplant", + "TestPlanConstructionRetainsIndependentExecutionChecks", + "TestPlanConstructionSurvivesOnlyNativeCopies", + "TestRecoveryDoesNotRequirePublicPlanConstruction", + }, + }, {"REQ-PROOFKIT-SPEC-033", "proofkit.spec-proof-core.repository-transaction-cleanup-state-matrix"}: { witnessPath: "internal/kernel/repositorytransaction/state_machine_test.go", selectors: []string{"TestCleanupDurabilityFailureDoesNotClaimRecoverableState"}, diff --git a/package-lock.json b/package-lock.json index 84a980f..b0c6ba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.10.0", + "version": "0.10.1", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index e052639..954e3be 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.10.0", + "version": "0.10.1", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index 3dad2fa..fcc6d31 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -153,7 +153,7 @@ }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:4279c057766efe379f92e1fbfdbafa1f961a1a15c0b113230ea6cb8cfd0d7ae1", + "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", "evidenceClass": "source_checkout" } ], @@ -291,7 +291,7 @@ }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:4279c057766efe379f92e1fbfdbafa1f961a1a15c0b113230ea6cb8cfd0d7ae1", + "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", "evidenceClass": "source_checkout" } ], @@ -405,7 +405,7 @@ }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:4279c057766efe379f92e1fbfdbafa1f961a1a15c0b113230ea6cb8cfd0d7ae1", + "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", "evidenceClass": "source_checkout" } ], @@ -2483,7 +2483,7 @@ }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:4279c057766efe379f92e1fbfdbafa1f961a1a15c0b113230ea6cb8cfd0d7ae1", + "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", "evidenceClass": "source_checkout" }, { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index d2dad8a..8d429d3 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -6091,6 +6091,37 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.spec-proof-core.repository-transaction-native-construction", + "witnessId": "proofkit.repository-transaction.native-construction-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/construction_test.go", + "witnessSelectors": [ + { + "selector": "TestPlanConstructionRejectsPublicFieldTransplant", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPlanConstructionRejectsPublicFieldTransplant$'" + }, + { + "selector": "TestPlanConstructionRetainsIndependentExecutionChecks", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPlanConstructionRetainsIndependentExecutionChecks$'" + }, + { + "selector": "TestPlanConstructionSurvivesOnlyNativeCopies", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPlanConstructionSurvivesOnlyNativeCopies$'" + }, + { + "selector": "TestRecoveryDoesNotRequirePublicPlanConstruction", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestRecoveryDoesNotRequirePublicPlanConstruction$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-SPEC-033", "scenarioId": "proofkit.spec-proof-core.repository-transaction-terminal-state", diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 850899a..46e995a 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,17 +1,13 @@ { "schemaVersion": 2, - "previousVersion": "0.9.0", - "version": "0.10.0", + "previousVersion": "0.10.0", + "version": "0.10.1", "changeClass": "compatible", "breakingChanges": [], "additions": [ { - "changeId": "proofkit.agent-integration.freshness", - "summary": "Add integration check for explicit Codex or Claude repository paths; compare bounded current template bytes through confined read-only inspection without granting installation or host-activation authority." - }, - { - "changeId": "proofkit.agent-integration.source", - "summary": "Add integration source to generate one bounded portable skill body and descriptor-owned path, with an identity bound to consumed registered CLI invocation contracts rather than package version or a host-specific executable." + "changeId": "proofkit.repository-transaction.native-construction", + "summary": "Keep re-admitted transaction reports non-executable for empty and nonempty payloads by binding Apply to native construction of the complete transaction identity. Preserve public JSON, valid native operations, and journal recovery." } ], "migration": { From 847d38d8f1ecac7d66d63a83a1eb1705171948b9 Mon Sep 17 00:00:00 2001 From: iperev Date: Sat, 5 Sep 2026 13:11:16 +0200 Subject: [PATCH 2/2] test: preserve independent transaction and release oracles --- .../repositorytransaction/construction_test.go | 14 +++++++++++++- internal/kernel/repositorytransaction/plan_test.go | 2 ++ internal/tools/releasechange/record_test.go | 9 ++++----- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/kernel/repositorytransaction/construction_test.go b/internal/kernel/repositorytransaction/construction_test.go index bad7477..4ca58b7 100644 --- a/internal/kernel/repositorytransaction/construction_test.go +++ b/internal/kernel/repositorytransaction/construction_test.go @@ -154,10 +154,13 @@ func TestPlanConstructionRejectsPublicFieldTransplant(t *testing.T) { } func TestPlanConstructionRetainsIndependentExecutionChecks(t *testing.T) { - for _, defect := range []string{"before-payload", "after-payload", "root", "target-state"} { + for _, defect := range []string{"before-payload", "after-payload", "root", "target-state", "parent-state"} { t.Run(defect, func(t *testing.T) { root := t.TempDir() item := constructionTarget{path: "old/a", exists: true, before: "before", after: "after", beforeMode: 0o600, afterMode: 0o644} + if defect == "parent-state" { + item = constructionTarget{path: "new/a", after: "after", afterMode: 0o644} + } plan := buildConstructionPlan(t, root, []constructionTarget{item}) mutated := clonePlan(plan) switch defect { @@ -169,6 +172,10 @@ func TestPlanConstructionRetainsIndependentExecutionChecks(t *testing.T) { assertConstructionRejected(t, t.TempDir(), mutated) case "target-state": mustWriteTestFile(t, root, item.path, "foreign", item.beforeMode) + case "parent-state": + if err := os.Mkdir(filepath.Join(root, "new"), 0o755); err != nil { + t.Fatal(err) + } } if mutated.constructedTransactionID != mutated.TransactionID { t.Fatal("counterexample lost its native construction binding") @@ -179,6 +186,11 @@ func TestPlanConstructionRetainsIndependentExecutionChecks(t *testing.T) { if defect == "target-state" { mustWriteTestFile(t, root, item.path, item.before, item.beforeMode) } + if defect == "parent-state" { + if err := os.Remove(filepath.Join(root, "new")); err != nil { + t.Fatal(err) + } + } result, err := Apply(context.Background(), root, plan) if err != nil || result.State != StateApplied || result.AppliedCount != 1 || !result.AppliedCountKnown { t.Fatalf("native control failed: %#v %v", result, err) diff --git a/internal/kernel/repositorytransaction/plan_test.go b/internal/kernel/repositorytransaction/plan_test.go index 31e669f..adaa4bb 100644 --- a/internal/kernel/repositorytransaction/plan_test.go +++ b/internal/kernel/repositorytransaction/plan_test.go @@ -235,6 +235,8 @@ func TestApplyRejectsForgedCreatedDirectoryOwnership(t *testing.T) { if err != nil { t.Fatal(err) } + // Isolate filesystem ownership from the independent construction guard. + plan.constructedTransactionID = plan.TransactionID if _, err := Apply(context.Background(), root, plan); err == nil { t.Fatal("Apply() admitted forged created-directory ownership") } diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 79a6c25..901d6dc 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -197,8 +197,7 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.agent-integration.freshness", Summary: "Add integration check for explicit Codex or Claude repository paths; compare bounded current template bytes through confined read-only inspection without granting installation or host-activation authority."}, - {ChangeID: "proofkit.agent-integration.source", Summary: "Add integration source to generate one bounded portable skill body and descriptor-owned path, with an identity bound to consumed registered CLI invocation contracts rather than package version or a host-specific executable."}, + {ChangeID: "proofkit.repository-transaction.native-construction", Summary: "Keep re-admitted transaction reports non-executable for empty and nonempty payloads by binding Apply to native construction of the complete transaction identity. Preserve public JSON, valid native operations, and journal recovery."}, } var currentMigrationSteps = []string{} @@ -221,7 +220,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.10.0", + "# @research-engineering/agentic-proofkit 0.10.1", "", "## Breaking Contract Changes", "", @@ -271,7 +270,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.10.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.10.1", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -282,7 +281,7 @@ func currentExpectedReleaseNotes() string { "", "## Rollback", "", - "- Pin npm consumers to the previous admitted version 0.9.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.9.0`.", + "- Pin npm consumers to the previous admitted version 0.10.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.10.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n"