diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58f7995..516fd63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,15 @@ jobs: - name: Run all Go tests run: npm run go:test + - name: Verify deletion across filesystem boundaries + run: | + set -euo pipefail + root="$(mktemp -d "${RUNNER_TEMP}/proofkit-cross-filesystem.XXXXXX")" + mkdir "${root}/other" + sudo mount -t tmpfs -o "size=16m,mode=0700,uid=$(id -u),gid=$(id -g)" tmpfs "${root}/other" + trap 'sudo umount "${root}/other" && rmdir "${root}/other" "${root}"' EXIT + PROOFKIT_CROSS_FILESYSTEM_ROOT="${root}" go test ./internal/kernel/repositorytransaction -run '^TestDeletionRejectsDifferentFilesystemBeforeControlMutation$' -count=1 -v + - name: Run Go vet run: npm run go:vet diff --git a/ADOPTION.md b/ADOPTION.md index 2a22f38..3a3f3df 100644 --- a/ADOPTION.md +++ b/ADOPTION.md @@ -217,23 +217,46 @@ Resolve the approved installed launcher for each session. An absent or ambiguous binding needs an owner decision, not an install, network fallback, package-manager default, or persisted machine-local executable path. -For one manual export, first inspect the explicit repository root and every -component of the selected path. Stop on symlinks, aliases, unknown ownership, -local edits, or an existing target; do not overwrite it. The example below -assumes the owner has approved the npm launcher, inspected and prepared the -ordinary parent directories, and ensured no concurrent writer can replace -them. Replace the example absolute root with that inspected root. The shell, -not Proofkit, creates the file; noclobber protects an existing final file but -does not establish race-free parent traversal or transactional installation. +For managed installation, select the tool and root explicitly, inspect the +plan, then apply its exact transaction and desired-state identities. The +following example assumes a repository-approved, already installed npm +launcher; other approved carriers expose the same logical routes. Replace the +root and identity placeholders with the reviewed plan's values. ```bash -(set -o noclobber; npm exec --offline -- agentic-proofkit integration source --tool codex --format text > /absolute/inspected/repository/.agents/skills/agentic-proofkit/SKILL.md) +npm exec --offline -- agentic-proofkit integration plan --tool codex --operation install --repo-root /absolute/inspected/repository --format text +npm exec --offline -- agentic-proofkit integration apply --tool codex --operation install --repo-root /absolute/inspected/repository --expect-transaction --expect-desired-state npm exec --offline -- agentic-proofkit integration check --tool codex --repo-root /absolute/inspected/repository ``` -Check a failed export before treating its output as usable: shell redirection -may leave an empty or partial new file if generation or transport fails. -Resolve such a file manually under repository ownership; check never repairs it. +`REQ-PROOFKIT-WORKFLOW-019` owns managed file lifecycle. Use `--operation +update` or `--operation remove` with a fresh reviewed plan for those operations. +The fixed bootstrap and `proofkit/integrations/.v1.json` baseline share +one native transaction. Local byte or mode edits are conflicts, not overwrite +permission. An exact current manually exported bootstrap can be enrolled by +install; an unknown or stale unbaselined file requires an owner decision. +The baseline records exact cooperative before-state, not authenticated origin. +Removal deletes selected managed files only, leaving their directories and +adjacent instructions. It does not archive or replace instructions with an +empty file. Baseline-only removal can clean a valid orphan baseline. + +After interruption, use `integration recover --repo-root --transaction + --action `. It uses the existing native +journal, not the current bootstrap source. A completed recovery is historical +evidence; inspect current files separately. Desired-absence journals and new +identity-bound terminal receipts use schema v2 and require this or a later +supporting binary. Finishing recovery alone does not establish downgrade +compatibility; no automatic downgrade or control-state deletion is supported. +Present-only v1 journals and historical v1 receipts remain readable. A legacy +receipt does not bind its missing desired identity: replan before applying. +Repeated apply checks that the retained applied transaction binds the exact +current desired state under one native lock; pending work blocks replay. + +Plan and apply default to JSON. Ready plans and passed receipts exit 0; +classified conflicts, recovery, cleanup or durability outcomes exit 1 with a +report and empty stderr. Invocation and operational failures use stderr. +`--format text --color auto` colors labels only on a capable TTY without +`NO_COLOR`; the default is uncolored. Check never repairs files. The checker admits flags before I/O and reads only the selected fixed path through an application-write-free confined lease with bounded reobservation. Exit 0 and `current` mean exact generated-byte equality; exit 2 reports @@ -249,8 +272,7 @@ identity when consumed projections remain unchanged. Shared native source digests may conservatively invalidate freshness. This is materialization freshness, not proof of every transitive runtime behavior. -Phase5B managed install/update/remove remains open. Manual export is not an -installer, rollback protocol, or proof of host activation. Installed npm/Python +Managed file lifecycle is not proof of host activation. Installed npm/Python integration proof requires actual carrier execution; source-only tests do not discharge it. Native-host file discovery, body loading, and approved-launcher calls are separate observations requiring isolated sessions and absent/altered-file diff --git a/BACKLOG.md b/BACKLOG.md index c451043..a6dacec 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -48,7 +48,6 @@ records, generated release manifests, or the owning docs named above. | Status | ID | Scope | Completion condition | |---|---|---|---| -| NEXT | INTEGRATION-01 | Complete the explicitly selected tool's managed bootstrap lifecycle without treating generation/check as installation or host activation. | One existing repository transaction owner performs reviewed install/update/remove with exact prior-byte and desired-state preconditions, conflict preservation for local edits, real final absence on removal, and rollback/recovery/cancellation/cleanup witnesses; adjacent and global instructions remain unchanged. Isolated native-host tests separately establish file discovery, body loading, and approved-launcher invocation, or retain those precise non-claims. | | BLOCKED | SOURCE-CUTOVER-01 | Migrate self-hosted requirement sources only after one codec, the typed v2 model, nested structural contracts, and the complete evidence counterfeit corpus pass their gates. | The `REQ-PROOFKIT-QUALITY-010` execution-backed command-oracle closure and `SCHEMA-01` are complete; a digest-bound clause ledger proves representation-only equality or owner-reviewed semantic decomposition for every legacy requirement; all bindings/scenarios/contracts/context/diff/graph/browser owners cut over atomically; v1 admission and the losing codec are removed; and active-v1 inventory is zero. | | BLOCKED | SCHEMA-01 | Replace root-shape-only public contracts with one independent complete nested structural-contract owner. | A versioned schema owner covers nested fields, variants, cardinalities, bounds, enums, defaults, duplicate and unknown-field policy, and cross-field constraints; generated artifacts pass parity against an independently authored completeness manifest and mutant corpus without becoming semantic or policy authority. | | BLOCKED | SOURCE-PILOT-01 | Validate the selected source-v2 model and agent routing against heterogeneous external repositories without mutating them. | At least two independent repository classes complete no-push dual runs whose frozen inputs compare incumbent and candidate mapping, diagnostics, token cost, authoring accuracy, proof-route gaps, and rollback; unresolved parity or authority gaps keep incumbent owners active. | diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index 72db9d7..30a7a8c 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -41,7 +41,7 @@ owner boundaries. It is not a second command-family inventory. | Family | Main commands | Caller provides | Proofkit owns | Consumer owns | Output authority | |---|---|---|---|---|---| -| Agent integration generation/check | `integration source`, `integration check` | explicit `claude` or `codex` tool; explicit repository root for check only | one portable bounded bootstrap, exact consumed registered-contract identity, descriptor-owned path, and confined read-only generated-byte freshness | launcher admission, manual materialization, instruction ownership, host discovery/activation, permissions, native verification, and any future managed lifecycle | generated source or missing/current/stale/invalid freshness report; neither is installation or execution authority | +| Agent integrations | `integration source`, `integration check`, `integration plan`, `integration apply`, `integration recover` | explicit tool for source/check/plan/apply; root for filesystem operations; operation and both reviewed identities for apply; transaction/action for recovery | one bounded renderer, read-only freshness, canonical cooperative baseline, and managed file lifecycle through the native transaction owner | launcher admission, instruction ownership, host discovery/activation, permissions and native verification | generated source, freshness classification, reviewed transaction plan or historical transaction receipt; none proves host activation | | Project state navigation | `status`, `next` | explicit repository root | bounded transaction-first materialized-project inspection, normalized observation identity, deterministic project-state classification, and one non-executable next action; admitted in-bound records bind exact content digests, while unread out-of-bound records intentionally identify only their invalid class | repository policy, byte identity for unread out-of-bound records, witness execution, receipt trust/currentness/scope, merge, release, deployment, rollout, and production readiness | project-status report, next-action packet, or bounded text projection | | Agent workflow planning | `change 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`, `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 | @@ -132,7 +132,9 @@ Semantic context routes are `requirement-context-compose`, | State or goal | Next Proofkit route | Stop or escalation condition | |---|---|---| -| A repository owner requests a portable Proofkit bootstrap or its exact-byte freshness. | `integration source --tool ` generates JSON or exact file bytes with `--format text`; `integration check --tool --repo-root ` checks only the selected fixed path. Select `claude` or `codex` explicitly. | Source writes nothing; neither route accepts `--output` or install. Source exits 0/1 for success/error; check exits 0 for current, 2 for missing/stale/invalid, and 1 for invocation or operation error. Current proves only generated-byte equality. Use `ADOPTION.md` for inspected manual export; Phase5B installation/update/removal and host activation remain open. | +| A repository owner requests a portable Proofkit bootstrap or its exact-byte freshness. | `integration source --tool ` generates JSON or exact file bytes with `--format text`; `integration check --tool --repo-root ` checks only the selected fixed path. Select `claude` or `codex` explicitly. | Source writes nothing; neither route accepts `--output` or install. Source exits 0/1 for success/error; check exits 0 for current, 2 for missing/stale/invalid, and 1 for invocation or operation error. Current proves only generated-byte equality. Managed lifecycle is a separate explicit route; host activation remains an independent observation. | +| A repository owner approves managed bootstrap installation, update or removal. | `integration plan --tool --operation --repo-root `; inspect both identities, then `integration apply` with the same tool/operation/root and `--expect-transaction` plus `--expect-desired-state`. | The native transaction changes only the selected bootstrap and cooperative baseline; unknown content, local edits, stale identities and invalid baselines block mutation. Ready/passed exits 0; classified conflicts or recovery states exit 1 with a report. Text/color are opt-in. | +| A managed file transaction was interrupted. | `integration recover --repo-root --transaction --action `; select resume or rollback explicitly. | Recovery does not generate the current source or infer a tool. Its result is historical; inspect current files separately. Finish v2 desired-absence recovery before downgrading. | | The repository may already contain a materialized Proofkit project. | `status --repo-root ` for the full bounded classification or `next --repo-root ` for its single action projection. | Treat `verification_required` as a request to run repository-owned verification, never as completion or approval. Stop on blocked or recovery-required states; the packet does not execute its route or own policy. | | The agent does not know where to start. | `adopt plan --mode --repo-root `, where mode is `fresh`, `code-baseline`, or `audit-from-code` | Choose the trust intent explicitly. Treat the fixed-catalog inventory and tasks as a read-only candidate plan; stop before arbitrary source inspection, writing files, or making requirements authoritative. | | An agent needs a bounded, deterministic stage transition for an engineering change. | `change 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. | diff --git a/docs/specs/proofkit-agent-workflow/overview.md b/docs/specs/proofkit-agent-workflow/overview.md index 7bf1d55..6651f28 100644 --- a/docs/specs/proofkit-agent-workflow/overview.md +++ b/docs/specs/proofkit-agent-workflow/overview.md @@ -28,7 +28,7 @@ The public capability is deliberately small: bootstrap without reading repositories or writing files; `integration check --tool --repo-root ` compares only the selected descriptor-owned file through confined read-only inspection. - These are generation/check targets, not managed integration support. + Generation/check remain read-only; separate lifecycle routes manage files. The change planner and evidence-guidance cores are stateless pure projections. Project status reads only an explicit repository root, the conventional routing @@ -132,9 +132,15 @@ identity and a bounded freshness classification, never observed content, its digest, or caller root paths. Exit 0 means current, exit 2 means a classified missing/stale/invalid file, and exit 1 means an invocation or operation error. Source returns 0 on success and 1 on error. Neither accepts `--output` or an -install option. Manual export guidance is owned by `ADOPTION.md`. +install option. Managed lifecycle guidance is owned by `ADOPTION.md`. -Phase5B managed installation, update, and safe removal remains explicitly open. +`REQ-PROOFKIT-WORKFLOW-019` owns `integration plan`, `integration apply`, and +`integration recover`. A reviewed install/update/remove changes only the +selected bootstrap and its canonical cooperative baseline through the existing +repository transaction owner. Both expected identities are required for apply; +local edits or malformed baselines block mutation. Recovery uses historical +journal state without assuming the current tool renderer. Desired absence +selects journal v2, while present-only v1 identities and recovery remain stable. Host discovery, body loading, and approved-launcher invocation require separate isolated native-host observations; direct CLI execution cannot prove skill use. diff --git a/docs/specs/proofkit-agent-workflow/requirements.v1.json b/docs/specs/proofkit-agent-workflow/requirements.v1.json index b1f3e78..0c0503e 100644 --- a/docs/specs/proofkit-agent-workflow/requirements.v1.json +++ b/docs/specs/proofkit-agent-workflow/requirements.v1.json @@ -240,7 +240,7 @@ { "requirementId": "REQ-PROOFKIT-WORKFLOW-018", "ownerId": "proofkit.agent-workflow", - "invariant": "Exactly two no-input commands expose Phase5A: integration-source through integration source with built_in_package_catalog scope, and integration-check through integration check with explicit_filesystem_scan scope. Both require exactly one --tool from claude/codex, admit at most one --format from json/text with JSON default, reject input transport, surplus operands, color, carrier, output-file, and install options; check additionally requires exactly one --repo-root, which source rejects. Source JSON has exactly schemaVersion, kind, tool, targetPath, integrationId, content, contentDigest, capabilityDigest, metadataBytes, bodyBytes, and nonClaims; check JSON has exactly schemaVersion, kind, tool, targetPath, integrationId, expectedContentDigest, state, and nonClaims. Both schema versions are 1; kinds are proofkit.integration-source.v1 and proofkit.integration-check.v1. Source text is the exact generated content. Source exits 0 on success and 1 on error; check exits 0 only for current, 2 for missing/stale/invalid classified reports with empty stderr, and 1 for operation or invocation error. Cancellation observed before emission and serialization/cleanup failures produce no success report. Descriptor, dispatch, help, the agent-integrations source/check-only family, root-shape definitions and their hashes, CLI contracts, contract-map routes, exact native witnesses, generated metadata, and installed npm/Python carriers must close over the same bounded invocation/output relation without a second semantic owner; native source bindings include internal/command/agentintegration. Source-carrier independence and installed-carrier execution are separate obligations. Phase5B managed install/update/remove remains open and is not discharged by generation, check, manual export, or this contract closure.", + "invariant": "Exactly two no-input commands expose Phase5A: integration-source through integration source with built_in_package_catalog scope, and integration-check through integration check with explicit_filesystem_scan scope. Both require exactly one --tool from claude/codex, admit at most one --format from json/text with JSON default, reject input transport, surplus operands, color, carrier, output-file, and install options; check additionally requires exactly one --repo-root, which source rejects. Source JSON has exactly schemaVersion, kind, tool, targetPath, integrationId, content, contentDigest, capabilityDigest, metadataBytes, bodyBytes, and nonClaims; check JSON has exactly schemaVersion, kind, tool, targetPath, integrationId, expectedContentDigest, state, and nonClaims. Both schema versions are 1; kinds are proofkit.integration-source.v1 and proofkit.integration-check.v1. Source text is the exact generated content. Source exits 0 on success and 1 on error; check exits 0 only for current, 2 for missing/stale/invalid classified reports with empty stderr, and 1 for operation or invocation error. Cancellation observed before emission and serialization/cleanup failures produce no success report. Descriptor, dispatch, help, the agent-integrations family, root-shape definitions and their hashes, CLI contracts, contract-map routes, exact native witnesses, generated metadata, and installed npm/Python carriers must close over the same bounded invocation/output relation without a second semantic owner; native source bindings include internal/command/agentintegration. Source-carrier independence and installed-carrier execution are separate obligations. Managed install/update/remove is a separate lifecycle contract and is not discharged by generation, check, manual export, or this contract closure.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], @@ -254,6 +254,23 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.agent-workflow", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "ownerId": "proofkit.agent-workflow", + "invariant": "Explicit integration plan/apply/recover routes reuse the native repository transaction owner, not a second installer. Plan and apply require a selected claude/codex tool, repository root and install/update/remove operation; apply additionally binds both reviewed transaction and desired-state identities. Recognition uses only native captured before snapshots and bytes for the fixed bootstrap and proofkit/integrations/.v1.json; the baseline is bounded canonical compact JSON with one LF, exact tool/path/content digest/byte count/mode and no unknown fields. Bootstrap and baseline use mode 0644. Install creates an absent pair or enrolls exact current unbaselined bytes, but rejects unknown or stale unbaselined content and requires update for a matched older pair. Update requires a matched baseline; removal accepts a matched pair, a valid orphan baseline, or both absent. Invalid baselines, local edits, unsafe paths, stale reviewed identities and pending transactions never authorize overwrite. Both targets share one native transaction and deletion/recovery protocol; adjacent and global instructions are unchanged. Replay uses the native locked current-state replay operation. Recovery requires only root, transaction and resume/rollback action, without current source generation or tool assumptions. JSON is default; explicit text and capability-bound auto/never color preserve the same outcome. Flags, multiplicity, enumerated values and identity syntax are admitted before I/O. Ready plans and passed receipts exit 0; classified conflict, recovery, cleanup or durability reports exit 1 with empty stderr; invocation or operational errors exit 1 with non-disclosing stderr. Descriptor, help, family, machine contract, owner projections, whole-CLI tests and installed npm/Python carrier witnesses cover the same operation relation.", + "claimLevel": "blocking", + "riskClass": "critical", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-WORKFLOW-019"], + "nonClaims": [ + "A completed file lifecycle does not prove host discovery, instruction loading, approved-launcher invocation, permission grants, native verification, or production readiness.", + "Recovery reports a historical transaction, not current installed/removed state or post-return stability; cancellation after the final effect cannot retract a committed operation.", + "The baseline is cooperative bookkeeping, not authenticated origin, owner approval, or protection from coordinated same-user edits or rollback." + ], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.agent-workflow", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ], "nonClaims": [ diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index aec7b6e..7ce8bcc 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. 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.", + "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. Desired absence has no mode or payload, never creates parent directories, and selects snapshot/action journal schema v2 even for unchanged absence; present-only v1 plan/journal bytes and identities remain unchanged. Delete requires exact before-image comparison, same-filesystem rollback staging, unlink, parent synchronization and observed absence; rollback must never overwrite a replacement created after deletion. Staging retains only existing changed before/after snapshots. Current-state replay requires a native unchanged plan, exact desired state, absence of pending work and the retained applied receipt binding the same desired-state identity under one existing native lock; historical terminal inspection alone cannot prove current state. New terminal receipts use schema v2 and bind desiredStateId. Legacy v1 receipts remain readable and recoverable without invented identity; acknowledgement retries require a newly reviewed plan. Interrupted terminal archival preserves the validated historical result; the current recovery action changes only the invocation projection. Retained v2 receipts require a supporting binary even after recovery completes. 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/adoption_materialization_command.go b/internal/app/adoption_materialization_command.go index 4f113ba..1f3fb65 100644 --- a/internal/app/adoption_materialization_command.go +++ b/internal/app/adoption_materialization_command.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "strings" "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" @@ -84,42 +83,13 @@ func writeAdoptionMaterializationText(plain string, exitCode int, err error, opt if err != nil { return writeText("", 1, err, stdout, stderr) } - output, err := renderTerminalText(adoptionMaterializationTerminalText(plain), options.color, capabilities) + output, err := renderTerminalText(labeledTerminalText(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{} diff --git a/internal/app/agent_integration_lifecycle_command.go b/internal/app/agent_integration_lifecycle_command.go new file mode 100644 index 0000000..631d7a2 --- /dev/null +++ b/internal/app/agent_integration_lifecycle_command.go @@ -0,0 +1,94 @@ +package app + +import ( + "context" + "fmt" + "io" + "slices" + + "github.com/research-engineering/agentic-proofkit/internal/command/agentintegration" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +type integrationLifecycleArgs struct { + tool, operation, root, format, color string + expectedTransaction, expectedDesired string + transaction, action string +} + +func runAgentIntegrationLifecycle(ctx context.Context, command string, args []string, stdout, stderr io.Writer, presentation PresentationCapabilities) int { + options, err := parseIntegrationLifecycleArgs(command, args) + if err != nil { + return writeJSON(nil, 1, err, stdout, stderr) + } + var value any + var plain string + var exitCode int + if command == "integration-recover" { + receipt, failure := agentintegration.RecoverLifecycle(ctx, options.root, options.transaction, options.action) + value, plain, exitCode, err = receipt.JSONValue(), receipt.Text(), receipt.ExitCode(), failure + } else { + capabilities, failure := integrationCapabilities(commandDescriptorByName, generatedCommandContractMetadataByName) + if failure != nil { + return writeJSON(nil, 1, failure, stdout, stderr) + } + document, failure := agentintegration.Source(options.tool, capabilities) + if failure != nil { + return writeJSON(nil, 1, failure, stdout, stderr) + } + if command == "integration-plan" { + plan, failure := agentintegration.PlanLifecycle(ctx, options.root, document, options.operation) + value, plain, exitCode, err = plan.JSONValue(), plan.Text(), plan.ExitCode(), failure + } else { + receipt, failure := agentintegration.ApplyLifecycle(ctx, options.root, document, options.operation, options.expectedTransaction, options.expectedDesired) + value, plain, exitCode, err = receipt.JSONValue(), receipt.Text(), receipt.ExitCode(), failure + } + } + if err != nil || options.format == "json" { + return writeJSON(value, exitCode, err, stdout, stderr) + } + text, err := renderTerminalText(labeledTerminalText(plain), options.color, presentation) + return writeText(text, exitCode, err, stdout, stderr) +} + +func parseIntegrationLifecycleArgs(command string, args []string) (integrationLifecycleArgs, error) { + descriptor, exists := commandDescriptorByName[command] + if !exists || descriptor.runner != commandRunnerAgentIntegrationLifecycle { + return integrationLifecycleArgs{}, fmt.Errorf("unsupported integration lifecycle command") + } + values := map[string]string{"--format": "json", "--color": "never"} + seen := map[string]bool{} + for index := 0; index < len(args); index += 2 { + flag := args[index] + if !slices.Contains(descriptor.allowedFlags, flag) { + return integrationLifecycleArgs{}, fmt.Errorf("unsupported integration lifecycle argument") + } + if seen[flag] || index+1 >= len(args) || args[index+1] == "" { + return integrationLifecycleArgs{}, fmt.Errorf("integration lifecycle arguments require one non-empty value per flag") + } + value := args[index+1] + if choices := descriptor.flagValueChoices[flag]; len(choices) > 0 && !slices.Contains(choices, value) { + return integrationLifecycleArgs{}, fmt.Errorf("integration lifecycle flag value is unsupported") + } + if flag == "--expect-transaction" || flag == "--expect-desired-state" || flag == "--transaction" { + if _, err := admit.SHA256Ref(value, "integration lifecycle identity"); err != nil { + return integrationLifecycleArgs{}, err + } + } + seen[flag], values[flag] = true, value + } + for _, flag := range descriptor.requiredFlags { + if !seen[flag] { + return integrationLifecycleArgs{}, fmt.Errorf("integration lifecycle requires %s", flag) + } + } + if seen["--color"] && values["--format"] != "text" { + return integrationLifecycleArgs{}, fmt.Errorf("--color is valid only with --format text") + } + return integrationLifecycleArgs{ + tool: values["--tool"], operation: values["--operation"], root: values["--repo-root"], + format: values["--format"], color: values["--color"], + expectedTransaction: values["--expect-transaction"], expectedDesired: values["--expect-desired-state"], + transaction: values["--transaction"], action: values["--action"], + }, nil +} diff --git a/internal/app/agent_integration_lifecycle_command_test.go b/internal/app/agent_integration_lifecycle_command_test.go new file mode 100644 index 0000000..31975a4 --- /dev/null +++ b/internal/app/agent_integration_lifecycle_command_test.go @@ -0,0 +1,232 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/agentintegration" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func integrationLifecycleCLI(t *testing.T, args []string, wantCode int) map[string]any { + t.Helper() + code, output, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != wantCode || diagnostic != "" || strings.Contains(output, "\x1b") { + t.Fatalf("lifecycle exit=%d want=%d diagnostic=%q", code, wantCode, diagnostic) + } + return decodeCLIJSON(t, output).(map[string]any) +} + +func integrationLifecyclePlanArgs(root, tool, operation string) []string { + return []string{"integration", "plan", "--tool", tool, "--operation", operation, "--repo-root", root} +} + +func integrationLifecycleApplyArgs(root, tool, operation string, plan map[string]any) []string { + transaction := plan["transaction"].(map[string]any) + return []string{"integration", "apply", "--tool", tool, "--operation", operation, "--repo-root", root, "--expect-transaction", transaction["transactionId"].(string), "--expect-desired-state", transaction["desiredStateId"].(string)} +} + +func assertIntegrationLifecycleRoot(t *testing.T, value map[string]any, plan bool) { + t.Helper() + fields := []string{"expectedDesiredStateId", "expectedTransactionId", "failureClass", "kind", "nonClaims", "operation", "schemaVersion", "state", "tool", "transactionResult"} + wantKind := "proofkit.integration-receipt.v1" + if plan { + fields = []string{"failureClass", "kind", "nonClaims", "operation", "recoveryTransactionId", "schemaVersion", "state", "tool", "transaction"} + wantKind = "proofkit.integration-plan.v1" + } + assertExactObjectKeys(t, value, fields, "integration lifecycle output") + if value["kind"] != wantKind || len(value["nonClaims"].([]any)) != 3 { + t.Fatal("lifecycle output identity or non-claims changed") + } + if plan && value["transaction"] != nil { + if _, err := repositorytransaction.AdmitPlanOutput(value["transaction"]); err != nil { + t.Fatal(err) + } + } else if !plan && value["transactionResult"] != nil { + if _, err := repositorytransaction.AdmitResultOutput(value["transactionResult"]); err != nil { + t.Fatal(err) + } + } +} + +func TestIntegrationPlanCLI(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.100499585455180558184680186149455671295538897549556316778137583216002856749965") + for _, tool := range agentintegration.Tools() { + root := t.TempDir() + for _, operation := range []string{"install", "update", "remove"} { + code := 0 + if operation == "update" { + code = 1 + } + value := integrationLifecycleCLI(t, integrationLifecyclePlanArgs(root, tool, operation), code) + assertIntegrationLifecycleRoot(t, value, true) + if value["tool"] != tool || value["operation"] != operation { + t.Fatal("plan routing changed selected tool or operation") + } + if operation == "update" { + if value["state"] != "blocked" || value["failureClass"] != "not_installed" || value["transaction"] != nil { + t.Fatal("update of absent installation was accepted") + } + continue + } + transaction := value["transaction"].(map[string]any) + for _, raw := range transaction["operations"].([]any) { + op := raw.(map[string]any) + want := "create" + if operation == "remove" { + want = "unchanged" + } + if op["action"] != want { + t.Fatal("plan action changed") + } + } + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatal("planning wrote repository entries") + } + } +} + +func TestIntegrationApplyCLI(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.074646976815602003213124531518453340149790461050955348292553777176771465626278") + for _, tool := range agentintegration.Tools() { + t.Run(tool, func(t *testing.T) { + root := t.TempDir() + document := integrationDocument(t, tool) + path := filepath.Join(root, document.JSONValue()["targetPath"].(string)) + for _, operation := range []string{"install", "update", "remove"} { + if operation == "update" { + capabilities, err := integrationCapabilities(commandDescriptorByName, generatedCommandContractMetadataByName) + if err != nil { + t.Fatal(err) + } + capabilities[0].ContractDigest = "sha256:" + strings.Repeat("1", 64) + prior, err := agentintegration.Source(tool, capabilities) + if err != nil || prior.Content() == document.Content() { + t.Fatal("fixture needs a distinct admitted prior source") + } + seed, err := agentintegration.PlanLifecycle(t.Context(), root, prior, agentintegration.OperationUpdate) + if err != nil { + t.Fatal(err) + } + transaction, err := repositorytransaction.AdmitPlanOutput(seed.JSONValue()["transaction"]) + if err != nil { + t.Fatal(err) + } + if receipt, err := agentintegration.ApplyLifecycle(t.Context(), root, prior, agentintegration.OperationUpdate, transaction.TransactionID, transaction.DesiredStateID); err != nil || receipt.ExitCode() != 0 { + t.Fatalf("seed prior: %v", err) + } + } + plan := integrationLifecycleCLI(t, integrationLifecyclePlanArgs(root, tool, operation), 0) + args := integrationLifecycleApplyArgs(root, tool, operation, plan) + receipt := integrationLifecycleCLI(t, args, 0) + assertIntegrationLifecycleRoot(t, receipt, false) + if receipt["state"] != "passed" || receipt["operation"] != operation || receipt["tool"] != tool { + t.Fatal("apply emitted a false lifecycle outcome") + } + content, err := os.ReadFile(path) + if operation == "remove" { + if !os.IsNotExist(err) { + t.Fatal("remove retained instructions") + } + } else if err != nil || string(content) != document.Content() { + t.Fatal("apply did not install exact source bytes") + } + replay := integrationLifecycleCLI(t, args, 0) + if replay["transactionResult"].(map[string]any)["state"] != "already_satisfied" { + t.Fatal("repeat apply lost idempotence") + } + } + plan := integrationLifecycleCLI(t, integrationLifecyclePlanArgs(root, tool, "install"), 0) + if err := os.WriteFile(path, []byte("owner edit\n"), 0o644); err != nil { + t.Fatal(err) + } + blocked := integrationLifecycleCLI(t, integrationLifecycleApplyArgs(root, tool, "install", plan), 1) + assertIntegrationLifecycleRoot(t, blocked, false) + if blocked["state"] != "blocked" || blocked["failureClass"] != "unrecognized_bootstrap" { + t.Fatal("local edit was overwritten") + } + content, err := os.ReadFile(path) + if err != nil || string(content) != "owner edit\n" { + t.Fatal("conflict changed owner bytes") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + for _, format := range []string{"json", "text"} { + args := append(integrationLifecycleApplyArgs(root, tool, "install", plan), "--format", format) + code, output, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || !strings.Contains(diagnostic, "regular non-symlink") || strings.Contains(diagnostic, root) { + t.Fatalf("operational stream: exit=%d output=%q diagnostic=%q", code, output, diagnostic) + } + } + }) + } +} + +func TestIntegrationRecoverCLI(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.048616201957844566215569600371534815430881597384694966035981327906285948266263") + t.Run("pending observation streams", testIntegrationRecoveryObservationStreams) + root := t.TempDir() + plan := integrationLifecycleCLI(t, integrationLifecyclePlanArgs(root, "codex", "install"), 0) + integrationLifecycleCLI(t, integrationLifecycleApplyArgs(root, "codex", "install", plan), 0) + tx := plan["transaction"].(map[string]any)["transactionId"].(string) + args := []string{"integration", "recover", "--repo-root", root, "--transaction", tx, "--action", "resume"} + receipt := integrationLifecycleCLI(t, args, 0) + assertIntegrationLifecycleRoot(t, receipt, false) + if receipt["state"] != "passed" || receipt["tool"] != nil || receipt["expectedDesiredStateId"] != nil || receipt["operation"] != "recover" { + t.Fatal("generic recovery assumed current source or tool") + } + args[len(args)-1] = "rollback" + blocked := integrationLifecycleCLI(t, args, 1) + if blocked["state"] == "passed" { + t.Fatal("committed transaction was rolled back by historical recovery") + } +} + +func TestIntegrationLifecycleInvocationAndPresentation(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + id := "sha256:" + strings.Repeat("0", 64) + for _, args := range [][]string{ + {"integration", "plan", "--repo-root", missing, "--tool", "codex"}, + {"integration", "plan", "--repo-root", missing, "--tool", "invalid", "--operation", "install"}, + {"integration", "plan", "--repo-root", missing, "--tool", "codex", "--operation", "install", "--tool", "claude"}, + {"integration", "plan", "--repo-root", missing, "--tool", "codex", "--operation", "install", "--input", "-"}, + {"integration", "plan", "--repo-root", missing, "--tool", "codex", "--operation", "install", "--color", "auto"}, + {"integration", "apply", "--repo-root", missing, "--tool", "codex", "--operation", "install", "--expect-transaction", "invalid", "--expect-desired-state", id}, + {"integration", "recover", "--repo-root", missing, "--transaction", id, "--action", "force"}, + {"integration", "recover", "--repo-root", missing, "--transaction", id, "--action", "resume", "--tool", "codex"}, + } { + code, output, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || diagnostic == "" || strings.Contains(diagnostic, "root") && !strings.Contains(diagnostic, "--repo-root") { + t.Fatalf("invocation admission did not dominate I/O: %q", diagnostic) + } + } + root := t.TempDir() + args := append(integrationLifecyclePlanArgs(root, "codex", "install"), "--format", "text") + code, plain, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{StdoutIsTTY: true}) + if code != 0 || diagnostic != "" || strings.Contains(plain, "\x1b") || !strings.Contains(plain, "Integration plan: ready") { + t.Fatal("text default is not plain or ready") + } + args = append(args, "--color", "auto") + for _, capability := range []PresentationCapabilities{{StdoutIsTTY: true}, {StdoutIsTTY: true, NoColorPresent: true}, {}} { + code, text, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, capability) + color := capability.StdoutIsTTY && !capability.NoColorPresent + if code != 0 || diagnostic != "" || strings.Contains(text, "\x1b") != color || !color && text != plain { + t.Fatal("terminal capability changed text semantics") + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := agentintegration.PlanLifecycle(ctx, root, integrationDocument(t, "codex"), "install"); err == nil { + t.Fatal("cancelled plan succeeded") + } +} diff --git a/internal/app/agent_integration_lifecycle_unix_test.go b/internal/app/agent_integration_lifecycle_unix_test.go new file mode 100644 index 0000000..0430a12 --- /dev/null +++ b/internal/app/agent_integration_lifecycle_unix_test.go @@ -0,0 +1,34 @@ +//go:build darwin || linux + +package app + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +func TestIntegrationRecoverBusyCLIIsClassified(t *testing.T) { + root := t.TempDir() + plan := integrationLifecycleCLI(t, integrationLifecyclePlanArgs(root, "codex", "install"), 0) + integrationLifecycleCLI(t, integrationLifecycleApplyArgs(root, "codex", "install", plan), 0) + tx := plan["transaction"].(map[string]any)["transactionId"].(string) + directory, err := os.Open(filepath.Join(root, repositorytransaction.ControlDirectory)) + if err != nil { + t.Fatal(err) + } + defer directory.Close() + if err := unix.Flock(int(directory.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + t.Fatal(err) + } + defer unix.Flock(int(directory.Fd()), unix.LOCK_UN) + receipt := integrationLifecycleCLI(t, []string{"integration", "recover", "--repo-root", root, "--transaction", tx, "--action", "resume"}, 1) + assertIntegrationLifecycleRoot(t, receipt, false) + if receipt["state"] != "blocked" || receipt["failureClass"] != "transaction_busy" || receipt["tool"] != nil || receipt["transactionResult"] != nil { + t.Fatal("busy recovery lost the classified no-effect result") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 8add6f8..c083a52 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -142,6 +142,8 @@ func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io return runAgentWorkflowCommand(args[0], args[1:], stdin, stdout, stderr, capabilities) case commandRunnerAgentIntegration: return runAgentIntegration(ctx, args[0], args[1:], stdout, stderr) + case commandRunnerAgentIntegrationLifecycle: + return runAgentIntegrationLifecycle(ctx, args[0], args[1:], stdout, stderr, capabilities) case commandRunnerAgentRoute: return runAgentRoute(args[1:], stdin, stdout, stderr, renderer) case commandRunnerContractEnvelope: diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index b25e16c..234b820 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "cc1fc5a55e00ea13e92d82edc3a3e3115cd9e69a00d08618fe2b1cefd25216d2" + cliContractPublicABISHA256 = "527ffbc7e261d4ac0f91cc81db0a390ec5aa2c4f18593671bbc1f92f3ed83c70" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -1526,6 +1526,9 @@ func TestDescriptorFlagConstraintsAreRenderedTruthfully(t *testing.T) { "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)", "integration-check": "agentic-proofkit integration check [--format ] --repo-root --tool ", + "integration-apply": "agentic-proofkit integration apply [--color ] --expect-desired-state --expect-transaction [--format ] --operation --repo-root --tool ", + "integration-plan": "agentic-proofkit integration plan [--color ] [--format ] --operation --repo-root --tool ", + "integration-recover": "agentic-proofkit integration recover --action [--color ] [--format ] --repo-root --transaction ", "integration-source": "agentic-proofkit integration source [--format ] --tool ", "json-report-cli-adapter-source": "agentic-proofkit json-report-cli-adapter-source [--format ] --language ", "next": "agentic-proofkit next [--color ] [--format ] --repo-root ", diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index 5a9abc3..e18f83b 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 = "846a642fbe1bfb9a59502c7018788667b5b6bd711e5fc45534f1846bc440e344" +const commandContractSourceSHA256 = "bcbfee8a2bc84bf17a433e8cc05e34c9df4460d68b323177e579b0a8b8ff34fe" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,15 +12,15 @@ 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: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-materialize-apply": {InputContractSHA256: "sha256:c973f13aea5b071bbf32fa31332fef73c6f1fda2821add4304f128e7285ccf77", 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:676fb85d7cb8669c9b636ba94aa4af90f48c6e5bbfd3070b96ca77604719ca74", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:309f594e0642a52fe38137bb31d1c3af20c04cda648881be11473a5fc56896b6", 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:9bfa8f75d87e1c5b5a0c275b412e7dcc81c80d88dc58c2080d9c891cb49a6bfa", 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:d1f75b77ab996921ce2e94380a65319f7f3db6e489bf3de0da8fe6fd0976a0d4", 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:746c36d030687077a41e91ea5391e1d051a0fcb9aebe3ed04001624191edfc72", 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:2911a7f4351acf9449580a86d4d91e7c363776675432d7e70fdf289500823cf0", 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"}}, @@ -38,8 +38,11 @@ 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: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"}}, + "integration-apply": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:042023fbed99bb8b55661b2c5b25979202d8445a0fefc97568400754bbacd025", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--operation": []string{"install", "remove", "update"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "apply"}}, + "integration-check": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:98c693f6d4dfa644fcba0ab2ff7da6bf7791f5b2ad197516a6d3846cbf1cd301", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "check"}}, + "integration-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:dd153473f15fa590c2c948b89f7c97c1a3b7d12efc851b0f2276a28f62b3d515", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--operation": []string{"install", "remove", "update"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "plan"}}, + "integration-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:64dc335ebfe4e7ae368e6432042bec7ae40a3cc8c9261fbdaa8ef60401130d0d", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"integration", "recover"}}, + "integration-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:6b1308edeb5f4edf033e0d696d130ee5ba14232f156e2ae9a1f0f3b0d0d420a4", 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"}}, "migration-plan": {InputContractSHA256: "sha256:58a62759a634101ce2ca9218184175134bbe5633328e1b23797b94c19fc9b11a", InputSchemaSummary: []string{"schemaVersion=1", "migrationId", "sourceProofOwners[]", "targetProofkitRefs[]", "parityEvidenceRefs[]", "retainedOwners[]", "retirementCandidates[]", "followUpCommands[]", "nonClaims[]", "root-shape-only definition proofkit.migration-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f14f0381e9dc241357c346315b95b03ef5b23f1d1bbc3b00f111fbe1515ed3ff", FlagChoices: map[string][]string{}, RouteTokens: []string{"migration-plan"}}, @@ -47,7 +50,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "next": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:92f6d3dc427a795ec97112e6c3ce55ebd4ab670e4323b4faa8215310a8492747", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"next"}}, "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:892052fb91837cb5778beca131b54515973432e6f6a4ee2e201cb6453c8972c5", 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:63563d18f12d10c1f0cea5766f74d0c76cc15f78579e0a504830514cdf703fb2", 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"}}, @@ -86,7 +89,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:915468675e98a165220ad4bf693462526534d53e718556c6f2066b3c1e934ea8", 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:ebe612f409f9a1b9516ce14b0368f812db3f5e8cd759bc15b02936d5d80f8866", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, + "self-check": {InputContractSHA256: "sha256:537fcf6837cac6841e58cb7aeddec16dd5079c2ac756959d74cf64b7797981db", 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:cb2ce0b159ed3923e65f1f8a92eaee9ef8def422b9b6ed5cb1814214ed7b9f68", 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/command_coverage_routes.go b/internal/app/command_coverage_routes.go index 5574c3c..885d227 100644 --- a/internal/app/command_coverage_routes.go +++ b/internal/app/command_coverage_routes.go @@ -56,6 +56,9 @@ var requiredInputAdmissionRoute = commandCoverageRoute{ } var commandCoverageRoutes = map[string][]commandCoverageRoute{ + "integration-apply": {directCLIRoute("internal/app/agent_integration_lifecycle_command_test.go", "TestIntegrationApplyCLI", semanticRouteProof("agent_integration.apply_whole_cli"), "Integration apply must bind both reviewed identities, preserve local edits, install exact generated bytes, replay safely and remove only selected managed targets.")}, + "integration-plan": {directCLIRoute("internal/app/agent_integration_lifecycle_command_test.go", "TestIntegrationPlanCLI", semanticRouteProof("agent_integration.plan_whole_cli"), "Integration plan must classify the selected operation and pair state without mutation or claiming an absent installation can be updated.")}, + "integration-recover": {directCLIRoute("internal/app/agent_integration_lifecycle_command_test.go", "TestIntegrationRecoverCLI", semanticRouteProof("agent_integration.recover_whole_cli"), "Integration recovery must preserve the historical native transaction outcome without assuming current tool source or permitting rollback of a committed transaction.")}, "integration-source": {directCLIRoute("internal/app/agent_integration_command_test.go", "TestIntegrationSourceCLI", semanticRouteProof("agent_integration.source_whole_cli"), "Integration source must emit exact bounded portable bytes through JSON and text without reading caller input or accepting implicit tool selection.")}, "integration-check": {directCLIRoute("internal/app/agent_integration_command_test.go", "TestIntegrationCheckCLI", semanticRouteProof("agent_integration.check_whole_cli"), "Integration check must classify actual fixed-path bytes without writing files or treating missing, stale, or invalid content as current.")}, "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.")}, diff --git a/internal/app/command_coverage_test.go b/internal/app/command_coverage_test.go index 8c3a713..ea39538 100644 --- a/internal/app/command_coverage_test.go +++ b/internal/app/command_coverage_test.go @@ -522,7 +522,7 @@ func TestNoInputCommandDescriptorsHaveRuntimeSmoke(t *testing.T) { func noInputRuntimeSmokeArgs(t *testing.T, descriptor commandDescriptor) ([]string, bool) { t.Helper() switch descriptor.name { - case "adopt-materialize-recover": + case "adopt-materialize-recover", "integration-recover": return append(cloneStrings(descriptor.routeTokens), "--help"), false case "adopt-plan": return append(cloneStrings(descriptor.routeTokens), "--mode", "fresh", "--repo-root", t.TempDir()), true @@ -530,6 +530,12 @@ func noInputRuntimeSmokeArgs(t *testing.T, descriptor commandDescriptor) ([]stri return []string{"help"}, false case "integration-source": return []string{"integration", "source", "--tool", "codex"}, true + case "integration-plan": + return integrationLifecyclePlanArgs(t.TempDir(), "codex", "install"), true + case "integration-apply": + root := t.TempDir() + plan := integrationLifecycleCLI(t, integrationLifecyclePlanArgs(root, "codex", "install"), 0) + return integrationLifecycleApplyArgs(root, "codex", "install", plan), true case "integration-check": root := t.TempDir() document := integrationDocument(t, "codex") diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index 901ba1f..c63b645 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -28,6 +28,7 @@ const ( commandRunnerAgentWorkflow commandRunner = "agent_workflow" commandRunnerAgentRoute commandRunner = "agent_route" commandRunnerAgentIntegration commandRunner = "agent_integration" + commandRunnerAgentIntegrationLifecycle commandRunner = "agent_integration_lifecycle" commandRunnerConformanceProfile commandRunner = "conformance_profile" commandRunnerContractEnvelope commandRunner = "contract_envelope" commandRunnerGradualAdoptionBootstrap commandRunner = "gradual_adoption_bootstrap" @@ -123,7 +124,10 @@ var commandDescriptors = []commandDescriptor{ command("gradual-adoption-guidance", commandInputRequired, flags("--agent-envelope", "--checked-scope", "--contract-envelope", "--guidance-mode", "--input", "--input-pointer", "--touched-rule-id"), modes("json"), ownerDirs("gradualadoption"), withRunner(commandRunnerGradualAdoptionGuidance), withAgentEnvelope(), withContractEnvelope()), command("help", commandInputNone, flags("--help", "-h"), modes("text"), ownerDirs("help"), withRunner(commandRunnerHelp), withSemanticAppTests("TestHelpCommandContractForms")), command("impact", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("impact")), + command("integration-apply", commandInputNone, flags("--color", "--expect-desired-state", "--expect-transaction", "--format", "--operation", "--repo-root", "--tool"), modes("json", "text"), ownerDirs("agentintegration"), withRunner(commandRunnerAgentIntegrationLifecycle), withSemanticAppTests("TestIntegrationApplyCLI"), withScopeClass(commandScopeExplicitFileSystemMutation), withRequiredFlags("--expect-desired-state", "--expect-transaction", "--operation", "--repo-root", "--tool"), withFlagChoices("--color", "auto", "never"), withFlagChoices("--format", "json", "text"), withFlagChoices("--operation", "install", "remove", "update"), withFlagChoices("--tool", agentintegration.Tools()...), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--color", "--expect-desired-state", "--expect-transaction", "--operation", "--repo-root", "--tool")), command("integration-check", commandInputNone, flags("--format", "--repo-root", "--tool"), modes("json", "text"), ownerDirs("agentintegration"), withRunner(commandRunnerAgentIntegration), withSemanticAppTests("TestIntegrationCheckCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root", "--tool"), withFlagChoices("--format", "json", "text"), withFlagChoices("--tool", agentintegration.Tools()...), withSingleOccurrenceFlags("--repo-root", "--tool")), + command("integration-plan", commandInputNone, flags("--color", "--format", "--operation", "--repo-root", "--tool"), modes("json", "text"), ownerDirs("agentintegration"), withRunner(commandRunnerAgentIntegrationLifecycle), withSemanticAppTests("TestIntegrationPlanCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--operation", "--repo-root", "--tool"), withFlagChoices("--color", "auto", "never"), withFlagChoices("--format", "json", "text"), withFlagChoices("--operation", "install", "remove", "update"), withFlagChoices("--tool", agentintegration.Tools()...), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--color", "--operation", "--repo-root", "--tool")), + command("integration-recover", commandInputNone, flags("--action", "--color", "--format", "--repo-root", "--transaction"), modes("json", "text"), ownerDirs("agentintegration"), withRunner(commandRunnerAgentIntegrationLifecycle), withSemanticAppTests("TestIntegrationRecoverCLI"), withScopeClass(commandScopeExplicitFileSystemMutation), withRequiredFlags("--action", "--repo-root", "--transaction"), withFlagChoices("--action", "resume", "rollback"), withFlagChoices("--color", "auto", "never"), withFlagChoices("--format", "json", "text"), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--action", "--color", "--repo-root", "--transaction")), command("integration-source", commandInputNone, flags("--format", "--tool"), modes("json", "text"), ownerDirs("agentintegration"), withRunner(commandRunnerAgentIntegration), withSemanticAppTests("TestIntegrationSourceCLI"), withRequiredFlags("--tool"), withFlagChoices("--format", "json", "text"), withFlagChoices("--tool", agentintegration.Tools()...), withSingleOccurrenceFlags("--tool")), command("json-report-cli-adapter-source", commandInputNone, flags("--format", "--language"), modes("json"), ownerDirs("jsonreportcliadaptersource"), withRunner(commandRunnerJSONReportCLIAdapterSource), withRequiredFlags("--language")), command("migration-parity-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("migrationparityadmission")), @@ -189,6 +193,7 @@ var commandDescriptors = []commandDescriptor{ var knownCommandRunners = map[commandRunner]struct{}{ commandRunnerAgentIntegration: {}, + commandRunnerAgentIntegrationLifecycle: {}, commandRunnerGenericInput: {}, commandRunnerAdoptionFrontDoor: {}, commandRunnerAdoptionMaterialization: {}, diff --git a/internal/app/command_family_catalog_generated.go b/internal/app/command_family_catalog_generated.go index 22affea..693e2fc 100644 --- a/internal/app/command_family_catalog_generated.go +++ b/internal/app/command_family_catalog_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandfamilygen; DO NOT EDIT. package app -const commandFamilyCatalogSourceSHA256 = "f725450743d3d270bce9adae34c23c0468415182ff9a6fc87ba51e845d4d65e0" +const commandFamilyCatalogSourceSHA256 = "328dab95301ee37df2cf4bb4aa45e49ec78713403bdf3f655853d69813fdbbd9" func generatedCommandFamilyCatalog() commandFamilyCatalog { return commandFamilyCatalog{ @@ -9,7 +9,7 @@ func generatedCommandFamilyCatalog() commandFamilyCatalog { 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-integrations", Label: "Agent integrations", Purpose: "Generate portable bootstrap source and check generated-byte freshness without installation or host activation.", Commands: []string{"integration-check", "integration-source"}}, + {ID: "agent-integrations", Label: "Agent integrations", Purpose: "Generate and check portable bootstrap source, then explicitly plan, apply or recover its managed file lifecycle without claiming native host activation.", Commands: []string{"integration-apply", "integration-check", "integration-plan", "integration-recover", "integration-source"}}, {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/integration_recovery_observation_test.go b/internal/app/integration_recovery_observation_test.go new file mode 100644 index 0000000..482fa1c --- /dev/null +++ b/internal/app/integration_recovery_observation_test.go @@ -0,0 +1,178 @@ +package app + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func testIntegrationRecoveryObservationStreams(t *testing.T) { + for _, phase := range []string{"preparing-temp", "preparing", "ready", "committed", "rolled-back"} { + for _, mutation := range []string{"none", "content", "directory", "parent"} { + for _, command := range []struct { + name string + route []string + }{ + {"integration", []string{"integration", "recover"}}, + {"adoption", []string{"adopt", "materialize", "recover"}}, + } { + for _, format := range []string{"json", "text"} { + t.Run(phase+"/"+mutation+"/"+command.name+"/"+format, func(t *testing.T) { + root, transaction, action := recoveryCLIFixture(t, phase) + target := filepath.Join(root, "source/item") + if mutation != "none" { + if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + } + switch mutation { + case "content": + if err := os.WriteFile(target, []byte("foreign caller content"), 0o600); err != nil { + t.Fatal(err) + } + case "directory": + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + case "parent": + if err := os.Remove(filepath.Dir(target)); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Dir(target), []byte("foreign parent"), 0o600); err != nil { + t.Fatal(err) + } + } + before := recoveryCLITree(t, root) + args := append(command.route, "--repo-root", root, "--transaction", transaction, "--action", action, "--format", format) + code, stdout, stderr := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if mutation == "directory" || mutation == "parent" { + if code != 1 || stdout != "" || !strings.Contains(stderr, "repository transaction") { + t.Fatalf("operational recovery error became a packet: code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + } else { + wantCode := 0 + if mutation == "content" { + wantCode = 1 + } + if code != wantCode || stdout == "" || stderr != "" { + t.Fatalf("classified recovery stream changed: code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + if format == "json" { + value := decodeCLIJSON(t, stdout).(map[string]any) + want := "passed" + if mutation == "content" { + want = "recovery_required" + } + if value["state"] != want { + t.Fatal("recovery fixture did not reach its intended state") + } + } + } + if mutation != "none" && !reflect.DeepEqual(before, recoveryCLITree(t, root)) { + t.Fatal("rejected recovery mutated target or control state") + } + for _, forbidden := range []string{root, "foreign caller content", "foreign parent"} { + if strings.Contains(stdout+stderr, forbidden) { + t.Fatal("recovery disclosed caller path or content") + } + } + }) + } + } + } + } +} + +func recoveryCLIFixture(t *testing.T, phase string) (string, string, string) { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "source"), 0o700); err != nil { + t.Fatal(err) + } + before := []byte("original") + if err := os.WriteFile(filepath.Join(root, "source/item"), before, 0o600); err != nil { + t.Fatal(err) + } + plan, err := repositorytransaction.BuildPlan(context.Background(), root, []repositorytransaction.Target{{Path: "source/item", Absent: true}}) + if err != nil { + t.Fatal(err) + } + // Author a pending journal independently of the execution path. Every phase + // has an unmodified positive CLI control before its one-operand mutations. + journal := plan.JSONValue() + delete(journal, "transactionKind") + delete(journal, "nonClaims") + journal["journalKind"] = "proofkit.repository-write-journal" + encoded, err := stablejson.Marshal(journal) + if err != nil { + t.Fatal(err) + } + active := filepath.Join(root, repositorytransaction.ControlDirectory, "active") + if err := os.MkdirAll(active, 0o700); err != nil { + t.Fatal(err) + } + journalName := "journal.json" + if phase == "preparing-temp" { + journalName = "journal.tmp" + } + files := map[string][]byte{journalName: encoded} + if phase != "preparing-temp" { + files["before-000.bin"] = before + } + action := "rollback" + if phase != "preparing" && phase != "preparing-temp" { + files["ready"] = nil + } + if phase == "committed" { + action = "resume" + files["committed"] = nil + if err := os.Remove(filepath.Join(root, "source/item")); err != nil { + t.Fatal(err) + } + } else if phase == "rolled-back" { + files["rolled-back"] = nil + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(active, name), content, 0o600); err != nil { + t.Fatal(err) + } + } + return root, plan.TransactionID, action +} + +type recoveryCLIEntry struct { + mode fs.FileMode + content string +} + +func recoveryCLITree(t *testing.T, root string) map[string]recoveryCLIEntry { + t.Helper() + entries := map[string]recoveryCLIEntry{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + var content []byte + if info.Mode().IsRegular() { + content, err = os.ReadFile(path) + } + entries[path] = recoveryCLIEntry{mode: info.Mode(), content: string(content)} + return err + }) + if err != nil { + t.Fatal(err) + } + return entries +} diff --git a/internal/app/integration_version_edge_test.go b/internal/app/integration_version_edge_test.go index bde3003..721e23b 100644 --- a/internal/app/integration_version_edge_test.go +++ b/internal/app/integration_version_edge_test.go @@ -31,7 +31,7 @@ func TestIntegrationVersionEdgeClosesCompletePublicABIDiff(t *testing.T) { if err := verifyAdditivePublicABIDiff(frozen, readArchivedProjectNavigationContract(t), []string{}, nil); err != nil { t.Fatalf("frozen predecessor fingerprints differ from archived release bytes: %v", err) } - if err := verifyAdditivePublicABIDiff(frozen, readCLIContractRaw(t), []string{"integration-check", "integration-source"}, integrationProcessAppendices()); err != nil { + if err := verifyAdditivePublicABIDiff(frozen, readArchivedIntegrationContract(t), []string{"integration-check", "integration-source"}, integrationProcessAppendices()); err != nil { t.Fatal(err) } if current := "sha256:" + currentCLIContractPublicABISHA256(t); current == frozen.PublicABISHA256 { @@ -60,3 +60,62 @@ func TestIntegrationVersionEdgeClosesCompletePublicABIDiff(t *testing.T) { } } } + +func readArchivedIntegrationContract(t *testing.T) map[string]any { + t.Helper() + return readArchivedCLIContract(t, "internal/app/testdata/releases/v0.10.1", "846a642fbe1bfb9a59502c7018788667b5b6bd711e5fc45534f1846bc440e344") +} + +func readFrozenManagedIntegrationPredecessor(t *testing.T) frozenPublicABI { + t.Helper() + return readFrozenPublicABI(t, "internal/app/testdata/releases/v0.10.1/public-abi-observation.json", + "1e7efe0e9e960911b54b2045504122ca3bd9355316ff2201e7ad1e19a2e76592", "0.10.1", + "sha256:cc1fc5a55e00ea13e92d82edc3a3e3115cd9e69a00d08618fe2b1cefd25216d2") +} + +const managedReplayPolicy = "Replay requires a retained generation-2 terminal receipt binding the exact desiredStateId under the native lock; generation-1 receipts remain recoverable but acknowledgement retries require a newly reviewed plan. Roots with generation-2 receipts require a supporting binary even after recovery completes." + +func verifyManagedIntegrationPublicABIDiff(frozen frozenPublicABI, current map[string]any) error { + current = clonePublicABIRecord(current) + commands, _, err := indexPublicABIRecords(current["commands"], "command") + if err != nil { + return err + } + command, ok := commands["adopt-materialize-apply"] + if !ok { + return fmt.Errorf("materialization apply command is missing") + } + output, ok := command["outputContract"].(map[string]any) + if !ok { + return fmt.Errorf("materialization apply output contract is missing") + } + summary, ok := output["compatibilitySummary"].([]any) + if !ok || len(summary) == 0 || summary[len(summary)-1] != managedReplayPolicy { + return fmt.Errorf("materialization apply replay policy differs from its declared delta") + } + command = clonePublicABIRecord(command) + output = clonePublicABIRecord(output) + output["compatibilitySummary"] = summary[:len(summary)-1] + command["outputContract"] = output + values := slices.Clone(current["commands"].([]any)) + for index, raw := range values { + if raw.(map[string]any)["command"] == "adopt-materialize-apply" { + values[index] = command + } + } + current["commands"] = values + return verifyAdditivePublicABIDiff(frozen, current, []string{"integration-apply", "integration-plan", "integration-recover"}, nil) +} + +func TestManagedIntegrationVersionEdgeClosesDeclaredPublicABIDelta(t *testing.T) { + frozen := readFrozenManagedIntegrationPredecessor(t) + if err := verifyAdditivePublicABIDiff(frozen, readArchivedIntegrationContract(t), []string{}, nil); err != nil { + t.Fatal(err) + } + if err := verifyManagedIntegrationPublicABIDiff(frozen, readCLIContractRaw(t)); err != nil { + t.Fatal(err) + } + if current := "sha256:" + currentCLIContractPublicABISHA256(t); current == frozen.PublicABISHA256 { + t.Fatal("managed lifecycle retained the predecessor semantic ABI identity") + } +} diff --git a/internal/app/project_navigation_abi_closure_test.go b/internal/app/project_navigation_abi_closure_test.go index 78127e6..beaf3a5 100644 --- a/internal/app/project_navigation_abi_closure_test.go +++ b/internal/app/project_navigation_abi_closure_test.go @@ -26,7 +26,12 @@ func TestProjectNavigationVersionEdgeClosesCompletePublicABIDiff(t *testing.T) { func readArchivedProjectNavigationContract(t *testing.T) map[string]any { t.Helper() - content, err := os.ReadFile(filepath.Join(repoRoot(t), archivedProjectNavigationReleaseRoot, "cli-contract.v2.json.zip")) + return readArchivedCLIContract(t, archivedProjectNavigationReleaseRoot, archivedProjectNavigationContractSHA256) +} + +func readArchivedCLIContract(t *testing.T, relativeRoot, expectedDigest string) map[string]any { + t.Helper() + content, err := os.ReadFile(filepath.Join(repoRoot(t), relativeRoot, "cli-contract.v2.json.zip")) if err != nil { t.Fatal(err) } @@ -46,7 +51,7 @@ func readArchivedProjectNavigationContract(t *testing.T) map[string]any { if err != nil || closeErr != nil { t.Fatalf("read archived CLI contract: %v; close: %v", err, closeErr) } - if got := fmt.Sprintf("%x", sha256.Sum256(raw)); got != archivedProjectNavigationContractSHA256 { + if got := fmt.Sprintf("%x", sha256.Sum256(raw)); got != expectedDigest { t.Fatalf("archived CLI contract changed: %s", got) } value, err := admission.DecodeJSON(bytes.NewReader(raw), int64(len(raw))) diff --git a/internal/app/public_abi_mutation_test.go b/internal/app/public_abi_mutation_test.go index 8546e3a..cc84816 100644 --- a/internal/app/public_abi_mutation_test.go +++ b/internal/app/public_abi_mutation_test.go @@ -9,11 +9,32 @@ import ( ) func TestIntegrationVersionEdgeRejectsUndeclaredPublicABIDrift(t *testing.T) { - assertRejectsUndeclaredPublicABIDrift(t, readFrozenIntegrationPublicABI(t), readCLIContractRaw, func(frozen frozenPublicABI, current map[string]any) error { + assertRejectsUndeclaredPublicABIDrift(t, readFrozenIntegrationPublicABI(t), readArchivedIntegrationContract, func(frozen frozenPublicABI, current map[string]any) error { return verifyAdditivePublicABIDiff(frozen, current, []string{"integration-check", "integration-source"}, integrationProcessAppendices()) }) } +func TestManagedIntegrationVersionEdgeRejectsUndeclaredPublicABIDrift(t *testing.T) { + assertRejectsUndeclaredPublicABIDrift(t, readFrozenManagedIntegrationPredecessor(t), readCLIContractRaw, func(frozen frozenPublicABI, current map[string]any) error { + return verifyManagedIntegrationPublicABIDiff(frozen, current) + }) + for _, replacement := range []any{nil, "weaker replay", managedReplayPolicy + " Drift."} { + current := readCLIContractRaw(t) + mutatePublicABIRecord(t, current, "commands", "command", "adopt-materialize-apply", func(record map[string]any) { + output := record["outputContract"].(map[string]any) + summary := output["compatibilitySummary"].([]any) + if replacement == nil { + output["compatibilitySummary"] = summary[:len(summary)-1] + } else { + summary[len(summary)-1] = replacement + } + }) + if verifyManagedIntegrationPublicABIDiff(readFrozenManagedIntegrationPredecessor(t), current) == nil { + t.Fatal("undeclared replay semantics were admitted") + } + } +} + func assertRejectsUndeclaredPublicABIDrift(t *testing.T, frozen frozenPublicABI, read func(*testing.T) map[string]any, verify func(frozenPublicABI, map[string]any) error) { t.Helper() firstDefinition := sortedFirstKey(t, frozen.ContractDefinitions) diff --git a/internal/app/terminal_style.go b/internal/app/terminal_style.go index 574c4bf..edb8197 100644 --- a/internal/app/terminal_style.go +++ b/internal/app/terminal_style.go @@ -37,6 +37,35 @@ func newTerminalText(tokens ...terminalTextToken) terminalText { return terminalText{tokens: append([]terminalTextToken(nil), tokens...)} } +func labeledTerminalText(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 renderTerminalText(value terminalText, colorMode string, capabilities PresentationCapabilities) (string, error) { useANSI := colorMode == "auto" && capabilities.StdoutIsTTY && !capabilities.NoColorPresent if colorMode != "never" && colorMode != "auto" { diff --git a/internal/app/testdata/releases/v0.10.1/cli-contract.v2.json.zip b/internal/app/testdata/releases/v0.10.1/cli-contract.v2.json.zip new file mode 100644 index 0000000..1993aa7 Binary files /dev/null and b/internal/app/testdata/releases/v0.10.1/cli-contract.v2.json.zip differ diff --git a/internal/app/testdata/releases/v0.10.1/public-abi-observation.json b/internal/app/testdata/releases/v0.10.1/public-abi-observation.json new file mode 100644 index 0000000..eed7b8c --- /dev/null +++ b/internal/app/testdata/releases/v0.10.1/public-abi-observation.json @@ -0,0 +1,272 @@ +{ + "commandFingerprintPolicy": "semantic_command_contract_without_native_source_digests", + "commands": { + "adopt-materialize-apply": "sha256:5f0d52c00cf834dc9ac6c8a40a29cbb441cad8f5c7b8a78999f09a555a3c8e34", + "adopt-materialize-plan": "sha256:421b9f2eeb783b683057c90b46d3184535dfb4e30be799566c3055978852ffcd", + "adopt-materialize-recover": "sha256:b24eddc0b49e73081d6b53439b8c637114f1bbfe8522f8324d1bddec32f120df", + "adopt-plan": "sha256:bf7bc41a2093727deb359c080c42734c4157c93c955ae13f83677f428309846f", + "adoption-checklist": "sha256:4301bde365eea518049173e060d61f1a2d6a12acb7f02685447d3fcd210955b0", + "adoption-contract-envelope": "sha256:24b7fcc6a98f5db44c079707d4e093bff75c5416fb9414ed0074af4cfdb8926a", + "adoption-doctor": "sha256:798b77fbc8e69874cacbf6685d93fa0e92366ad789f280ac1b0f5c85cb7e2cd2", + "adoption-workflow-plan": "sha256:78d6346f3fd49c43e33d12a5dea19bd4d5cb6fa022f7d583d39f94bd92259305", + "agent-route": "sha256:92e5a84a888a0e92d438c9d40d7f8ef3d1a22dcc7abb0367a6310183cc64589c", + "binding-partition": "sha256:7d9812754e8841ddf7cd955dd799815f025a69a392b41893d95ef1a1d06ea446", + "branch-authority": "sha256:42583306c75e9efa20f603c6419d368461f061e4ab83263291aa320e23691d32", + "capability-map-admission": "sha256:bbf7969228241619ef59403284566066e225b365140a01e413657d1e4f9167b2", + "change-workflow-plan": "sha256:202a00112d185d16996dde91df90c6187348ff0dbaf91025f9b9a37945585308", + "changed-path-set": "sha256:ea8d8df0e6136f84d98bc127408f1bfeed1aaee850bb8f4b0b2604896a4f39e3", + "completion-criteria": "sha256:00a9d3cde3173dafba0f682564030c457d0fd0f61de9fbc19bdbb1cc2e3e00ac", + "conformance-profile": "sha256:c551d279669ce56b24b9326394656c9dfd6eb47c1a0c276c1670886db1d19e38", + "custom-rule-boundary": "sha256:f09cbd55617b4502190119b6c60eaa5500ae1edae73bc90cc08f4340d2872c6c", + "deployment-evidence-admission": "sha256:30beda51eef07b7470c02f14289b5a49ee72460ec54d3f8efc1ee6f123339b59", + "document-lifecycle-boundary": "sha256:c9285694816ae8ca7ecd2ae501acf3074ac2518766a22a528dfbf7fe1595c7d8", + "evidence-graph": "sha256:adf2f5a6822d058d6fbf66541159fa2c87cc40a6d7f2c67d0029fdcf264cd483", + "external-consumer": "sha256:498d350faa3e85cf6169e13a11fd2b13b40fdc59a53a95ae00e433dd64c84804", + "gradual-adoption": "sha256:39a43397353855c737e152fc944ca9376e8de5480f202dc65c6ba6b623fb3ce4", + "gradual-adoption-bootstrap": "sha256:ab4359514cea255f75e94fb4669487f4da56eda0728195be9dfe4c8576d98314", + "gradual-adoption-guidance": "sha256:5da4401fe574b6206c8242bd4a559939d25be86fde636df500511eb419301d73", + "help": "sha256:c7c895acccb0bd6e4763f27d93992b9690071cf22927f1e3a5182c894cd695fe", + "impact": "sha256:0351c86bf7b8ad604554230e790af777e70fb7fc6010d0d7c41e0c2539ea4a39", + "integration-check": "sha256:e69df74b81f0b5ea7401a138f1cf64d3236354fe6676af0f9bdbed2111d5b186", + "integration-source": "sha256:0a3af7886be0320b11bc7b00281ef094d89b43b6da3fce63bb6ae64a4f85b982", + "json-report-cli-adapter-source": "sha256:ae3e7188f2a297c77053bc620878f95da9a06518d86be329b53b179aeaf5536b", + "migration-parity-admission": "sha256:b9094e7e44e7f71589b98299d2fd9d84f22b9986b90ed63226a6660d7f35e476", + "migration-plan": "sha256:59a1da7205e8d1eaeac6425c6ae601b349a4310a35e4bc83692058c24cb3fed1", + "native-evidence-guidance": "sha256:66ef6fe61beeb2142f8fdf0a4486c855b270781fcc1ca4f07191482fdcbd8a5f", + "next": "sha256:a5dba97011ed9183a0b5cc5eda77d33f1d8a778c4280198ee185c0defbeb329b", + "obligation-decision": "sha256:daba2f642ea83778e0e2fd1138d20ea8f21ae30fd8091098d0cd5088e100b4e9", + "package-runtime-dependency-admission": "sha256:ea4de2c2339eff9768d00b64ed73034ca021fd8fcd0c54571bfbb5c97ff4347d", + "pilot-admission": "sha256:6312cc5ca823d64a4f2907babc6cdbe64d185f561447e9467edccbd51b138dd9", + "producer-policy-self-proof": "sha256:f221d2e12a6dd0fbd0b8dd22365eaf4c170544abf15ea40a11c3fa6a793652db", + "proof-obligation-algebra": "sha256:40a0bdbcd38f6ea3abe35131b74698745bb3ee262bb12c5c1c47ed50d87d455e", + "proof-receipt-admission": "sha256:ad0bdb8d01674a8dcf7d01d47a2766a491554f0f34229bd67547779ee5625328", + "proof-slice": "sha256:7fe4a8d83bc96af0142182e600a93a07c1ac4120c238c89eebcde02626a0e426", + "readiness-closeout": "sha256:dfdc0430c94df86a555e10f220c6791de390a669c6e3d264da333ccf87646762", + "receipt-currentness-scope": "sha256:59f07af61715f93154a64405d18737ede83dad1384d8ee30eaa81ee36f30d931", + "receipt-producer-admission": "sha256:e2a67e3c396ec1a103b4882e2f3d6d217675b3faf649da61b47bdd573d3a1f06", + "receipt-trust-class": "sha256:dcb19494d9f93e9ac7a5131450df8eb301debbc17b3ef97de3a762487a518ad1", + "registry-consumer": "sha256:ba1993e50fbffa1e91b53acdcb80f0018931bc6a8933113daa5c7cec2bc2057e", + "registry-consumer-proof-input-compose": "sha256:afd82cc90313e3a3a0f51cf23224789e13f509458705040e92aa9925688b1264", + "release-authority": "sha256:dfad5dc22d4ee148c9ef812911a37bb92aa9b8714660da19cdfa2d271d7303f0", + "rendered-artifact-freshness": "sha256:e0ec176191308353dce416a8988adc161d44ede3cde0f3f7e7cf0169a12e86f3", + "repo-profile-admission": "sha256:db6632e3f1cada4144a0f174b043f497539ac1a39f068695db09d3ea5b2d6bd8", + "repository-inventory": "sha256:8c637fc38d4151d3fb323bbc17fcf18c707526059d17b19cd3e3216f033ac0f7", + "requirement-authoring-plan": "sha256:e5f6520e8db048f55f8e46d33dd6e1e8c32c0986a839a84ea2dc5b692215ab54", + "requirement-bindings": "sha256:8747f9f25446ca8bf543c8d4eae42c45aacf91f919f9516aa0da696c99b80e4d", + "requirement-browser-server": "sha256:3eb6e553849772503844ac0a808f504044c705e920587225ee7e0c77018e8d01", + "requirement-context-compose": "sha256:f510132944ab918aabfe79e837c502a9b36ef1ba9809b2eeebba5a66560ba840", + "requirement-context-slice": "sha256:ccf1f912d0e9cfd4f330c3b4b0a3fa8ab8811192d4d6fdf6c6c8175d5415062d", + "requirement-coverage-input-compose": "sha256:a2a38a9e95780af68adb76a8afa16ccc104787e205764616f429b76947eda789", + "requirement-coverage-view": "sha256:08af3b6ccb675c9533329c27cc44f9486d797a53770fb2f5df77740cec9484e5", + "requirement-impact-input-compose": "sha256:a80c3f5a7e4c4879c24aff806c7a6675696533d14fc938211160663f3cabff81", + "requirement-proof-resolver": "sha256:5f6b34e11fdccf381fd77fb49fd0e0b99784ea875a1184f88621e2d1e6a19345", + "requirement-proof-source-set": "sha256:bfe2aa94d1713c70ce939f794d7adc1f866ad6da71c46d347559e74faa55cbad", + "requirement-proof-view": "sha256:0f316e3da056f7e4a5a0a3ccdca72304e92b6b2abf9deec9c957672b2ef8505c", + "requirement-semantic-diff": "sha256:73d590602d2ee45533b610e8c31f9776dd5c504477a1c3c9e6325bfd4b9ee88d", + "requirement-source-admission": "sha256:d63a1b1a3b3f0b057602c6eca4dc8f9f9d2d8697eb314316c5a624044831099d", + "requirement-source-transition": "sha256:036faa999d55f92d194e4648cfeb03c9de0fc08fa5ad90669a82287acc58a1df", + "requirement-source-view": "sha256:380b829ebfcdfcf35bd024f2a4d474bde56c013068353f0f897c0fa02566ad21", + "requirement-spec-tree": "sha256:b865f5988555a56c6ba158f73da85120277ba2d6f6d764a9e475da0e6601a088", + "requirement-spec-tree-view": "sha256:a8fdbb358902c36912bc15097315630cd02b926672d1ebe94eb8aa15ebcd3125", + "requirement-traceability-graph": "sha256:ebe9aee328218caec9b94ce50ef23a13aafcdb775440d012ee1b7cbc10a9075f", + "scaffold-profile-plan": "sha256:bd1a2f627d79b38fec7b4857659f9870588f7c52e4da003abd40b01d66907d8a", + "scaffold-project-structure": "sha256:61528eb82049b3b595564079292aae805671351dcdb5c99813ba172f25bab95c", + "secret-scan": "sha256:e4fba0f0c9bb2ab59d3ae839290804cd867ae2fda51e4bb2cf7da4cb90ff34d9", + "selective-gate-evidence": "sha256:8d8a5f8b6a53dc80ea73ca3d183f7f41147ea7a895696fe1dcf56428696074cb", + "selective-gate-obligation-decision-input": "sha256:05a59d50765ebea08c209cbed21b9fd4de74b5bb9f9ff748683042fad986da32", + "selective-gate-plan": "sha256:cdcd34fb480b64170d8406acc74832fff33ee499430c2e22ad42246ea9f4045b", + "self-check": "sha256:93464e3868286e76ec193438e70c2157e448f29eb7932b0b0a2d7afef12e72f6", + "spec-overview-claims": "sha256:4c98991b271e8f9b441811a1d408fc9473642aa87effa4614e497d65cba9ec3b", + "spec-proof-bundle-admission": "sha256:eeff98b256d94200d0326e143b633deedc65531b87ce9af03f63d65f3e6968c8", + "stack-preset": "sha256:77714de54868bc398ab7c6911237957f5d9bc1cc98d218b140f0b47ca437af17", + "status": "sha256:fd4c19cf0becab9f8104a967ac677b6f22de3498b05c08b003887f047db1999c", + "test-evidence-inventory": "sha256:0424529a9c6e258fb746d7a71b45673765db64241cfecf9b4d5ae40e268ed94e", + "text-policy": "sha256:8b616f59d4c1bbcc179f6ae4797d97bffae0dc9e30923be80ab68186e8d0a2c2", + "typescript-public-api-surfaces": "sha256:dc1f2dee440c0f8130f0a439e17f275b2f13b76ca22eed8ed46433c853cb5eba", + "witness-plan": "sha256:7ea2cc4ecc54743c3ec60b9ccd744272e204bbd854932ee8262e27d5eb46648f", + "witness-scheduler-plan": "sha256:3a15a957d55c7192a64789031570dd3aa2a56d5621c7a6e5d2fc309ecee5319d", + "workspace-changed-package-plan": "sha256:13ca36deb447eb2c4561657e9abdfd348c932848fe28dd92ce184b2eadc07e26", + "workspace-manifest-facts": "sha256:732e8d9cc582cbcd1b9e0d3be142f25bc1305f295378f3962368a93d1402c4b4", + "workspace-registry": "sha256:5f339565f1e0228cad9f1f59bc42a6baf3cfe8240ba412ab1a4f25c412ff7bae", + "workspace-shard-partition": "sha256:11e500cc8b3c6b18b27bdc0e838f92cdb27725cde1497e0beb38f07138cdd391" + }, + "contractDefinitions": { + "proofkit.adopt-plan.output.v1.root-shape": "sha256:b1055d057771573252ed1ebb982f2b99d5bd1059caf2c1d57373af3249ec4444", + "proofkit.adoption-checklist.input.v1.root-shape": "sha256:183d3924bcd381a0718167ed1e5c16019edab1c8ed98edb329ec0c706c179b8b", + "proofkit.adoption-checklist.output.v1.root-shape": "sha256:d4fbec44fbbc3fa81d0a1f0383524be80952f7252d9ce02fcb041d3ca4dc449d", + "proofkit.adoption-contract-envelope.input.v2.root-shape": "sha256:648d0b5cbb894e06063bdd160a7b8d3a5cbbd41aa0825b2b6593186e3ae9790b", + "proofkit.adoption-contract-envelope.output.v1.root-shape": "sha256:3c28c67b8a40e150561a97d68691298cdffc4e381dfe8d8880d9aa4edf168664", + "proofkit.adoption-doctor.input.v1.root-shape": "sha256:67b5d7169f47e8ae4e696c651f87f433f4026112f9ccd9ff8c14e1709c7972dc", + "proofkit.adoption-doctor.output.v1.root-shape": "sha256:bbcf32e471e51d65ab0cac13f76d3c3a08c58374eb672c730bcda079b42e70cb", + "proofkit.adoption-materialization.apply-input.v1.root-shape": "sha256:d9b8a2d1725e0d64edb0e4ba037ce123909b0663bbcfde8d7ca4ba2025f98df2", + "proofkit.adoption-materialization.apply-output.v1.root-shape": "sha256:1ef903ee78ca688268a0f6ab51abce1ee389f4583724a4c55b5fe94482592307", + "proofkit.adoption-materialization.plan-input.v1.root-shape": "sha256:3e44ecca4b4b5012155bc2b461d293bb624663e93650ec5fef9d16c6bc3a0f2f", + "proofkit.adoption-materialization.plan-output.v1.root-shape": "sha256:a5d7762b12030d42c107134083b038a162ec0c02151c1f7dbdef32d88433111b", + "proofkit.adoption-materialization.recover-output.v1.root-shape": "sha256:1969957f358d4abacb179b741c2718fd89044b251eefac09901ebc31f7b2d999", + "proofkit.adoption-workflow-plan.input.v1.root-shape": "sha256:4b1ea641b5b3d5541a09b42f7e70f760da272ca62c77c1769116fdffb9dd06e5", + "proofkit.adoption-workflow-plan.output.v1.root-shape": "sha256:b66f9127f43ac3c5d4e97ef1920664df96bd8da655a53c333ac6b5fc3739d63d", + "proofkit.agent-route.input.v2.root-shape": "sha256:0a37b2ce49fc2ca2dca9d95653f1efa5363b1177d79364ca82b1281d06ae5e23", + "proofkit.agent-route.output.v3.root-shape": "sha256:09d2ffb20df8eeef73e4f0e67bdcc70cc7602525bf12d9b982a92d3c2d98ddee", + "proofkit.binding-partition.input.v1.root-shape": "sha256:db3aa86a745fd5650c0558668deb0dee2c1a04042527193efb3295774e579c97", + "proofkit.binding-partition.output.v1.root-shape": "sha256:a044f95c13e445edb611c53b61dcfbfeeef3c172596b3bb7f574529039f95093", + "proofkit.branch-authority.input.v1.root-shape": "sha256:b2dd6ab268f6430dfa27cb351d7f99b6d08d09eb2d903a957030cf384883be58", + "proofkit.branch-authority.output.v1.root-shape": "sha256:09ffe941c0c08ab9399e4e17dc0e85146abaf869a47a2c770c71ceb46ab895bc", + "proofkit.capability-map-admission.input.v1.root-shape": "sha256:5d02b008d6069c2838f38e0c1af0d68b820b7a37b14092c0336c02f0a4e01f03", + "proofkit.capability-map-admission.output.v1.root-shape": "sha256:f77609e0606035c0046ecb5baefd02a8deb9567aa03c88db568faebe1d665fbb", + "proofkit.change-workflow-plan.input.v1.root-shape": "sha256:c741c11ee19f6aa74df7c9cb3865a6b174f443a8ce6fcd5ec13c6a1105d87901", + "proofkit.change-workflow-plan.output.v1.root-shape": "sha256:fb4c512272e7d6f11f81566023ca5a83d54b31e9ef22d7324ba2c266625aa53d", + "proofkit.changed-path-set.input.v1.root-shape": "sha256:c2eb73384ab1ae5345156eec9a304835acf6ffa74c8709bac2508d427d489c29", + "proofkit.changed-path-set.output.v1.root-shape": "sha256:110a5c0ee9ec150916d832ca5d5ca99e50f1c90efee31249d8dcf547b0a6bffc", + "proofkit.completion-criteria.input.v1.root-shape": "sha256:85695f5582e2c2741ea136b32d86cdb213ee96c729b558e257e0d9222e65856f", + "proofkit.completion-criteria.output.v1.root-shape": "sha256:5f3bd4bc6bfc668135ed90a373e3c33d6d63da76637255e0722868974c3b98d2", + "proofkit.conformance-profile.input.v2.root-shape": "sha256:e1596622238c513eb0f04f9472047d70530bb98467d9d7e3e99ab944106d6777", + "proofkit.conformance-profile.output.v2.root-shape": "sha256:c48f4f4813087c55e02b53a5b6bc4cc581ca76f43f97b9d9c5da62044924ab23", + "proofkit.custom-rule-boundary.input.v1.root-shape": "sha256:072b25c19600d00d636df40b1bb9f21bd1f138ce63ca595b44b50f0648f79925", + "proofkit.custom-rule-boundary.output.v1.root-shape": "sha256:9bb1c66dded3a22febeab730b198258b731051770ea3519704eb45cf5ca5e6f3", + "proofkit.deployment-evidence-admission.input.v1.root-shape": "sha256:bd9e7dc71a4651da2ae10bf34110adb10285e4ebd30210ab6bb93b956b26a12c", + "proofkit.deployment-evidence-admission.output.v1.root-shape": "sha256:c4e5f4ca22deb9f7b79b4abfdeb2fdda2b4d7413e915e703c8038abd619b212d", + "proofkit.document-lifecycle-boundary.input.v1.root-shape": "sha256:1d4d94e4b7e4a12b39432af4d51cf483d02bd7de4f78cadd9b7262b908ebe1f4", + "proofkit.document-lifecycle-boundary.output.v1.root-shape": "sha256:e1cafa2ec4595fac0365bc3910ef9018b79e078bb49a7b26e32a94e745589da1", + "proofkit.evidence-graph.input.v1.root-shape": "sha256:e3d7704e1139ec9ecae885285cc71ec6ba59f020d2bc827162ebc47d8880332b", + "proofkit.evidence-graph.output.v1.root-shape": "sha256:482b548c92db4e3a43c29414e47ffaff788b4dec9500a863e10e6506803369a8", + "proofkit.external-consumer.input.v1.root-shape": "sha256:bf99fd56342e243a63148f828b82d55cbfca09976e348bd30c100191066325a1", + "proofkit.external-consumer.output.v1.root-shape": "sha256:906c1a16937fe87113700dd6f4e4b9161ce2a2344b6722f163ad9cbb1f5c72ba", + "proofkit.gradual-adoption-bootstrap.input.v1.root-shape": "sha256:9197bf926b6c560146de8c37c07f99c0ab0700ca02d0c08369a0ab88ff7d5233", + "proofkit.gradual-adoption-bootstrap.output.v1.root-shape": "sha256:ad134eeec1ca291e73da8d945209f98b7dfcdbf3a8ce41fcc4e9ea9e05de67cc", + "proofkit.gradual-adoption-guidance.input.v1.root-shape": "sha256:d3ba1789108f9b097a76ce388b17ffda975915ba0728a1dd465fe1b5b0d25c56", + "proofkit.gradual-adoption-guidance.output.v1.root-shape": "sha256:5dc2baadf195bcdef7f441ed1f64cc4ce91df762209c0c98a9433ab3fa8bfec2", + "proofkit.gradual-adoption.input.v1.root-shape": "sha256:6c51b22526e345d56a2568aab21c7696916c4c9bafa8f146d2ddd8ca84fc2f1a", + "proofkit.gradual-adoption.output.v1.root-shape": "sha256:27f63f422c35eabe29d1fdbe8ba663d05e9bbd7aab695471f101fe6fa881c152", + "proofkit.impact.input.v2.root-shape": "sha256:2de844000fd3b54bfed60df8d6992609477a38742c56fd9a4744a07ad19a0bc6", + "proofkit.impact.output.v2.root-shape": "sha256:9ab04fde5afaba3f6bc925e8274a3fadf21f0fec020871e2575d38f2616ffb1d", + "proofkit.integration-check.output.v1.root-shape": "sha256:cc7886335e69d58a37cf2313ccda433e1850f21591ccdfad3906540305f54584", + "proofkit.integration-source.output.v1.root-shape": "sha256:866b0b731e8e1f95e74c36d2684adcb9fa9f9bc0799a8518204c667f8245c893", + "proofkit.json-report-cli-adapter-source.output.v1.root-shape": "sha256:6c506e9a805ec1e2b998a3f91cd80dcfe47b49b174d5fcb390fe9a3b495d364c", + "proofkit.migration-parity-admission.input.v1.root-shape": "sha256:4466c6a3a7d33620c1605d4e8eea932bd90432f0efe72783cafb9d269cdb7877", + "proofkit.migration-parity-admission.output.v1.root-shape": "sha256:e6fe3414939f0c297827fd94c50ff0bfadfba18969530aac40788bb9f66193d0", + "proofkit.migration-plan.input.v1.root-shape": "sha256:a0fb2f7e9d2469b648a6933f693ba6948a9f06c06e79393c236e2ad62a721690", + "proofkit.migration-plan.output.v1.root-shape": "sha256:d2bf5285bafc193f832836b2a8ff36d29d2c2d6a8c6efeb812edc58b2b25d31a", + "proofkit.native-evidence-guidance.output.v1.root-shape": "sha256:ea40df7bad16d5893871398f31d00af8e7d0a154dd830d03441dee1f3b3f457e", + "proofkit.next.output.v1.root-shape": "sha256:1374b8a1a9ee4100ed9323da8e2260dcf6cacf16026a16090c182643d112c9ee", + "proofkit.obligation-decision.input.v1.root-shape": "sha256:b46ca0727b8bd37c54af94d40ddb8a393c7b34352e10d98d13aa5295b5c5e99e", + "proofkit.obligation-decision.output.v1.root-shape": "sha256:d00ef83f944f8617dd20c8a02e305e4d5a30321a1e78bb0f413d68aeea15d277", + "proofkit.package-runtime-dependency-admission.input.v1.root-shape": "sha256:79457e79a5057dea7da0f933ede4b3f2d2eff15abb327d7d231d88e138fa4d15", + "proofkit.package-runtime-dependency-admission.output.v1.root-shape": "sha256:edd2d97079917745afc4c35bfb41e05594bb0e1b76ed174e15dba4d06639932b", + "proofkit.pilot-admission.input.v2.root-shape": "sha256:b3449d30c5995ec43c70a0e0b375741e91322c391865c5b9809e4beda29beb18", + "proofkit.pilot-admission.output.v1.root-shape": "sha256:ae8162846cdb4285a6c9fbf516e3500316ab7111055d83743f2c2706d8eba764", + "proofkit.producer-policy-self-proof.input.v1.root-shape": "sha256:712d3a8ac69c02c7fb348020ac44e2929325c2ac82c20fe9abf013d74f0b3fad", + "proofkit.producer-policy-self-proof.output.v1.root-shape": "sha256:76a8b86df2051f8dcd5ba67863d04afb66bf037726e7616cfc199ac2a2f85bbf", + "proofkit.proof-obligation-algebra.input.v1.root-shape": "sha256:ef13ec8945a62655b1ff804d30dc9ba3b5f712377142bf96ff5b4c3b6b6fb171", + "proofkit.proof-obligation-algebra.output.v1.root-shape": "sha256:1309b0bdd52bdb413a64b8084ce6af876ff3973e9edc82c5dd3ebe7a3b11692c", + "proofkit.proof-receipt-admission.input.v1.root-shape": "sha256:90880159e5bac4c583021791fac7acd6cbd5bd00d0f8d9e2b7761e066572b938", + "proofkit.proof-receipt-admission.output.v1.root-shape": "sha256:6ac0f95689749db3e78f56f789222475722464c3028e4ae52c45b5eb1a38b879", + "proofkit.proof-slice.input.v1.root-shape": "sha256:00bc6195cc7545c50bc8a9b3aa514423177537d02410f56ab97637fd7e5d6141", + "proofkit.proof-slice.output.v1.root-shape": "sha256:ebe71fa04a2bcea25b718a3270de37a695cf8b1cbd3338074ce5d2097e14ea8d", + "proofkit.readiness-closeout.input.v1.root-shape": "sha256:01aa2f0eb36ebe4d626d067a74bda0a98da4d6d9aab0fc29739a1f73fa23013e", + "proofkit.readiness-closeout.output.v1.root-shape": "sha256:af0c0fc7feee99b450dc665d14a9fe85a7e5f6e07a8d6fd3246c64018a7397bf", + "proofkit.receipt-currentness-scope.input.v1.root-shape": "sha256:fe1b7088d55547ce55caba88434df6e1728e522f9b68f22a006943e5c86f7073", + "proofkit.receipt-currentness-scope.output.v1.root-shape": "sha256:44a90b8eea868c9be44cbde472438dab29d2d02a0745741c94deedca4a954c4c", + "proofkit.receipt-producer-admission.input.v1.root-shape": "sha256:f13cd253ee1de8f0514d76f1e52e4a8c484f3dbd0151e961a20659cef57a75bd", + "proofkit.receipt-producer-admission.output.v1.root-shape": "sha256:f7da547e6ed0bf89a29aeb5f5426fbce04bb0f6369246ff0c74fe6ee5c4d80bf", + "proofkit.receipt-trust-class.input.v1.root-shape": "sha256:46d9d121275c7763632e51edc9fbe04829b7843b6a587a65e08954c2dbad0fe7", + "proofkit.receipt-trust-class.output.v1.root-shape": "sha256:e1a9d098ea2037cf466481824a4a3ca017ba74df0d92aa6613b7603eea794d32", + "proofkit.registry-consumer-proof-input-compose.input.v1.root-shape": "sha256:529947c77c35bbf762c410f7446655132caa829c70cbfbb6e6c5d93672cc998a", + "proofkit.registry-consumer-proof-input-compose.output.v1.root-shape": "sha256:094335d032894ba6d0681a1aa91b62e76f056d06fdffc24b1748b78f9ed954d0", + "proofkit.registry-consumer.input.v1.root-shape": "sha256:a89496595f72e9376a2cfb5a1b2639157952817add2202428356ac448c3364f1", + "proofkit.registry-consumer.output.v1.root-shape": "sha256:1610d3231112d3e2dbbcc241debe0ca1077f6a603086e10fde1d94b8262cac17", + "proofkit.release-authority.input.v1.root-shape": "sha256:31c75401d61456af4047e5611f66dc91124f4388de59278c7ad9ccf919069daf", + "proofkit.release-authority.output.v1.root-shape": "sha256:f1b0a9ef15a0ec0f51c6e86860fe108df3098bef59d8af92f1d833d688efc45c", + "proofkit.rendered-artifact-freshness.input.v1.root-shape": "sha256:8db852267c4e57b95b7498fb039f01ccb6ee1595bd7ec2837d9c35dd126adccb", + "proofkit.rendered-artifact-freshness.output.v1.root-shape": "sha256:f62fb968ec2cae41525ecac5665e4ac49add852c3d82d820073d60d9fa6ea3ef", + "proofkit.repo-profile-admission.input.v1.root-shape": "sha256:c2340140fc4e1093500fc9500ca619a05fac79767a19a63d8c6e36cb33937170", + "proofkit.repo-profile-admission.output.v1.root-shape": "sha256:063bf420f694f15383352da18ec0180cd5727337a2e4813d8de082b2a6450b2f", + "proofkit.repository-inventory.output.v1.root-shape": "sha256:02f0bd8e33d192437bb8f7c3fc1c0d01e39d428b90a8135470521a69d3d08d25", + "proofkit.requirement-authoring-plan.input.v1.root-shape": "sha256:aa8bc39d8dfd7746a4b23176a8e834b0e84a0937136bc506435fefb39d3a5d99", + "proofkit.requirement-authoring-plan.output.v2.root-shape": "sha256:47a28cb30a58048faf0d824523921fb4204120ff63b19f4833a4f2dfaaa40a13", + "proofkit.requirement-bindings.input.v1.root-shape": "sha256:b57bcec7fcdfa6a98c13051d06ff69ff05e15f2b4a19860eb71c7aeabda23018", + "proofkit.requirement-bindings.output.v1.root-shape": "sha256:ff1fbd4ad20e6c5c6b13a2a3ff443c2082c1514d7c3b34013817ee10ff0077fc", + "proofkit.requirement-browser-server.input.v3.root-shape": "sha256:7a15e92365672d1ca260fcbee8be2bd6fbb4b1e194e9996fcbfd59e73e1f4f34", + "proofkit.requirement-browser-server.output.v1.root-shape": "sha256:bfb0da899a9c77d969d2687e04e866912031a7f9cb1086e40f5038144c8924b3", + "proofkit.requirement-context-compose.input.v1.root-shape": "sha256:99984834220692385d55e421fca1a03cf596d303d3be91f88827d5860659b9f0", + "proofkit.requirement-context-compose.output.v2.root-shape": "sha256:66ed99649703edfe696684ec5a38264b03f456c3349c3b6010742187c5235f1a", + "proofkit.requirement-context-slice.input.v1.root-shape": "sha256:994d367551a46aeea385257bd27798a3bd32d8eff7f494bbf332bed71ee456f9", + "proofkit.requirement-context-slice.output.v1.root-shape": "sha256:990ecf59350ba938d31fc32c63b9b88478911782d608245a2a4457c132f5a7d2", + "proofkit.requirement-coverage-input-compose.input.v2.root-shape": "sha256:cfaf53655f04130f02ee986bdc57379593d6b63ec0ae6c7415370866000baed3", + "proofkit.requirement-coverage-input-compose.output.v2.root-shape": "sha256:e63a626870e41ce2b9ff3ecbdb82029fcea6c26d1c2893f6fbe325045dd34087", + "proofkit.requirement-coverage-view.input.v2.root-shape": "sha256:c890cddbc60c77bd9a66116caedb46d71eb45da9b657be95e6adf54f70aa07f7", + "proofkit.requirement-coverage-view.output.v3.root-shape": "sha256:9d4404f95e4a54fbe21a8817c5577d30c9cf76903c898888b9d688d88d557161", + "proofkit.requirement-impact-input-compose.input.v2.root-shape": "sha256:17489925d632b6e8543f86be0f6824818803b47cdb882278d212032b944a384f", + "proofkit.requirement-impact-input-compose.output.v2.root-shape": "sha256:809cbddcc6ed83a781d01a0ea6ed59bb7730a5916ec1f95aeafd6ae2f19b3ec5", + "proofkit.requirement-proof-resolver.input.v2.root-shape": "sha256:3b8a89fc3d32e20d40db72ccbb2a90fe8329072108fbef3a439c7df4ad89b711", + "proofkit.requirement-proof-resolver.output.v2.root-shape": "sha256:7cf374f46d230331241e723c423f3695b2e06a50fc385aae18d7341d95059307", + "proofkit.requirement-proof-source-set.input.v2.root-shape": "sha256:57c43710648281e5c4264bfadaa407d1268aecd020df22ba6641212749146a7f", + "proofkit.requirement-proof-source-set.output.v2.root-shape": "sha256:f8a03a5f5b4964cc8f7e5d00d6b962052e95ff0b91d27b0d41cc43e771310055", + "proofkit.requirement-proof-view.input.v2.root-shape": "sha256:fd96cde2473fe37924a588a5e257017e025efb73d1ec1fc063f8b715b4b7e0cb", + "proofkit.requirement-proof-view.output.v2.root-shape": "sha256:a11aef4bb9ac6eced780b75287412b3f6bd43b1302d392773e1b6db81f42ff67", + "proofkit.requirement-semantic-diff.input.v2.root-shape": "sha256:9df463380742b2424153c275365401765c5b8da8c19a116e42e108d5359ab0b3", + "proofkit.requirement-semantic-diff.output.v2.root-shape": "sha256:25a530d869c69ff644ad53f84abbcbdaa03559577c99e244e5e944ec3a811ff9", + "proofkit.requirement-source-admission.input.v1.root-shape": "sha256:1ef5b4ff14c7ee7e5f0b76d0e8cdedb5f9fad32cbcfdd7b3930493eae14670e6", + "proofkit.requirement-source-admission.output.v1.root-shape": "sha256:cd9db4e8db0336364a4815d1c7aac3ad317e42d89279c2f0924bcf1f439b0a7a", + "proofkit.requirement-source-transition.input.v1.root-shape": "sha256:4cb46831aa6d7350eaf22fbd5b9b7e154c9a660e21a901815f70378369cf38f6", + "proofkit.requirement-source-transition.output.v1.root-shape": "sha256:955fff4cf23f583cb860d4490b369f4e253c54ec6516221660c803df592cb5b7", + "proofkit.requirement-source-view.input.v1.root-shape": "sha256:ae046f9795c4e524f95fcf47e8cdbd008c1d9424d66ecfb17683ea3c0abf3df8", + "proofkit.requirement-source-view.output.v1.root-shape": "sha256:26591b6039c8a3550854dc161a1a60b9259f3fd5be80790950d9ec8e36346620", + "proofkit.requirement-spec-tree-view.input.v1.root-shape": "sha256:bfecbc18fe9e747ce79bbe03b83db5a7f7178d58f514b0b9d23ed26cb2190f32", + "proofkit.requirement-spec-tree-view.output.v2.root-shape": "sha256:9a03221240f61eeb94157bfb556edee325840b6e54f5d8a79169c2a9e93d3b4c", + "proofkit.requirement-spec-tree.input.v1.root-shape": "sha256:394f54e615f552af8012c9ff762b83095ec9dabcc163aa19d52404f02589ea73", + "proofkit.requirement-spec-tree.output.v1.root-shape": "sha256:929838c2402c4512c97721a2d8a1d9389a34fc09d992fa801e22b4527edbdcd9", + "proofkit.requirement-traceability-graph.input.v2.root-shape": "sha256:5a3b03a8103996a61ea80cfdfeef3bf18a57886cd37d58e7c8df4b0efb8a947f", + "proofkit.requirement-traceability-graph.output.v1.root-shape": "sha256:16e8aec9edb257caff0e834816dfc44e6929bbdf399055477c779383dbcf4a5c", + "proofkit.scaffold-profile-plan.input.v1.root-shape": "sha256:aec8ad7d35f5c77dbae52197834c3abc797251371b20f3ce7036353154d28f0b", + "proofkit.scaffold-profile-plan.output.v1.root-shape": "sha256:9e6cccdfd25d48ae93d2a6c247873c1db4204cdebed3ec7d83fe332c8023ecc9", + "proofkit.scaffold-project-structure.input.v1.root-shape": "sha256:4eaa681eb5a480b123693a8e87815958e7ae7e0a409a71189c671fca4e345ede", + "proofkit.scaffold-project-structure.output.v1.root-shape": "sha256:4ffbee5bc142abb84d0fb8fbdcc25dac1b043310e3b728b7b8de190d30bb03fe", + "proofkit.secret-scan.input.v1.root-shape": "sha256:3e8d086056134b306a27f817012f1fa6728bf7b1eb7636b37d5f180ee0f8b46a", + "proofkit.secret-scan.output.v1.root-shape": "sha256:b1e5663db3a831f670b7d76015697719c87683766088a4a62674d9fd6e34c186", + "proofkit.selective-gate-evidence.input.v1.root-shape": "sha256:a0b9c78088bb7429086cf9244ce54ba5636281899ae506e410baab9ffa320146", + "proofkit.selective-gate-evidence.output.v1.root-shape": "sha256:a5f97a52b9f74d20d757495d22e7ba736aa8e93fef4521aa2d130e65eead8e5f", + "proofkit.selective-gate-obligation-decision-input.input.v1.root-shape": "sha256:e9ff696a70ecb2b08b74c84ea3fb34bd56bdc615f6d75f151694137e15677905", + "proofkit.selective-gate-obligation-decision-input.output.v1.root-shape": "sha256:ffd3ee7cafe59efa0fb20ff99bdc33962d090f012dec81060e2b9f78b36ed08d", + "proofkit.selective-gate-plan.input.v1.root-shape": "sha256:b7bc0bc0f5860b6562039caf8a74a4b3cf10c2bec0776db642d8a6c833dfed02", + "proofkit.selective-gate-plan.output.v1.root-shape": "sha256:fc1736408f2bc4e51fdd353e89ee6912295c7b4401b237c972d4ceddd45b729b", + "proofkit.self-check.input.v1.root-shape": "sha256:f3d13182d45baade9b5870bc33472ddc177721a31b0b82fe6c3ecd2fb08ca6de", + "proofkit.self-check.output.v1.root-shape": "sha256:8635d51a9ae997df05d7b64f86f6d6c29bb7850cf85835293dddbe3a204864a5", + "proofkit.spec-overview-claims.input.v1.root-shape": "sha256:1aea724c3878b0d79354cd6320fdc67de6e0efe189882c311397d702d385e752", + "proofkit.spec-overview-claims.output.v1.root-shape": "sha256:0e9cabc13356be0946beeb84f18f48a2be287a34f73a2c0d03c548eec49d0a5e", + "proofkit.spec-proof-bundle-admission.input.v1.root-shape": "sha256:85e99552f0e1e6bc24aa23761756c7989d8d297280ae8da6f09e3e7a29cfdee3", + "proofkit.spec-proof-bundle-admission.output.v1.root-shape": "sha256:a8d651222dda8c6c3652f6dc8858aaa4b69b01eb7010727222bfcfebbecd8371", + "proofkit.stack-preset.output.v1.root-shape": "sha256:0cabd5952f7716cd6db163a2037bdb99ff12f8879712420987a4c93dbe3e0528", + "proofkit.status.output.v1.root-shape": "sha256:c88e90087b87c789cbbdf4082fb3569fba17f4665ea325a8b222577a72a4b5e3", + "proofkit.test-evidence-inventory.input.v2.root-shape": "sha256:b89dc7cb02e288f4734da6febc02eedfe8dd113018e90b5ab4ae501cc60aff3c", + "proofkit.test-evidence-inventory.output.v2.root-shape": "sha256:12259060e4b5dde06ef1ea4440b249633af88be5555496e9daa95838b0593d22", + "proofkit.text-policy.input.v1.root-shape": "sha256:8343f50b7d23e0104beff0c2fc4272b205b40dfc122dfff4acb0a7dab49ac1ad", + "proofkit.text-policy.output.v1.root-shape": "sha256:fdd89dc7df35b4d3e13d668aba3835bcfe1dd8f45e5e417a15e04d356cbc7b90", + "proofkit.typescript-public-api-surfaces.input.v1.root-shape": "sha256:3595f9120e43ab94658e320057650ed68b9fa1b81b8c450423949453e4c4b80d", + "proofkit.typescript-public-api-surfaces.output.v1.root-shape": "sha256:4f3c85ffe365b67bb763099c51c712ca5ce2c3e6a18c8f807523d26cf1b146b8", + "proofkit.witness-plan.input.v1.root-shape": "sha256:4cd56bcc87a5a38ab7767797e2c3c3aa58de8d8785375a740aae2abfdcd895e1", + "proofkit.witness-plan.output.v1.root-shape": "sha256:54bbc6306250bb8d5e5e92355380427c36f7f607b60d72dd1408600a25a7f588", + "proofkit.witness-scheduler-plan.input.v1.root-shape": "sha256:13be42b86615146db538ca509127b8685503bfcc38053954db049948dc975224", + "proofkit.witness-scheduler-plan.output.v1.root-shape": "sha256:fa148200a4050d07122235767624170bd477d2d7e307cb62e6877c0ac1469e09", + "proofkit.workspace-changed-package-plan.input.v1.root-shape": "sha256:65f68f90d6b8d5227c43f6f7a86676d80c23874fa3f678ea154daf1923ba8743", + "proofkit.workspace-changed-package-plan.output.v1.root-shape": "sha256:6431ffd5078f661d919ea9d74dc670d3ddf964b815639c31dd2ad4d9b7265b99", + "proofkit.workspace-manifest-facts.input.v1.root-shape": "sha256:27561dac52db8ce803e5ae75ec1f8113822896fa7ae283c1786448ef6bbcc95c", + "proofkit.workspace-manifest-facts.output.v1.root-shape": "sha256:20b1c2c0caeab5c64c22e1ac8445bba65712b8907d73d960c6162fe6d4786ea8", + "proofkit.workspace-registry.input.v1.root-shape": "sha256:eff179bdd299387a66ac3c9c41b404687cd7e3ea8770d411f8ac443bd8d48fed", + "proofkit.workspace-registry.output.v1.root-shape": "sha256:87881114c7ae0a0ea62b08644df9cba0d919f53dfcf402ecc772edcd81b5965b", + "proofkit.workspace-shard-partition.input.v1.root-shape": "sha256:21148091a01be99a42b3b44694565afd69c63fe63326ca2589a20836573c3807", + "proofkit.workspace-shard-partition.output.v1.root-shape": "sha256:4b54ef65d891ea3ac80459bc3d2f2f2bb5b7306e06450c5d8dde6bca5da0b2f4" + }, + "contractId": "proofkit.cli-contract.v2", + "contractSchemaVersion": 2, + "nonClaims": [ + "Per-command fingerprints omit only native source canonical digests; the exact raw contract remains bound by publicAbiSha256.", + "This frozen source observation does not authenticate registry publication, provider state, consumer migration, or runtime compatibility." + ], + "observationKind": "proofkit.frozen-public-abi-observation", + "orderingPolicy": "lexicographic_by_identity", + "packageName": "@research-engineering/agentic-proofkit", + "processContractSha256": "sha256:e4b835b9e6bd8ce658ad3386076b77df4c7fe604bbd3be7185a32e011ba1ee24", + "publicAbiSha256": "sha256:cc1fc5a55e00ea13e92d82edc3a3e3115cd9e69a00d08618fe2b1cefd25216d2", + "releaseVersion": "0.10.1", + "schemaVersion": 1 +} diff --git a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go index d951e3a..1004c22 100644 --- a/internal/command/adoptionmaterialization/adoptionmaterialization_test.go +++ b/internal/command/adoptionmaterialization/adoptionmaterialization_test.go @@ -297,6 +297,7 @@ func TestApplyBlocksStaleMutationButAcceptsLostAcknowledgementRetry(t *testing.T func TestTerminalReplayClassificationPreservesDistinctOutcomes(t *testing.T) { transactionID := "sha256:" + strings.Repeat("a", 64) + operational := errors.New("native observation failed") tests := []struct { name string result repositorytransaction.Result @@ -305,11 +306,17 @@ func TestTerminalReplayClassificationPreservesDistinctOutcomes(t *testing.T) { 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: "admitted replay", result: repositorytransaction.Result{AppliedCountKnown: true, State: repositorytransaction.StateAlreadySatisfied, TransactionID: transactionID}, wantState: repositorytransaction.StateAlreadySatisfied}, + {name: "historical result only", result: repositorytransaction.Result{AppliedCount: 3, AppliedCountKnown: true, RecoveredBy: repositorytransaction.RecoveryResume, State: repositorytransaction.StateApplied, TransactionID: transactionID}, wantFailureClass: "transaction_identity_mismatch"}, + {name: "read cleanup", err: repositorytransaction.ErrReadCleanup, wantError: repositorytransaction.ErrReadCleanup}, {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: "absent", err: repositorytransaction.ErrReplayMismatch, wantFailureClass: "transaction_identity_mismatch"}, + {name: "operational", err: operational, wantError: operational}, + {name: "mismatch and cleanup", err: errors.Join(repositorytransaction.ErrReplayMismatch, repositorytransaction.ErrReadCleanup), wantError: repositorytransaction.ErrReadCleanup}, + {name: "busy and cleanup", err: errors.Join(repositorytransaction.ErrBusy, repositorytransaction.ErrReadCleanup), wantError: repositorytransaction.ErrReadCleanup}, + {name: "pending and cleanup", err: errors.Join(repositorytransaction.ErrRecoveryRequired, repositorytransaction.ErrReadCleanup), wantError: repositorytransaction.ErrReadCleanup}, {name: "wrong terminal state", result: repositorytransaction.Result{AppliedCountKnown: true, State: repositorytransaction.StateRolledBack, TransactionID: transactionID}, wantFailureClass: "transaction_identity_mismatch"}, } for _, test := range tests { diff --git a/internal/command/adoptionmaterialization/build.go b/internal/command/adoptionmaterialization/build.go index f97a5b9..c365899 100644 --- a/internal/command/adoptionmaterialization/build.go +++ b/internal/command/adoptionmaterialization/build.go @@ -82,7 +82,13 @@ func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, if transactionHasChanges(plan.Transaction) { return blockedReceipt(OperationApply, expected, expectedDesired, "transaction_identity_mismatch", plan.NonClaims) } - terminal, terminalErr := repositorytransaction.ReadTerminalResult(ctx, repositoryRoot, expected) + terminal, terminalErr := repositorytransaction.ReplayApplied(ctx, repositoryRoot, plan.Transaction, expected) + if errors.Is(terminalErr, repositorytransaction.ErrReadCleanup) { + return Receipt{}, 1, terminalErr + } + if errors.Is(terminalErr, repositorytransaction.ErrRecoveryRequired) { + return pendingReceipt(OperationApply, expected, expectedDesired, terminalErr, plan.NonClaims) + } replay, failureClass, replayErr := classifyTerminalReplay(terminal, terminalErr) if replayErr != nil { return Receipt{}, 1, replayErr @@ -108,21 +114,19 @@ func Apply(ctx context.Context, raw any, repositoryRoot, expectedTransactionID, 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): + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, repositorytransaction.ErrReadCleanup): return repositorytransaction.Result{}, "", err case errors.Is(err, repositorytransaction.ErrBusy): return repositorytransaction.Result{}, "transaction_busy", nil - default: + case errors.Is(err, repositorytransaction.ErrReplayMismatch): return repositorytransaction.Result{}, "transaction_identity_mismatch", nil + default: + return repositorytransaction.Result{}, "", err } } - if result.State != repositorytransaction.StateApplied { + if result.State != repositorytransaction.StateAlreadySatisfied { return repositorytransaction.Result{}, "transaction_identity_mismatch", nil } - result.AppliedCount = 0 - result.AppliedCountKnown = true - result.RecoveredBy = "" - result.State = repositorytransaction.StateAlreadySatisfied return result, "", nil } diff --git a/internal/command/agentintegration/lifecycle.go b/internal/command/agentintegration/lifecycle.go new file mode 100644 index 0000000..5942528 --- /dev/null +++ b/internal/command/agentintegration/lifecycle.go @@ -0,0 +1,153 @@ +package agentintegration + +import ( + "context" + "errors" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +const ( + OperationInstall = "install" + OperationUpdate = "update" + OperationRemove = "remove" + OperationRecover = "recover" +) + +// PlanLifecycle recognizes only snapshots captured by the native transaction +// builder. The opaque result retains its executable plan privately. +func PlanLifecycle(ctx context.Context, root string, document Document, operation string) (LifecyclePlan, error) { + if ctx == nil { + return LifecyclePlan{}, fmt.Errorf("integration context is required") + } + if operation != OperationInstall && operation != OperationUpdate && operation != OperationRemove { + return LifecyclePlan{}, fmt.Errorf("integration operation must be install, update or remove") + } + if document.tool == "" || document.path == "" || document.content == "" || document.contentDigest == "" || document.identity == "" { + return LifecyclePlan{}, fmt.Errorf("integration source document is required") + } + baseline, err := currentBaseline(document) + if err != nil { + return LifecyclePlan{}, err + } + targets := []repositorytransaction.Target{ + {Path: document.path, Content: []byte(document.content), Mode: 0o644}, + {Path: baselinePath(document), Content: baseline, Mode: 0o644}, + } + if operation == OperationRemove { + for index := range targets { + targets[index] = repositorytransaction.Target{Path: targets[index].Path, Absent: true} + } + } + plan := LifecyclePlan{document: document, operation: operation, state: "ready"} + transaction, err := repositorytransaction.BuildPlan(ctx, root, targets) + if err != nil { + if errors.Is(err, repositorytransaction.ErrRecoveryRequired) { + plan.state, plan.failure = "recovery_required", "pending_transaction_state" + plan.recoveryTransactionID, _ = repositorytransaction.RecoveryTransactionID(err) + return plan, nil + } + if errors.Is(err, repositorytransaction.ErrBusy) { + plan.state, plan.failure = "blocked", "transaction_busy" + return plan, nil + } + return LifecyclePlan{}, err + } + if conflict := recognizeLifecyclePair(document, operation, transaction); conflict != "" { + plan.state, plan.failure = "blocked", conflict + return plan, nil + } + plan.transaction = &transaction + return plan, nil +} + +func ApplyLifecycle(ctx context.Context, root string, document Document, operation, expectedTransaction, expectedDesired string) (LifecycleReceipt, error) { + expected, err := admit.SHA256Ref(expectedTransaction, "integration expected transaction") + if err != nil { + return LifecycleReceipt{}, err + } + desired, err := admit.SHA256Ref(expectedDesired, "integration expected desired state") + if err != nil { + return LifecycleReceipt{}, err + } + plan, err := PlanLifecycle(ctx, root, document, operation) + if err != nil { + return LifecycleReceipt{}, err + } + receipt := LifecycleReceipt{tool: document.tool, operation: operation, expectedTransactionID: expected, expectedDesiredStateID: desired, state: plan.state, failure: plan.failure} + if plan.state != "ready" { + if plan.state == "recovery_required" { + result := repositorytransaction.Result{State: repositorytransaction.StateRecoveryRequired, FailureClass: plan.failure, TransactionID: plan.recoveryTransactionID} + receipt.result = &result + } + return receipt, nil + } + transaction := *plan.transaction + if transaction.DesiredStateID != desired { + receipt.state, receipt.failure = "blocked", "desired_state_identity_mismatch" + return receipt, nil + } + var result repositorytransaction.Result + if transaction.TransactionID != expected { + if lifecycleHasChanges(transaction) { + receipt.state, receipt.failure = "blocked", "transaction_identity_mismatch" + return receipt, nil + } + result, err = repositorytransaction.ReplayApplied(ctx, root, transaction, expected) + } else { + result, err = repositorytransaction.Apply(ctx, root, transaction) + } + return applyLifecycleResult(receipt, result, err) +} + +func applyLifecycleResult(receipt LifecycleReceipt, result repositorytransaction.Result, err error) (LifecycleReceipt, error) { + if err != nil { + switch { + case errors.Is(err, repositorytransaction.ErrReadCleanup), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + return LifecycleReceipt{}, err + case errors.Is(err, repositorytransaction.ErrReplayMismatch): + receipt.state, receipt.failure = "blocked", "transaction_identity_mismatch" + return receipt, nil + case errors.Is(err, repositorytransaction.ErrBusy): + receipt.state, receipt.failure = "blocked", "transaction_busy" + return receipt, nil + case errors.Is(err, repositorytransaction.ErrRecoveryRequired): + id, _ := repositorytransaction.RecoveryTransactionID(err) + result = repositorytransaction.Result{State: repositorytransaction.StateRecoveryRequired, FailureClass: "pending_transaction_state", TransactionID: id} + default: + return LifecycleReceipt{}, err + } + } + return receipt.withResult(result), nil +} + +func RecoverLifecycle(ctx context.Context, root, transactionID, action string) (LifecycleReceipt, error) { + if ctx == nil { + return LifecycleReceipt{}, fmt.Errorf("integration context is required") + } + expected, err := admit.SHA256Ref(transactionID, "integration recovery transaction") + if err != nil { + return LifecycleReceipt{}, err + } + result, err := repositorytransaction.Recover(ctx, root, expected, action) + receipt := LifecycleReceipt{operation: OperationRecover, expectedTransactionID: expected} + if errors.Is(err, repositorytransaction.ErrBusy) { + receipt.state, receipt.failure = "blocked", "transaction_busy" + return receipt, nil + } + if err != nil { + return LifecycleReceipt{}, err + } + return receipt.withResult(result), nil +} + +func lifecycleHasChanges(transaction repositorytransaction.Plan) bool { + for _, operation := range transaction.Operations { + if operation.Action != repositorytransaction.ActionUnchanged { + return true + } + } + return false +} diff --git a/internal/command/agentintegration/lifecycle_baseline.go b/internal/command/agentintegration/lifecycle_baseline.go new file mode 100644 index 0000000..cdf2a59 --- /dev/null +++ b/internal/command/agentintegration/lifecycle_baseline.go @@ -0,0 +1,110 @@ +package agentintegration + +import ( + "bytes" + "encoding/json" + "fmt" + + "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" +) + +const maximumBaselineBytes = 1024 + +func baselinePath(document Document) string { + return "proofkit/integrations/" + document.tool + ".v1.json" +} + +func baselineValue(document Document, snapshot repositorytransaction.Snapshot) map[string]any { + return map[string]any{ + "kind": "proofkit.integration-baseline", "schemaVersion": json.Number("1"), + "tool": document.tool, "targetPath": document.path, + "byteCount": json.Number(fmt.Sprint(snapshot.ByteCount)), + "contentDigest": snapshot.SHA256, "mode": "0644", + } +} + +func currentBaseline(document Document) ([]byte, error) { + return stablejson.MarshalLayout(baselineValue(document, repositorytransaction.Snapshot{ + ByteCount: int64(len(document.content)), Exists: true, Mode: 0o644, SHA256: document.contentDigest, + }), stablejson.LayoutCompact) +} + +func admitBaseline(content []byte, document Document) (repositorytransaction.Snapshot, error) { + raw, err := admission.DecodeJSON(bytes.NewReader(content), maximumBaselineBytes) + if err != nil { + return repositorytransaction.Snapshot{}, fmt.Errorf("integration baseline is invalid") + } + record, ok := raw.(map[string]any) + if !ok { + return repositorytransaction.Snapshot{}, fmt.Errorf("integration baseline must be an object") + } + if err := admit.KnownKeys(record, []string{"kind", "schemaVersion", "tool", "targetPath", "byteCount", "contentDigest", "mode"}, "integration baseline"); err != nil { + return repositorytransaction.Snapshot{}, err + } + if record["kind"] != "proofkit.integration-baseline" || !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["tool"] != document.tool || record["targetPath"] != document.path || record["mode"] != "0644" { + return repositorytransaction.Snapshot{}, fmt.Errorf("integration baseline identity is invalid") + } + count, err := admit.CanonicalInteger(record["byteCount"], "integration baseline byteCount") + if err != nil || count <= 0 || count > maximumCheckBytes { + return repositorytransaction.Snapshot{}, fmt.Errorf("integration baseline byteCount is invalid") + } + sha, err := admit.SHA256Ref(record["contentDigest"], "integration baseline contentDigest") + if err != nil { + return repositorytransaction.Snapshot{}, err + } + snapshot := repositorytransaction.Snapshot{Exists: true, ByteCount: count, SHA256: sha, Mode: 0o644} + canonical, err := stablejson.MarshalLayout(baselineValue(document, snapshot), stablejson.LayoutCompact) + if err != nil || !bytes.Equal(content, canonical) { + return repositorytransaction.Snapshot{}, fmt.Errorf("integration baseline encoding is not canonical") + } + return snapshot, nil +} + +func recognizeLifecyclePair(document Document, operation string, transaction repositorytransaction.Plan) string { + var bootstrap, baseline repositorytransaction.Operation + var baselineBytes []byte + for index, target := range transaction.Operations { + if target.Path == document.path { + bootstrap = target + } else if target.Path == baselinePath(document) { + baseline = target + baselineBytes, _ = transaction.BeforeContent(index) + } + } + if !baseline.Before.Exists { + if !bootstrap.Before.Exists { + if operation == OperationUpdate { + return "not_installed" + } + return "" + } + current := bootstrap.Before.Mode == 0o644 && bootstrap.Before.SHA256 == document.contentDigest && bootstrap.Before.ByteCount == int64(len(document.content)) + if !current { + return "unrecognized_bootstrap" + } + if operation != OperationInstall { + return "missing_baseline" + } + return "" + } + prior, err := admitBaseline(baselineBytes, document) + if err != nil || baseline.Before.Mode != 0o644 { + return "invalid_baseline" + } + if !bootstrap.Before.Exists { + if operation == OperationRemove { + return "" + } + return "orphan_baseline" + } + if bootstrap.Before != prior { + return "baseline_mismatch" + } + if operation == OperationInstall && lifecycleHasChanges(transaction) { + return "update_required" + } + return "" +} diff --git a/internal/command/agentintegration/lifecycle_output.go b/internal/command/agentintegration/lifecycle_output.go new file mode 100644 index 0000000..964a6c1 --- /dev/null +++ b/internal/command/agentintegration/lifecycle_output.go @@ -0,0 +1,128 @@ +package agentintegration + +import ( + "encoding/json" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +type LifecyclePlan struct { + document Document + operation string + state string + failure string + recoveryTransactionID string + transaction *repositorytransaction.Plan +} + +type LifecycleReceipt struct { + tool string + operation string + state string + failure string + expectedTransactionID string + expectedDesiredStateID string + result *repositorytransaction.Result +} + +func (plan LifecyclePlan) ExitCode() int { + if plan.state == "ready" { + return 0 + } + return 1 +} + +func (receipt LifecycleReceipt) ExitCode() int { + if receipt.state == "passed" { + return 0 + } + return 1 +} + +func (plan LifecyclePlan) JSONValue() map[string]any { + var child any + if plan.transaction != nil { + child = plan.transaction.JSONValue() + } + return map[string]any{ + "kind": "proofkit.integration-plan.v1", "schemaVersion": json.Number("1"), + "tool": plan.document.tool, "operation": plan.operation, "state": plan.state, + "failureClass": lifecycleNullable(plan.failure), + "recoveryTransactionId": lifecycleNullable(plan.recoveryTransactionID), + "transaction": child, "nonClaims": lifecycleNonClaims(), + } +} + +func (receipt LifecycleReceipt) JSONValue() map[string]any { + var child any + if receipt.result != nil { + child = receipt.result.JSONValue() + } + return map[string]any{ + "kind": "proofkit.integration-receipt.v1", "schemaVersion": json.Number("1"), + "tool": lifecycleNullable(receipt.tool), "operation": receipt.operation, "state": receipt.state, + "failureClass": lifecycleNullable(receipt.failure), + "expectedTransactionId": receipt.expectedTransactionID, + "expectedDesiredStateId": lifecycleNullable(receipt.expectedDesiredStateID), + "transactionResult": child, "nonClaims": lifecycleNonClaims(), + } +} + +func (receipt LifecycleReceipt) withResult(result repositorytransaction.Result) LifecycleReceipt { + receipt.result, receipt.failure = &result, result.FailureClass + switch result.State { + case repositorytransaction.StateApplied, repositorytransaction.StateAlreadySatisfied: + receipt.state = "passed" + case repositorytransaction.StateRolledBack: + receipt.state = "failed" + if receipt.operation == OperationRecover { + receipt.state = "passed" + } + case repositorytransaction.StateCleanupRequired, repositorytransaction.StateDurabilityUnknown, repositorytransaction.StateRecoveryRequired: + receipt.state = result.State + default: + receipt.state = "failed" + } + return receipt +} + +func (plan LifecyclePlan) Text() string { + text := fmt.Sprintf("Integration plan: %s\nTool: %s\nOperation: %s\n", plan.state, plan.document.tool, plan.operation) + if plan.transaction != nil { + text += fmt.Sprintf("Transaction: %s\nDesired state: %s\n", plan.transaction.TransactionID, plan.transaction.DesiredStateID) + for _, operation := range plan.transaction.Operations { + text += fmt.Sprintf("%s %s\n", operation.Action, operation.Path) + } + } + if plan.failure != "" { + text += "Reason: " + plan.failure + "\n" + } + if plan.recoveryTransactionID != "" { + text += "Pending transaction: " + plan.recoveryTransactionID + "\n" + } + return text + "File lifecycle only; baseline is cooperative bookkeeping, not authenticated origin or host activation.\n" +} + +func (receipt LifecycleReceipt) Text() string { + text := fmt.Sprintf("Integration %s: %s\nTransaction: %s\n", receipt.operation, receipt.state, receipt.expectedTransactionID) + if receipt.failure != "" { + text += "Reason: " + receipt.failure + "\n" + } + return text + "Recovery is historical; no current host activation or post-return file stability is proven.\n" +} + +func lifecycleNullable(text string) any { + if text == "" { + return nil + } + return text +} + +func lifecycleNonClaims() []any { + return []any{ + "A baseline is cooperative snapshot bookkeeping, not authenticated origin, owner approval, or protection from coordinated same-user edits or rollback.", + "File lifecycle operations do not prove native host discovery, loading, invocation, semantic correctness, merge approval, or production readiness.", + "Recovery results describe a historical transaction, not current installed or removed state; observations do not guarantee post-return stability.", + } +} diff --git a/internal/command/agentintegration/lifecycle_result_test.go b/internal/command/agentintegration/lifecycle_result_test.go new file mode 100644 index 0000000..fec28e4 --- /dev/null +++ b/internal/command/agentintegration/lifecycle_result_test.go @@ -0,0 +1,81 @@ +package agentintegration + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +func testLifecycleTerminalResultProjection(t *testing.T) { + id := "sha256:" + strings.Repeat("a", 64) + desired := "sha256:" + strings.Repeat("b", 64) + for _, test := range []struct { + native, apply, recover, failure string + known bool + count int + }{ + {"applied", "passed", "passed", "", true, 2}, + {"already_satisfied", "passed", "passed", "", true, 0}, + {"rolled_back", "failed", "passed", "cancelled", true, 0}, + {"cleanup_required", "cleanup_required", "cleanup_required", "cleanup_failed", true, 2}, + {"durability_unknown", "durability_unknown", "durability_unknown", "applied_cleanup_durability_unknown", true, 2}, + {"recovery_required", "recovery_required", "recovery_required", "ambiguous_target_state", false, 0}, + } { + for _, operation := range []string{OperationInstall, OperationUpdate, OperationRemove, OperationRecover} { + t.Run(test.native+"/"+operation, func(t *testing.T) { + seed := LifecycleReceipt{tool: "codex", operation: operation, expectedTransactionID: id, expectedDesiredStateID: desired} + want, recoveredBy := test.apply, "" + var tool, desiredValue any = "codex", desired + if operation == OperationRecover { + seed.tool, seed.expectedDesiredStateID = "", "" + want, recoveredBy, tool, desiredValue = test.recover, "resume", nil, nil + if test.native == "rolled_back" { + recoveredBy = "rollback" + } + } + native := repositorytransaction.Result{State: test.native, FailureClass: test.failure, AppliedCount: test.count, AppliedCountKnown: test.known, TransactionID: id, RecoveredBy: recoveredBy} + receipt, err := applyLifecycleResult(seed, native, nil) + if err != nil || receipt.result == nil || *receipt.result != native { + t.Fatalf("native result lost: %#v %v", receipt, err) + } + value := receipt.JSONValue() + var failure, count, recovery any + if test.failure != "" { + failure = test.failure + } + if test.known { + count = json.Number(fmt.Sprint(test.count)) + } + if recoveredBy != "" { + recovery = recoveredBy + } + if len(value) != 10 || value["kind"] != "proofkit.integration-receipt.v1" || value["schemaVersion"] != json.Number("1") || value["tool"] != tool || value["operation"] != operation || value["state"] != want || value["failureClass"] != failure || value["expectedTransactionId"] != id || value["expectedDesiredStateId"] != desiredValue || !reflect.DeepEqual(value["nonClaims"], lifecycleNonClaims()) { + t.Fatal("parent state, identity or failure projection changed") + } + child, ok := value["transactionResult"].(map[string]any) + if !ok || len(child) != 7 || child["state"] != test.native || child["appliedCount"] != count || child["failureClass"] != failure || child["recoveredBy"] != recovery || child["transactionId"] != id || child["schemaVersion"] != json.Number("1") { + t.Fatal("native child fields were dropped or rewritten") + } + if !reflect.DeepEqual(child, native.JSONValue()) { + t.Fatal("parent no longer retains the complete child-owned projection") + } + wantCode := 1 + if want == "passed" { + wantCode = 0 + } + text := fmt.Sprintf("Integration %s: %s\nTransaction: %s\n", operation, want, id) + if test.failure != "" { + text += "Reason: " + test.failure + "\n" + } + text += "Recovery is historical; no current host activation or post-return file stability is proven.\n" + if receipt.ExitCode() != wantCode || receipt.Text() != text { + t.Fatal("text or exit projection erased the terminal outcome") + } + }) + } + } +} diff --git a/internal/command/agentintegration/lifecycle_test.go b/internal/command/agentintegration/lifecycle_test.go new file mode 100644 index 0000000..05cee74 --- /dev/null +++ b/internal/command/agentintegration/lifecycle_test.go @@ -0,0 +1,299 @@ +package agentintegration + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +func lifecycleWrite(t *testing.T, root, path string, content []byte) { + t.Helper() + full := filepath.Join(root, filepath.FromSlash(path)) + checkMkdir(t, filepath.Dir(full)) + if err := os.WriteFile(full, content, 0o644); err != nil { + t.Fatal(err) + } +} + +func lifecyclePair(t *testing.T, root string, document Document) { + t.Helper() + baseline, err := currentBaseline(document) + if err != nil { + t.Fatal(err) + } + lifecycleWrite(t, root, document.path, []byte(document.content)) + lifecycleWrite(t, root, baselinePath(document), baseline) +} + +func lifecycleDocuments(t *testing.T, tool string) (Document, Document) { + t.Helper() + current, err := Source(tool, sourceCapabilities()) + if err != nil { + t.Fatal(err) + } + previous := checkTestDocument(t, tool) + if current.content == previous.content || current.identity == previous.identity { + t.Fatal("version fixtures must differ in consumed invocation contracts") + } + return current, previous +} + +func TestLifecyclePairRecognition(t *testing.T) { + for _, tool := range Tools() { + current, previous := lifecycleDocuments(t, tool) + for _, fixture := range []struct { + name string + seed func(*testing.T, string) + failures [3]string + }{ + {"absent", func(*testing.T, string) {}, [3]string{"", "not_installed", ""}}, + {"bootstrap only current", func(t *testing.T, root string) { lifecycleWrite(t, root, current.path, []byte(current.content)) }, [3]string{"", "missing_baseline", "missing_baseline"}}, + {"bootstrap only unknown", func(t *testing.T, root string) { + lifecycleWrite(t, root, current.path, []byte("owner-authored instructions\n")) + }, [3]string{"unrecognized_bootstrap", "unrecognized_bootstrap", "unrecognized_bootstrap"}}, + {"current pair", func(t *testing.T, root string) { lifecyclePair(t, root, current) }, [3]string{}}, + {"previous pair", func(t *testing.T, root string) { lifecyclePair(t, root, previous) }, [3]string{"update_required", "", ""}}, + {"orphan", func(t *testing.T, root string) { + lifecyclePair(t, root, current) + if err := os.Remove(filepath.Join(root, current.path)); err != nil { + t.Fatal(err) + } + }, [3]string{"orphan_baseline", "orphan_baseline", ""}}, + {"edited bootstrap", func(t *testing.T, root string) { + lifecyclePair(t, root, current) + lifecycleWrite(t, root, current.path, []byte(current.content+"owner edit\n")) + }, [3]string{"baseline_mismatch", "baseline_mismatch", "baseline_mismatch"}}, + {"wrong bootstrap mode", func(t *testing.T, root string) { + lifecyclePair(t, root, current) + if err := os.Chmod(filepath.Join(root, current.path), 0o600); err != nil { + t.Fatal(err) + } + }, [3]string{"baseline_mismatch", "baseline_mismatch", "baseline_mismatch"}}, + {"wrong baseline mode", func(t *testing.T, root string) { + lifecyclePair(t, root, current) + if err := os.Chmod(filepath.Join(root, baselinePath(current)), 0o600); err != nil { + t.Fatal(err) + } + }, [3]string{"invalid_baseline", "invalid_baseline", "invalid_baseline"}}, + {"invalid baseline", func(t *testing.T, root string) { + lifecyclePair(t, root, current) + lifecycleWrite(t, root, baselinePath(current), []byte("{}\n")) + }, [3]string{"invalid_baseline", "invalid_baseline", "invalid_baseline"}}, + } { + for index, operation := range []string{OperationInstall, OperationUpdate, OperationRemove} { + t.Run(tool+"/"+fixture.name+"/"+operation, func(t *testing.T) { + root := t.TempDir() + fixture.seed(t, root) + before := checkTree(t, root) + plan, err := PlanLifecycle(context.Background(), root, current, operation) + if err != nil || plan.failure != fixture.failures[index] { + t.Fatalf("recognition failure=%s want=%s error=%v", plan.failure, fixture.failures[index], err) + } + if plan.failure == "" { + if plan.state != "ready" || plan.ExitCode() != 0 || plan.transaction == nil { + t.Fatal("ready plan is incomplete") + } + if _, err := repositorytransaction.AdmitPlanOutput(plan.transaction.JSONValue()); err != nil { + t.Fatal(err) + } + } else if plan.state != "blocked" || plan.ExitCode() != 1 || plan.transaction != nil { + t.Fatal("conflict exposed an executable plan") + } + checkUnchanged(t, root, before) + }) + } + } + } +} + +func TestLifecycleBaselineCanonicalAdmission(t *testing.T) { + document := checkTestDocument(t, "codex") + canonical, err := currentBaseline(document) + if err != nil { + t.Fatal(err) + } + if _, err := admitBaseline(canonical, document); err != nil { + t.Fatal(err) + } + for _, mutation := range []struct { + name string + mutate func(map[string]any) + }{ + {"unknown", func(v map[string]any) { v["unknown"] = true }}, + {"kind", func(v map[string]any) { v["kind"] = "other" }}, + {"version", func(v map[string]any) { v["schemaVersion"] = 2 }}, + {"tool", func(v map[string]any) { v["tool"] = "claude" }}, + {"path", func(v map[string]any) { v["targetPath"] = "other.md" }}, + {"count zero", func(v map[string]any) { v["byteCount"] = 0 }}, + {"count string", func(v map[string]any) { v["byteCount"] = "1" }}, + {"count beyond bound", func(v map[string]any) { v["byteCount"] = maximumCheckBytes + 1 }}, + {"digest", func(v map[string]any) { v["contentDigest"] = "sha256:bad" }}, + {"mode", func(v map[string]any) { v["mode"] = "0600" }}, + } { + t.Run(mutation.name, func(t *testing.T) { + value := map[string]any{} + if err := json.Unmarshal(canonical, &value); err != nil { + t.Fatal(err) + } + mutation.mutate(value) + content, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if _, err := admitBaseline(append(content, '\n'), document); err == nil { + t.Fatal("invalid baseline admitted") + } + }) + } + for _, value := range [][]byte{canonical[:len(canonical)-1], append(append([]byte{}, canonical...), '\n'), []byte(strings.Replace(string(canonical), "{", "{\"kind\":\"duplicate\",", 1)), []byte(strings.Repeat(" ", maximumBaselineBytes+1))} { + if _, err := admitBaseline(value, document); err == nil { + t.Fatal("noncanonical baseline admitted") + } + } +} + +func TestLifecycleAppliesReplaysAndPreservesNeighbors(t *testing.T) { + for _, tool := range Tools() { + t.Run(tool, func(t *testing.T) { + root := t.TempDir() + current, previous := lifecycleDocuments(t, tool) + lifecycleWrite(t, root, "AGENTS.md", []byte("owner instructions\n")) + otherTool := "claude" + if tool == otherTool { + otherTool = "codex" + } + other := checkTestDocument(t, otherTool) + lifecyclePair(t, root, other) + protectedBootstrap, err := os.ReadFile(filepath.Join(root, other.path)) + if err != nil { + t.Fatal(err) + } + for index, step := range []struct { + document Document + operation string + }{{previous, OperationInstall}, {current, OperationUpdate}, {current, OperationRemove}} { + plan, err := PlanLifecycle(context.Background(), root, step.document, step.operation) + if err != nil || plan.ExitCode() != 0 || !lifecycleHasChanges(*plan.transaction) { + t.Fatalf("step %d plan failed: %v", index, err) + } + receipt, err := ApplyLifecycle(context.Background(), root, step.document, step.operation, plan.transaction.TransactionID, plan.transaction.DesiredStateID) + if err != nil || receipt.ExitCode() != 0 || receipt.result.State != repositorytransaction.StateApplied { + t.Fatalf("step %d apply failed: %v", index, err) + } + before := checkTree(t, root) + replay, err := ApplyLifecycle(context.Background(), root, step.document, step.operation, plan.transaction.TransactionID, plan.transaction.DesiredStateID) + if err != nil || replay.ExitCode() != 0 || replay.result.State != repositorytransaction.StateAlreadySatisfied || replay.result.TransactionID != plan.transaction.TransactionID { + t.Fatalf("step %d replay failed: %v", index, err) + } + checkUnchanged(t, root, before) + for _, target := range plan.transaction.Operations { + content, err := os.ReadFile(filepath.Join(root, target.Path)) + if step.operation == OperationRemove { + if !os.IsNotExist(err) { + t.Fatal("remove retained a selected file") + } + continue + } + if err != nil { + t.Fatal(err) + } + if target.Path == current.path && string(content) != step.document.content { + t.Fatal("installed bytes differ from document") + } + if target.Path == baselinePath(current) { + if _, err := admitBaseline(content, step.document); err != nil { + t.Fatal(err) + } + } + } + } + otherBytes, err := os.ReadFile(filepath.Join(root, other.path)) + if err != nil || !reflect.DeepEqual(otherBytes, protectedBootstrap) { + t.Fatal("unselected tool changed") + } + instructions, err := os.ReadFile(filepath.Join(root, "AGENTS.md")) + if err != nil || string(instructions) != "owner instructions\n" { + t.Fatal("neighbor instructions changed") + } + }) + } +} + +func TestLifecycleRejectsChangedIdentityAndLocalEdits(t *testing.T) { + t.Run("terminal result projection", testLifecycleTerminalResultProjection) + t.Run("native outcome projection", func(t *testing.T) { + id := "sha256:" + strings.Repeat("a", 64) + seed := LifecycleReceipt{tool: "codex", operation: OperationInstall, expectedTransactionID: id, expectedDesiredStateID: id} + operational := errors.New("native observation failed") + for _, outcome := range []struct { + err error + state string + failure string + }{ + {repositorytransaction.ErrReplayMismatch, "blocked", "transaction_identity_mismatch"}, + {repositorytransaction.ErrBusy, "blocked", "transaction_busy"}, + {&repositorytransaction.RecoveryRequiredError{TransactionID: id}, "recovery_required", "pending_transaction_state"}, + {operational, "", ""}, + } { + for _, terminalError := range []error{nil, repositorytransaction.ErrReadCleanup, context.Canceled, context.DeadlineExceeded} { + nativeError := errors.Join(outcome.err, terminalError) + receipt, err := applyLifecycleResult(seed, repositorytransaction.Result{}, nativeError) + if terminalError != nil || outcome.err == operational { + if !errors.Is(err, outcome.err) || terminalError != nil && !errors.Is(err, terminalError) || receipt != (LifecycleReceipt{}) { + t.Fatalf("operational error became a packet: %#v %v", receipt, err) + } + } else if err != nil || receipt.state != outcome.state || receipt.failure != outcome.failure || receipt.expectedTransactionID != id || receipt.expectedDesiredStateID != id { + t.Fatalf("native classification changed: %#v %v", receipt, err) + } + } + } + }) + for _, mutation := range []string{"desired", "transaction", "local edit", "baseline edit", "symlink", "cancelled"} { + t.Run(mutation, func(t *testing.T) { + root := t.TempDir() + document, previous := lifecycleDocuments(t, "codex") + lifecyclePair(t, root, previous) + plan, err := PlanLifecycle(context.Background(), root, document, OperationUpdate) + if err != nil || plan.ExitCode() != 0 { + t.Fatal("fixture plan failed") + } + tx, desired := plan.transaction.TransactionID, plan.transaction.DesiredStateID + ctx := context.Background() + switch mutation { + case "desired": + desired = "sha256:" + strings.Repeat("0", 64) + case "transaction": + tx = "sha256:" + strings.Repeat("0", 64) + case "local edit": + lifecycleWrite(t, root, previous.path, []byte("private local edit\n")) + case "baseline edit": + lifecycleWrite(t, root, baselinePath(previous), []byte("{}\n")) + case "symlink": + if err := os.Remove(filepath.Join(root, previous.path)); err != nil { + t.Fatal(err) + } + checkSymlink(t, "missing", filepath.Join(root, previous.path)) + case "cancelled": + cancelled, cancel := context.WithCancel(ctx) + cancel() + ctx = cancelled + } + before := checkTree(t, root) + receipt, err := ApplyLifecycle(ctx, root, document, OperationUpdate, tx, desired) + if err == nil && receipt.ExitCode() == 0 { + t.Fatal("changed precondition was accepted") + } + if err == nil && strings.Contains(receipt.Text(), "private local edit") { + t.Fatal("caller content reached output") + } + checkUnchanged(t, root, before) + }) + } +} diff --git a/internal/command/agentintegration/source.go b/internal/command/agentintegration/source.go index ec6ad84..8cee8da 100644 --- a/internal/command/agentintegration/source.go +++ b/internal/command/agentintegration/source.go @@ -1,5 +1,6 @@ // Package agentintegration owns portable CLI bootstrap materialization and -// read-only freshness. It does not own host activation or installation. +// read-only freshness, and explicit managed file lifecycle. Host activation +// remains outside this package's authority. package agentintegration import ( diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 29d7b66..626710a 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 = "846a642fbe1bfb9a59502c7018788667b5b6bd711e5fc45534f1846bc440e344" +const presetContractSourceSHA256 = "bcbfee8a2bc84bf17a433e8cc05e34c9df4460d68b323177e579b0a8b8ff34fe" 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/cleanup.go b/internal/kernel/repositorytransaction/cleanup.go index cd9a271..4450aab 100644 --- a/internal/kernel/repositorytransaction/cleanup.go +++ b/internal/kernel/repositorytransaction/cleanup.go @@ -65,7 +65,7 @@ func (runtime engine) compactTerminalTombstone(root *os.Root, tombstone string, } if plan != nil { want, relationErr := terminalReceiptFromResult(*plan, receipt.result()) - if relationErr != nil || want != receipt { + if relationErr != nil || !terminalReceiptMatchesPlan(receipt, want) { return fmt.Errorf("repository transaction terminal receipt does not match its plan") } } diff --git a/internal/kernel/repositorytransaction/control_state.go b/internal/kernel/repositorytransaction/control_state.go index e63ddc7..0fb09f8 100644 --- a/internal/kernel/repositorytransaction/control_state.go +++ b/internal/kernel/repositorytransaction/control_state.go @@ -56,7 +56,9 @@ func validateTransactionEntries(entries []fs.DirEntry, plan *Plan, allowPartialT if operation.Action == ActionUnchanged { continue } - allowed[strings.TrimPrefix(afterObjectPath(index), activeDirectory+"/")] = struct{}{} + if operation.After.Exists { + 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{}{} diff --git a/internal/kernel/repositorytransaction/delete_cancellation_test.go b/internal/kernel/repositorytransaction/delete_cancellation_test.go new file mode 100644 index 0000000..855b8ec --- /dev/null +++ b/internal/kernel/repositorytransaction/delete_cancellation_test.go @@ -0,0 +1,66 @@ +package repositorytransaction + +import ( + "context" + "errors" + "reflect" + "testing" +) + +func testDeletionCancellationCommitBoundary(t *testing.T) { + for _, test := range []struct { + name string + cancelAt int + cleanup failurePoint + state string + failure string + finalState string + }{ + {"deleted-prefix", 1, "", StateRolledBack, "cancelled", StateRolledBack}, + {"final-effect", 3, "", StateApplied, "", StateApplied}, + {"final-effect-cleanup", 3, faultBeforeCleanup, StateCleanupRequired, "injected_cleanup_failure", StateApplied}, + {"final-effect-durability", 3, faultAfterStateRemoval, StateDurabilityUnknown, "applied_cleanup_durability_unknown", StateApplied}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + plan := seedDeletionRecovery(t, root) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cancelled := false + runtime := engine{fault: func(point failurePoint, index int) error { + if point == faultAfterPublish && index == test.cancelAt { + cancelled = true + cancel() + } + if test.cleanup != "" && point == test.cleanup { + return errors.New("terminal cleanup failure") + } + return nil + }} + result, err := runtime.apply(ctx, root, plan) + count := 3 + if test.finalState == StateRolledBack { + count = 0 + } + if !cancelled || ctx.Err() != context.Canceled || err != nil || result.State != test.state || result.FailureClass != test.failure || result.TransactionID != plan.TransactionID || !result.AppliedCountKnown || result.AppliedCount != count { + t.Fatalf("cancellation crossed the commit boundary: %#v %v", result, err) + } + assertDeletionRecoveryFiles(t, root, test.finalState) + action := RecoveryResume + if test.finalState == StateRolledBack { + action = RecoveryRollback + } + recovered, err := Recover(context.Background(), root, plan.TransactionID, action) + if err != nil || recovered.State != test.finalState || recovered.TransactionID != plan.TransactionID || recovered.RecoveredBy != action { + t.Fatalf("terminal recovery changed cancellation result: %#v %v", recovered, err) + } + assertDeletionRecoveryFiles(t, root, test.finalState) + assertNoPendingTransaction(t, root) + before := snapshotTestTree(t, root) + replayed, err := Recover(context.Background(), root, plan.TransactionID, action) + if err != nil || replayed != recovered || !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Fatalf("terminal retry changed the retained outcome: %#v %v", replayed, err) + } + }) + } +} diff --git a/internal/kernel/repositorytransaction/delete_recovery_test.go b/internal/kernel/repositorytransaction/delete_recovery_test.go new file mode 100644 index 0000000..5adcd06 --- /dev/null +++ b/internal/kernel/repositorytransaction/delete_recovery_test.go @@ -0,0 +1,188 @@ +package repositorytransaction + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +func deletionRecoveryTargets() []Target { + return []Target{ + {Path: "a", Absent: true}, + {Path: "b", Content: []byte("new-b"), Mode: 0o644}, + {Path: "c", Mode: 0o644}, + {Path: "d/missing", Absent: true}, + {Path: "z/new", Content: []byte("new-z"), Mode: 0o644}, + } +} + +func seedDeletionRecovery(t *testing.T, root string) Plan { + t.Helper() + mustWriteTestFile(t, root, "a", "old-a", 0o600) + mustWriteTestFile(t, root, "b", "old-b", 0o640) + mustWriteTestFile(t, root, "c", "", 0o644) + plan, err := BuildPlan(context.Background(), root, deletionRecoveryTargets()) + if err != nil { + t.Fatal(err) + } + return plan +} + +func assertDeletionRecoveryFiles(t *testing.T, root, state string) { + t.Helper() + assertTestFile(t, root, "c", "", 0o644) + assertAbsentTestPath(t, root, "d") + if state == StateApplied { + assertAbsentTestPath(t, root, "a") + assertTestFile(t, root, "b", "new-b", 0o644) + assertTestFile(t, root, "z/new", "new-z", 0o644) + } else { + assertTestFile(t, root, "a", "old-a", 0o600) + assertTestFile(t, root, "b", "old-b", 0o640) + assertAbsentTestPath(t, root, "z") + } +} + +func TestDeletionRecoversEveryMixedPrefixAndHistoricalResult(t *testing.T) { + t.Run("observation-partition", testRecoveryObservationPartition) + t.Run("cancellation-commit-boundary", testDeletionCancellationCommitBoundary) + for _, action := range []string{RecoveryResume, RecoveryRollback} { + for prefix := 0; prefix <= 3; prefix++ { + t.Run(fmt.Sprintf("%s-%d", action, prefix), func(t *testing.T) { + rootPath := t.TempDir() + plan := seedDeletionRecovery(t, rootPath) + leaveDeletionPrefix(t, rootPath, plan, prefix) + result, err := Recover(context.Background(), rootPath, plan.TransactionID, action) + want := StateApplied + if action == RecoveryRollback { + want = StateRolledBack + } + if err != nil || result.State != want || result.RecoveredBy != action { + t.Fatalf("recover: %#v %v", result, err) + } + assertDeletionRecoveryFiles(t, rootPath, want) + assertNoPendingTransaction(t, rootPath) + if action == RecoveryResume { + mustWriteTestFile(t, rootPath, "a", "recreated", 0o644) + } + before := snapshotTestTree(t, rootPath) + repeated, err := Recover(context.Background(), rootPath, plan.TransactionID, action) + if err != nil || repeated != result || !reflect.DeepEqual(before, snapshotTestTree(t, rootPath)) { + t.Fatalf("historical recovery changed result or repository: %#v %v", repeated, err) + } + }) + } + } +} + +func TestDeletionRejectsMissingCorruptAndUnexpectedStagedObjects(t *testing.T) { + for _, corruption := range []string{"missing-before", "corrupt-before", "unexpected-after", "unexpected-unchanged"} { + t.Run(corruption, func(t *testing.T) { + root := t.TempDir() + plan := seedDeletionRecovery(t, root) + leaveDeletionPrefix(t, root, plan, 1) + switch corruption { + case "missing-before": + if err := os.Remove(filepath.Join(root, beforeObjectPath(0))); err != nil { + t.Fatal(err) + } + case "corrupt-before": + mustWriteTestFile(t, root, beforeObjectPath(0), "wrong", 0o600) + case "unexpected-after": + mustWriteTestFile(t, root, afterObjectPath(0), "", 0o600) + case "unexpected-unchanged": + mustWriteTestFile(t, root, afterObjectPath(3), "", 0o600) + } + before := snapshotTestTree(t, root) + result, err := Recover(context.Background(), root, plan.TransactionID, RecoveryRollback) + if err != nil || result.State != StateRecoveryRequired || !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Fatalf("invalid staging: %#v %v", result, err) + } + }) + } +} + +func TestDeletionProcessInterruptionAtMutationBoundaries(t *testing.T) { + if point := os.Getenv("PROOFKIT_DELETE_CRASH_POINT"); point != "" { + root := os.Getenv("PROOFKIT_DELETE_CRASH_ROOT") + plan, err := BuildPlan(context.Background(), root, deletionRecoveryTargets()) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(actual failurePoint, index int) error { + if actual == failurePoint(point) { + os.Exit(73) + } + if point == string(faultAfterRollback) && actual == faultAfterPublish && index == 2 { + return errors.New("trigger reverse rollback") + } + return nil + }} + result, err := runtime.apply(context.Background(), root, plan) + t.Fatalf("crash boundary not reached: %#v %v", result, err) + } + for _, point := range []failurePoint{faultAfterJournal, faultAfterStaging, faultAfterReady, faultAfterDirectory, faultBeforePublish, faultAfterPublish, faultAfterRollback, faultAfterTerminal, faultBeforeCleanup, faultAfterStateRemoval} { + t.Run(string(point), func(t *testing.T) { + root := t.TempDir() + plan := seedDeletionRecovery(t, root) + command := exec.Command(os.Args[0], "-test.run=^TestDeletionProcessInterruptionAtMutationBoundaries$") + command.Env = append(os.Environ(), "PROOFKIT_DELETE_CRASH_POINT="+string(point), "PROOFKIT_DELETE_CRASH_ROOT="+root) + err := command.Run() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 73 { + t.Fatalf("crash helper: %v", err) + } + action, want := RecoveryRollback, StateRolledBack + if point == faultAfterTerminal || point == faultBeforeCleanup || point == faultAfterStateRemoval { + action, want = RecoveryResume, StateApplied + } + result, err := Recover(context.Background(), root, plan.TransactionID, action) + if err != nil || result.State != want || result.RecoveredBy != action { + t.Fatalf("recover: %#v %v", result, err) + } + assertDeletionRecoveryFiles(t, root, want) + assertNoPendingTransaction(t, root) + }) + } +} + +func leaveDeletionPrefix(t *testing.T, rootPath string, plan Plan, prefix int) { + t.Helper() + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + 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) + } + // Recovery input uses direct effects, not the forward executor under test. + if prefix > 0 { + if err := os.Remove(filepath.Join(rootPath, "a")); err != nil { + t.Fatal(err) + } + } + if prefix > 1 { + mustWriteTestFile(t, rootPath, "b", "new-b", 0o644) + if err := os.Chmod(filepath.Join(rootPath, "b"), 0o644); err != nil { + t.Fatal(err) + } + } + if prefix > 2 { + mustWriteTestFile(t, rootPath, "z/new", "new-z", 0o644) + } +} diff --git a/internal/kernel/repositorytransaction/delete_test.go b/internal/kernel/repositorytransaction/delete_test.go new file mode 100644 index 0000000..3faf4c7 --- /dev/null +++ b/internal/kernel/repositorytransaction/delete_test.go @@ -0,0 +1,155 @@ +package repositorytransaction + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestAbsentTargetsDistinguishDeletionEmptyAndUnchanged(t *testing.T) { + for _, test := range []struct { + name, before, action, version string + exists, absent bool + }{ + {"missing-absent", "", ActionUnchanged, "2", false, true}, + {"empty-delete", "", ActionDelete, "2", true, true}, + {"text-delete", "old", ActionDelete, "2", true, true}, + {"missing-empty", "", ActionCreate, "1", false, false}, + {"empty-unchanged", "", ActionUnchanged, "1", true, false}, + {"text-to-empty", "old", ActionReplace, "1", true, false}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + if test.exists { + mustWriteTestFile(t, root, "a/target", test.before, 0o644) + } + target := Target{Path: "a/target", Absent: test.absent} + if !target.Absent { + target.Mode = 0o644 + } + plan, err := BuildPlan(context.Background(), root, []Target{target}) + if err != nil { + t.Fatal(err) + } + if plan.schemaVersion() != json.Number(test.version) || plan.Operations[0].Action != test.action { + t.Fatalf("wrong independent action/version: %#v", plan) + } + if target.Absent && (plan.Operations[0].After != (Snapshot{}) || len(plan.CreatedDirectories) != 0) { + t.Fatal("absence gained file metadata or directory creation") + } + assertConstructionRejected(t, root, readmittedConstructionPlan(t, plan)) + result, err := Apply(context.Background(), root, plan) + want := StateApplied + if test.action == ActionUnchanged { + want = StateAlreadySatisfied + } + if err != nil || result.State != want || result.TransactionID != plan.TransactionID { + t.Fatalf("apply: %#v %v", result, err) + } + if target.Absent { + assertAbsentTestPath(t, root, target.Path) + } else { + assertTestFile(t, root, target.Path, "", 0o644) + } + replay, err := Apply(context.Background(), root, plan) + if err != nil || replay.State != StateAlreadySatisfied { + t.Fatalf("repeat: %#v %v", replay, err) + } + }) + } +} + +func TestAbsentTargetRejectsPayloadAndNoncanonicalVersionWithoutMutation(t *testing.T) { + root := t.TempDir() + for _, target := range []Target{ + {Absent: true, Path: "a", Content: []byte("forbidden")}, + {Absent: true, Path: "a", Mode: 0o644}, + } { + before := snapshotTestTree(t, root) + if _, err := BuildPlan(context.Background(), root, []Target{target}); err == nil { + t.Fatal("absent target admitted payload") + } + if !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Fatal("invalid absence changed repository") + } + } + for _, absent := range []bool{false, true} { + target := Target{Path: "a", Absent: absent} + if !absent { + target.Mode = 0o644 + } + plan, err := BuildPlan(context.Background(), root, []Target{target}) + if err != nil { + t.Fatal(err) + } + wire := plan.JSONValue() + wrong := json.Number("2") + if absent { + wrong = "1" + } + wire["schemaVersion"] = wrong + if _, err := AdmitPlanOutput(wire); err == nil || !strings.Contains(err.Error(), "schema") { + t.Fatalf("wrong version did not reach semantic version predicate: %v", err) + } + } +} + +func TestMixedAbsentTargetDoesNotRequireOrCreateItsParents(t *testing.T) { + root := t.TempDir() + plan, err := BuildPlan(context.Background(), root, []Target{ + {Path: "a/b/file", Content: []byte("created"), Mode: 0o644}, + {Path: "a/c/file", Absent: true}, + }) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(plan.CreatedDirectories, []string{"a", "a/b"}) { + t.Fatalf("wrong directory closure: %v", plan.CreatedDirectories) + } + if result, err := Apply(context.Background(), root, plan); err != nil || result.State != StateApplied { + t.Fatalf("apply: %#v %v", result, err) + } + assertAbsentTestPath(t, root, "a/c") + assertTestFile(t, root, "a/b/file", "created", 0o644) +} + +func TestDeleteChecksLastBeforeImageAndPreservesForeignRecreation(t *testing.T) { + for _, afterDelete := range []bool{false, true} { + t.Run(map[bool]string{false: "before-unlink", true: "after-unlink"}[afterDelete], func(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "a", "old", 0o600) + mustWriteTestFile(t, root, "sibling", "untouched", 0o644) + plan, err := BuildPlan(context.Background(), root, []Target{{Path: "a", Absent: true}}) + if err != nil { + t.Fatal(err) + } + runtime := engine{fault: func(point failurePoint, index int) error { + if !afterDelete && point == faultBeforePublish || afterDelete && point == faultAfterPublish { + mustWriteTestFile(t, root, "a", "foreign", 0o600) + if afterDelete { + return errors.New("stop after recreation") + } + } + return nil + }} + result, err := runtime.apply(context.Background(), root, plan) + if err != nil || result.State != StateRecoveryRequired { + t.Fatalf("foreign conflict: %#v %v", result, err) + } + assertTestFile(t, root, "a", "foreign", 0o600) + assertTestFile(t, root, "sibling", "untouched", 0o644) + }) + } +} + +func assertAbsentTestPath(t *testing.T, root, relative string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(relative))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected absence of %s: %v", relative, err) + } +} diff --git a/internal/kernel/repositorytransaction/deletion_filesystem_linux_test.go b/internal/kernel/repositorytransaction/deletion_filesystem_linux_test.go new file mode 100644 index 0000000..c72b636 --- /dev/null +++ b/internal/kernel/repositorytransaction/deletion_filesystem_linux_test.go @@ -0,0 +1,37 @@ +//go:build linux + +package repositorytransaction + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestDeletionRejectsDifferentFilesystemBeforeControlMutation(t *testing.T) { + root := os.Getenv("PROOFKIT_CROSS_FILESYSTEM_ROOT") + if root == "" { + t.Skip("requires the isolated dual-filesystem fixture supplied by the source-quality CI step") + } + left, err := os.Stat(root) + if err != nil { + t.Fatal(err) + } + right, err := os.Stat(filepath.Join(root, "other")) + if err != nil { + t.Fatal(err) + } + same, err := platformSameFilesystem(left, right) + if err != nil || same { + t.Fatal("fixture must expose two distinct filesystems") + } + mustWriteTestFile(t, root, "other/owned", "before", 0o644) + before := snapshotTestTree(t, root) + _, err = BuildPlan(context.Background(), root, []Target{{Path: "other/owned", Absent: true}}) + if err == nil || !strings.Contains(err.Error(), "filesystem") || !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Fatalf("cross-filesystem deletion was not rejected before effects: %v", err) + } +} diff --git a/internal/kernel/repositorytransaction/execution.go b/internal/kernel/repositorytransaction/execution.go index d38da64..b6dad64 100644 --- a/internal/kernel/repositorytransaction/execution.go +++ b/internal/kernel/repositorytransaction/execution.go @@ -10,6 +10,13 @@ import ( ) func (runtime engine) applyForward(ctx context.Context, root *os.Root, plan Plan, prefix int) error { + for _, operation := range plan.Operations { + if operation.Action == ActionDelete { + if err := verifyDeletionFilesystem(root, operation.Path); err != nil { + return err + } + } + } if err := ensureTargetDirectories(root, plan); err != nil { return err } @@ -33,7 +40,14 @@ func (runtime engine) applyForward(ctx context.Context, root *os.Root, plan Plan 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 { + if operation.Action == ActionDelete { + if err := verifyDeletionFilesystem(root, operation.Path); err != nil { + return err + } + if err := removeExactTarget(root, operation.Path, operation.Before); err != nil { + return err + } + } else if err := publishContent(root, plan, operationIndex, operation.Before, operation.afterContent, operation.After.Mode); err != nil { return err } changedIndex++ diff --git a/internal/kernel/repositorytransaction/filesystem.go b/internal/kernel/repositorytransaction/filesystem.go index 5275c23..bcf23b7 100644 --- a/internal/kernel/repositorytransaction/filesystem.go +++ b/internal/kernel/repositorytransaction/filesystem.go @@ -440,14 +440,52 @@ func publishContent(root *os.Root, plan Plan, operationIndex int, expected Snaps } func removeCreatedTarget(root *os.Root, operation Operation) error { - observed, _, err := inspectTarget(root, operation.Path, MaximumFileBytes) - if err != nil || !equalSnapshot(observed, operation.After) { + return removeExactTarget(root, operation.Path, operation.After) +} + +func removeExactTarget(root *os.Root, targetPath string, expected Snapshot) error { + observed, _, err := inspectTarget(root, targetPath, MaximumFileBytes) + if err != nil || !expected.Exists || !equalSnapshot(observed, expected) { 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") + if err := root.Remove(filepath.FromSlash(targetPath)); err != nil { + return fmt.Errorf("remove repository transaction target") + } + if err := syncDirectory(root, path.Dir(targetPath)); err != nil { + return err + } + observed, _, err = inspectTarget(root, targetPath, MaximumFileBytes) + if err != nil || observed.Exists { + return fmt.Errorf("repository transaction target is not absent after removal") + } + return nil +} + +func verifyDeletionFilesystem(root *os.Root, targetPath string) error { + if _, err := inspectParentDirectories(root, path.Dir(targetPath)); err != nil { + return err + } + missing, err := inspectParentDirectories(root, activeDirectory) + if err != nil { + return err + } + stagingAncestor := activeDirectory + if len(missing) > 0 { + stagingAncestor = path.Dir(missing[0]) + } + staging, err := root.Lstat(filepath.FromSlash(stagingAncestor)) + if err != nil || !staging.IsDir() { + return fmt.Errorf("repository transaction staging filesystem is unavailable") + } + parent, err := root.Lstat(filepath.FromSlash(path.Dir(targetPath))) + if err != nil || !parent.IsDir() { + return fmt.Errorf("repository transaction deletion parent is unavailable") } - return syncDirectory(root, path.Dir(operation.Path)) + same, err := platformSameFilesystem(staging, parent) + if err != nil || !same { + return fmt.Errorf("repository transaction deletion requires same-filesystem rollback") + } + return nil } func transactionTemporaryPath(transactionID string, index int, targetPath string) string { diff --git a/internal/kernel/repositorytransaction/invariant_test.go b/internal/kernel/repositorytransaction/invariant_test.go index 8b0fe49..be0d971 100644 --- a/internal/kernel/repositorytransaction/invariant_test.go +++ b/internal/kernel/repositorytransaction/invariant_test.go @@ -132,7 +132,7 @@ func TestDirectoryOwnershipRejectsPortableRouteAlias(t *testing.T) { t.Fatal(err) } result, err := Recover(context.Background(), rootPath, plan.TransactionID, RecoveryRollback) - if err != nil || result.State != StateRecoveryRequired || result.FailureClass != "ambiguous_target_state" { + if err == nil || result != (Result{}) || !strings.Contains(err.Error(), "portable filesystem identity") { t.Fatalf("Recover() result=%#v error=%v", result, err) } if info, err := os.Stat(filepath.Join(rootPath, "New")); err != nil || !info.IsDir() { diff --git a/internal/kernel/repositorytransaction/journal.go b/internal/kernel/repositorytransaction/journal.go index 3d31f5a..166ab3e 100644 --- a/internal/kernel/repositorytransaction/journal.go +++ b/internal/kernel/repositorytransaction/journal.go @@ -56,8 +56,10 @@ func stageObjects(root *os.Root, plan Plan) error { if operation.Action == ActionUnchanged { continue } - if err := writeOwnedFile(root, afterObjectPath(index), operation.afterContent, 0o600); err != nil { - return err + if operation.After.Exists { + 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 { @@ -94,11 +96,13 @@ func loadObjects(root *os.Root, plan Plan) (Plan, error) { 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") + if operation.After.Exists { + 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 } - operation.afterContent = after if operation.Before.Exists { before, err := readOwnedFile(root, beforeObjectPath(index), MaximumFileBytes) if err != nil || !contentMatches(before, operation.Before) { diff --git a/internal/kernel/repositorytransaction/journal_admission.go b/internal/kernel/repositorytransaction/journal_admission.go index 26e92be..5468aa0 100644 --- a/internal/kernel/repositorytransaction/journal_admission.go +++ b/internal/kernel/repositorytransaction/journal_admission.go @@ -1,7 +1,6 @@ package repositorytransaction import ( - "encoding/json" "fmt" "path" @@ -22,7 +21,7 @@ func journalValue(plan Plan) map[string]any { "journalKind": "proofkit.repository-write-journal", "operations": operations, "rootId": plan.RootID, - "schemaVersion": json.Number("1"), + "schemaVersion": plan.schemaVersion(), "transactionId": plan.TransactionID, } } @@ -35,7 +34,7 @@ func admitJournal(raw any) (Plan, error) { 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" { + if (!admit.JSONNumberEquals(record["schemaVersion"], 1) && !admit.JSONNumberEquals(record["schemaVersion"], 2)) || 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") @@ -77,6 +76,9 @@ func admitJournal(raw any) (Plan, error) { operations = append(operations, operation) } plan := Plan{CreatedDirectories: directories, DesiredStateID: desiredStateID, Operations: operations, RootID: rootID, TransactionID: transactionID} + if record["schemaVersion"] != plan.schemaVersion() { + return Plan{}, fmt.Errorf("repository transaction journal schema does not match its target semantics") + } if err := validatePlanShape(plan); err != nil { return Plan{}, err } @@ -110,7 +112,7 @@ func admitOperation(raw any, index int) (Operation, error) { return Operation{}, fmt.Errorf("repository transaction operation overlaps its control directory") } action, ok := record["action"].(string) - if !ok || (action != ActionCreate && action != ActionReplace && action != ActionUnchanged) { + if !ok || (action != ActionCreate && action != ActionReplace && action != ActionDelete && 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)) @@ -118,17 +120,10 @@ func admitOperation(raw any, index int) (Operation, error) { return Operation{}, err } after, err := admitSnapshot(record["after"], fmt.Sprintf("repository transaction operation %d after", index)) - if err != nil || !after.Exists { + if err != nil { 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 { + if action != snapshotAction(before, after) { return Operation{}, fmt.Errorf("repository transaction operation %d action contradicts its snapshots", index) } return Operation{Action: action, After: after, Before: before, Path: targetPath}, nil @@ -201,7 +196,7 @@ func validatePlanShape(plan Plan) error { } ownsTarget := false for _, operation := range plan.Operations { - if isLexicalDescendant(operation.Path, directory) { + if operation.After.Exists && isLexicalDescendant(operation.Path, directory) { ownsTarget = true break } @@ -233,7 +228,7 @@ func validatePlanShape(plan Plan) error { } continue } - if createdAncestor { + if createdAncestor && operation.After.Exists { return fmt.Errorf("repository transaction created directory chain is incomplete") } } diff --git a/internal/kernel/repositorytransaction/model.go b/internal/kernel/repositorytransaction/model.go index cef141b..3407e3f 100644 --- a/internal/kernel/repositorytransaction/model.go +++ b/internal/kernel/repositorytransaction/model.go @@ -20,6 +20,7 @@ const ( const ( ActionCreate = "create" ActionReplace = "replace" + ActionDelete = "delete" ActionUnchanged = "unchanged" ) @@ -45,6 +46,7 @@ var boundaryNonClaims = []string{ } type Target struct { + Absent bool Content []byte Mode fs.FileMode Path string @@ -122,12 +124,21 @@ func (plan Plan) JSONValue() map[string]any { "nonClaims": admit.StringSliceToAny(boundaryNonClaims), "operations": operations, "rootId": plan.RootID, - "schemaVersion": json.Number("1"), + "schemaVersion": plan.schemaVersion(), "transactionId": plan.TransactionID, "transactionKind": "proofkit.repository-write-plan", } } +func (plan Plan) schemaVersion() json.Number { + for _, operation := range plan.Operations { + if !operation.After.Exists { + return json.Number("2") + } + } + return json.Number("1") +} + func (result Result) JSONValue() map[string]any { var appliedCount any if result.AppliedCountKnown { diff --git a/internal/kernel/repositorytransaction/output_admission.go b/internal/kernel/repositorytransaction/output_admission.go index 320e2eb..09cc163 100644 --- a/internal/kernel/repositorytransaction/output_admission.go +++ b/internal/kernel/repositorytransaction/output_admission.go @@ -29,7 +29,7 @@ func AdmitPlanOutput(raw any) (Plan, error) { 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" { + if (!admit.JSONNumberEquals(record["schemaVersion"], 1) && !admit.JSONNumberEquals(record["schemaVersion"], 2)) || 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) diff --git a/internal/kernel/repositorytransaction/plan.go b/internal/kernel/repositorytransaction/plan.go index 04e8d65..1083027 100644 --- a/internal/kernel/repositorytransaction/plan.go +++ b/internal/kernel/repositorytransaction/plan.go @@ -2,7 +2,6 @@ package repositorytransaction import ( "context" - "encoding/json" "errors" "fmt" "io/fs" @@ -87,7 +86,10 @@ func BuildPlan(ctx context.Context, rootPath string, targets []Target) (Plan, er } } validatedPaths = append(validatedPaths, targetPath) - if target.Mode == 0 || target.Mode&^fs.ModePerm != 0 || target.Mode.Perm()&0o400 == 0 { + if target.Absent && (target.Mode != 0 || len(target.Content) != 0) { + return Plan{}, fmt.Errorf("repository transaction absent target %d must have no content or mode", index) + } + if !target.Absent && (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 { @@ -97,8 +99,10 @@ func BuildPlan(ctx context.Context, rootPath string, targets []Target) (Plan, er if err != nil { return Plan{}, err } - for _, directory := range missing { - directories[directory] = struct{}{} + if !target.Absent { + for _, directory := range missing { + directories[directory] = struct{}{} + } } before, beforeContent, err := inspectTarget(root, targetPath, MaximumFileBytes) if err != nil { @@ -107,16 +111,18 @@ func BuildPlan(ctx context.Context, rootPath string, targets []Target) (Plan, er 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) + after := Snapshot{} + if !target.Absent { + 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 + action := snapshotAction(before, after) + if action == ActionDelete { + if err := verifyDeletionFilesystem(root, targetPath); err != nil { + return Plan{}, err } } plan.Operations = append(plan.Operations, Operation{ @@ -185,7 +191,7 @@ func desiredStateIdentityValue(plan Plan) map[string]any { return map[string]any{ "desiredStateKind": "proofkit.repository-desired-state", "rootId": plan.RootID, - "schemaVersion": json.Number("1"), + "schemaVersion": plan.schemaVersion(), "targets": targets, } } @@ -211,6 +217,19 @@ func equalSnapshot(left, right Snapshot) bool { return left.Exists == right.Exists && left.ByteCount == right.ByteCount && left.Mode == right.Mode && left.SHA256 == right.SHA256 } +func snapshotAction(before, after Snapshot) string { + switch { + case equalSnapshot(before, after): + return ActionUnchanged + case !after.Exists: + return ActionDelete + case !before.Exists: + return ActionCreate + default: + return ActionReplace + } +} + func pathsOverlap(left, right string) bool { overlaps, err := pathidentity.Overlaps(left, right) return err != nil || overlaps diff --git a/internal/kernel/repositorytransaction/platform_other.go b/internal/kernel/repositorytransaction/platform_other.go index 093d0b3..2759587 100644 --- a/internal/kernel/repositorytransaction/platform_other.go +++ b/internal/kernel/repositorytransaction/platform_other.go @@ -15,6 +15,10 @@ func platformOwnedByCurrentUser(os.FileInfo) (bool, error) { return false, fmt.Errorf("repository transactions require darwin or linux") } +func platformSameFilesystem(os.FileInfo, 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") } diff --git a/internal/kernel/repositorytransaction/platform_unix.go b/internal/kernel/repositorytransaction/platform_unix.go index 09929a1..93c0b83 100644 --- a/internal/kernel/repositorytransaction/platform_unix.go +++ b/internal/kernel/repositorytransaction/platform_unix.go @@ -27,6 +27,15 @@ func platformOwnedByCurrentUser(info os.FileInfo) (bool, error) { return stat.Uid == uint32(os.Geteuid()), nil } +func platformSameFilesystem(left, right os.FileInfo) (bool, error) { + l, leftOK := left.Sys().(*syscall.Stat_t) + r, rightOK := right.Sys().(*syscall.Stat_t) + if !leftOK || !rightOK { + return false, fmt.Errorf("repository filesystem identity is unavailable") + } + return l.Dev == r.Dev, 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) } diff --git a/internal/kernel/repositorytransaction/recovery.go b/internal/kernel/repositorytransaction/recovery.go index 7cc9323..acb1871 100644 --- a/internal/kernel/repositorytransaction/recovery.go +++ b/internal/kernel/repositorytransaction/recovery.go @@ -84,6 +84,9 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if action != RecoveryRollback { return Result{FailureClass: "preparing_state_mismatch", State: StateRecoveryRequired, TransactionID: preparingPlan.TransactionID}, nil } + if err := verifyTargetVector(root, preparingPlan, 0); err != nil { + return recoveryObservationFailure(preparingPlan.TransactionID, "preparing_state_mismatch", err) + } if err := publishPreparingJournal(root); err != nil { return Result{FailureClass: "journal_publication_failed", State: StateRecoveryRequired, TransactionID: preparingPlan.TransactionID}, nil } @@ -118,9 +121,12 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti if err != nil { return Result{FailureClass: "invalid_staged_objects", State: StateRecoveryRequired, TransactionID: transactionID}, nil } - if action != RecoveryResume || verifyTargetVector(root, plan, changedCount(plan)) != nil { + if action != RecoveryResume { return Result{FailureClass: "committed_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil } + if err := verifyTargetVector(root, plan, changedCount(plan)); err != nil { + return recoveryObservationFailure(transactionID, "committed_state_mismatch", err) + } if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } @@ -130,9 +136,12 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti return runtime.cleanupRecovered(root, plan, StateApplied, action) } if rolledBack { - if action != RecoveryRollback || verifyTargetVector(root, plan, 0) != nil { + if action != RecoveryRollback { return Result{FailureClass: "rolled_back_state_mismatch", State: StateRecoveryRequired, TransactionID: transactionID}, nil } + if err := verifyTargetVector(root, plan, 0); err != nil { + return recoveryObservationFailure(transactionID, "rolled_back_state_mismatch", err) + } if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) } @@ -152,8 +161,8 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti 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 := verifyTargetVector(root, plan, 0); err != nil { + return recoveryObservationFailure(transactionID, "preparing_state_mismatch", err) } if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("repository transaction recovery cancelled: %w", err) @@ -178,7 +187,7 @@ func (runtime engine) recover(ctx context.Context, rootPath, transactionID, acti } prefix, err := classifyPrefix(root, plan) if err != nil { - return Result{FailureClass: "ambiguous_target_state", State: StateRecoveryRequired, TransactionID: transactionID}, nil + return recoveryObservationFailure(transactionID, "ambiguous_target_state", err) } if action == RecoveryResume { if err := ctx.Err(); err != nil { @@ -227,12 +236,23 @@ func (runtime engine) cleanupRecovered(root *os.Root, plan Plan, state, action s return Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, FailureClass: "temporary_cleanup_failed", RecoveredBy: action, State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil } terminal := Result{AppliedCount: prefixForState(plan, state), AppliedCountKnown: true, RecoveredBy: action, State: state, TransactionID: plan.TransactionID} + if exists, err := pathExists(root, activeDirectory+"/"+terminalReceiptName); err != nil { + return Result{FailureClass: "invalid_terminal_receipt", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } else if exists { + receipt, err := loadTerminalReceipt(root, activeDirectory) + expected, relationErr := terminalReceiptFromResult(plan, receipt.result()) + if err != nil || relationErr != nil || receipt.State != state || !terminalReceiptMatchesPlan(receipt, expected) { + return Result{FailureClass: "invalid_terminal_receipt", State: StateRecoveryRequired, TransactionID: plan.TransactionID}, nil + } + terminal = receipt.result() + } 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 } + terminal.RecoveredBy = action return terminal, nil } diff --git a/internal/kernel/repositorytransaction/recovery_observation_test.go b/internal/kernel/repositorytransaction/recovery_observation_test.go new file mode 100644 index 0000000..5027509 --- /dev/null +++ b/internal/kernel/repositorytransaction/recovery_observation_test.go @@ -0,0 +1,86 @@ +package repositorytransaction + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +func testRecoveryObservationPartition(t *testing.T) { + for _, phase := range []string{"preparing-temp", "preparing", "ready", "committed", "rolled-back"} { + for _, observation := range []string{"unchanged", "content", "directory", "parent"} { + t.Run(phase+"/"+observation, func(t *testing.T) { + rootPath := t.TempDir() + plan := seedDeletionRecovery(t, rootPath) + prefix, action, want := 0, RecoveryRollback, StateRolledBack + if phase == "committed" { + prefix, action, want = 3, RecoveryResume, StateApplied + } + if phase != "preparing-temp" { + leaveDeletionPrefix(t, rootPath, plan, prefix) + } + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + switch phase { + case "preparing-temp": + err = prepareJournal(root, plan) + if err == nil { + err = root.Rename(journalPath, journalTemp) + } + case "preparing": + err = root.Remove(readyMarker) + case "committed": + err = writeMarker(root, committedMarker) + case "rolled-back": + err = writeMarker(root, rolledBackMarker) + } + if closeErr := root.Close(); err != nil || closeErr != nil { + t.Fatal(errors.Join(err, closeErr)) + } + switch observation { + case "content": + mustWriteTestFile(t, rootPath, "b", "foreign", 0o640) + case "directory": + if err := os.Remove(filepath.Join(rootPath, "b")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(rootPath, "b"), 0o700); err != nil { + t.Fatal(err) + } + case "parent": + if err := os.Remove(filepath.Join(rootPath, "z/new")); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(rootPath, "z")); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + mustWriteTestFile(t, rootPath, "z", "foreign parent", 0o600) + } + before := snapshotTestTree(t, rootPath) + result, err := Recover(context.Background(), rootPath, plan.TransactionID, action) + if observation == "unchanged" { + if err != nil || result.State != want || result.RecoveredBy != action { + t.Fatalf("valid recovery fixture rejected: %#v %v", result, err) + } + assertDeletionRecoveryFiles(t, rootPath, want) + return + } + if observation == "content" { + if err != nil || result.State != StateRecoveryRequired || result.TransactionID != plan.TransactionID { + t.Fatalf("observed mismatch became operational: %#v %v", result, err) + } + } else if err == nil || result != (Result{}) { + t.Fatalf("observation failure became a classified result: %#v %v", result, err) + } + if !reflect.DeepEqual(before, snapshotTestTree(t, rootPath)) { + t.Fatal("rejected observation changed target or control state") + } + }) + } + } +} diff --git a/internal/kernel/repositorytransaction/replay.go b/internal/kernel/repositorytransaction/replay.go new file mode 100644 index 0000000..b0e7a9b --- /dev/null +++ b/internal/kernel/repositorytransaction/replay.go @@ -0,0 +1,72 @@ +package repositorytransaction + +import ( + "context" + "errors" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +var ErrReplayMismatch = errors.New("repository transaction replay identity or current state does not match") + +// ReplayApplied verifies a native no-change plan and an exact prior applied +// receipt under one cooperative lock. Unlike ReadTerminalResult, it rejects +// pending work and stale target observations before acknowledging satisfaction. +func ReplayApplied(ctx context.Context, rootPath string, plan Plan, transactionID string) (result Result, returnErr error) { + expected, err := admit.SHA256Ref(transactionID, "repository transaction replay identity") + if err != nil { + return Result{}, err + } + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction replay cancelled: %w", err) + } + plan = clonePlan(plan) + root, rootID, err := openRepository(rootPath) + if err != nil { + return Result{}, err + } + defer func() { + returnErr = errors.Join(returnErr, closeReadResource(root, "transaction replay root")) + }() + if err := validateExecutablePlan(plan, rootID); err != nil { + return Result{}, err + } + if changedCount(plan) != 0 { + return Result{}, fmt.Errorf("repository transaction replay requires a no-change plan") + } + lock, exists, err := acquireExistingTransactionLock(root) + if err != nil { + return Result{}, err + } + if !exists { + return Result{}, ErrReplayMismatch + } + defer func() { + if err := lock.releaseChecked(); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("%w: transaction replay lock", ErrReadCleanup)) + } + }() + if err := ctx.Err(); err != nil { + return Result{}, fmt.Errorf("repository transaction replay cancelled: %w", err) + } + if pending, err := pendingTransactionState(root); err != nil { + return Result{}, err + } else if pending.Exists { + return Result{}, &RecoveryRequiredError{TransactionID: pending.TransactionID} + } + if err := verifyTargetVector(root, plan, 0); err != nil { + if errors.Is(err, errTargetSnapshotChanged) { + return Result{}, ErrReplayMismatch + } + return Result{}, err + } + terminal, err := readRetainedTerminalReceipt(root, expected) + if err != nil { + return Result{}, err + } + if terminal.State != StateApplied || terminal.DesiredStateID != plan.DesiredStateID { + return Result{}, ErrReplayMismatch + } + return Result{AppliedCountKnown: true, State: StateAlreadySatisfied, TransactionID: expected}, nil +} diff --git a/internal/kernel/repositorytransaction/replay_test.go b/internal/kernel/repositorytransaction/replay_test.go new file mode 100644 index 0000000..e184f21 --- /dev/null +++ b/internal/kernel/repositorytransaction/replay_test.go @@ -0,0 +1,130 @@ +package repositorytransaction + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestReplayAppliedRequiresCurrentNativePlanAndExactRetainedResult(t *testing.T) { + for _, mutation := range []string{"content", "unsafe-parent"} { + t.Run("observation/"+mutation, func(t *testing.T) { + ctx := t.Context() + root := t.TempDir() + targets := []Target{{Path: "nested/owned", Content: []byte("managed"), Mode: 0o644}} + initial, err := BuildPlan(ctx, root, targets) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(ctx, root, initial); err != nil || result.State != StateApplied { + t.Fatalf("initial: %#v %v", result, err) + } + current, err := BuildPlan(ctx, root, targets) + if err != nil { + t.Fatal(err) + } + if mutation == "unsafe-parent" { + if err := os.Rename(filepath.Join(root, "nested"), filepath.Join(root, "moved")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("moved", filepath.Join(root, "nested")); err != nil { + t.Fatal(err) + } + } else { + mustWriteTestFile(t, root, "nested/owned", "changed", 0o644) + } + before := snapshotTestTree(t, root) + result, err := ReplayApplied(ctx, root, current, initial.TransactionID) + if err == nil || result != (Result{}) || errors.Is(err, ErrReplayMismatch) != (mutation == "content") { + t.Fatalf("observation error classification: %#v %v", result, err) + } + if mutation == "unsafe-parent" && !strings.Contains(err.Error(), "symlink or non-directory") { + t.Fatal("native observation error was erased") + } + if !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Fatal("replay mutated the repository") + } + }) + } + for _, state := range []string{"valid", "stale", "pending", "busy", "wrong-id", "other-desired-state", "legacy-receipt", "descriptive", "changed-plan", "cancelled"} { + t.Run(state, func(t *testing.T) { + root := t.TempDir() + mustWriteTestFile(t, root, "a", "old", 0o644) + targets := []Target{{Path: "a", Absent: true}} + initial, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + if result, err := Apply(context.Background(), root, initial); err != nil || result.State != StateApplied { + t.Fatalf("initial: %#v %v", result, err) + } + current, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + expected := initial.TransactionID + ctx := context.Background() + switch state { + case "stale": + mustWriteTestFile(t, root, "a", "foreign", 0o644) + case "pending": + pending, err := BuildPlan(ctx, root, []Target{{Path: "b", Mode: 0o644, Content: []byte("pending")}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, root, pending, 0) + if historical, err := ReadTerminalResult(ctx, root, initial.TransactionID); err != nil || historical.State != StateApplied { + t.Fatalf("historical receipt should exist beside pending: %#v %v", historical, err) + } + case "busy": + handle, _, err := openRepository(root) + if err != nil { + t.Fatal(err) + } + defer handle.Close() + lock, err := acquireTransactionLock(handle) + if err != nil { + t.Fatal(err) + } + defer lock.release() + case "wrong-id": + expected = "sha256:" + strings.Repeat("f", 64) + case "other-desired-state": + current, err = BuildPlan(ctx, root, []Target{{Path: "unrelated", Absent: true}}) + if err != nil { + t.Fatal(err) + } + case "legacy-receipt": + rewriteTerminalAsLegacy(t, root, terminalTombstonePath(expected, StateApplied)) + case "descriptive": + current = readmittedConstructionPlan(t, current) + case "changed-plan": + current = initial + case "cancelled": + cancelled, cancel := context.WithCancel(ctx) + cancel() + ctx = cancelled + } + before := snapshotTestTree(t, root) + result, err := ReplayApplied(ctx, root, current, expected) + if state == "valid" { + want := Result{State: StateAlreadySatisfied, AppliedCountKnown: true, TransactionID: expected} + if err != nil || result != want { + t.Fatalf("replay: %#v %v", result, err) + } + } else if err == nil { + t.Fatalf("invalid replay passed: %#v", result) + } + if state == "pending" && !errors.Is(err, ErrRecoveryRequired) || state == "busy" && !errors.Is(err, ErrBusy) || state == "cancelled" && !errors.Is(err, context.Canceled) { + t.Fatalf("lost precise state: %v", err) + } + if !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Fatal("read-only replay changed repository") + } + }) + } +} diff --git a/internal/kernel/repositorytransaction/state.go b/internal/kernel/repositorytransaction/state.go index b004eb0..d6382e8 100644 --- a/internal/kernel/repositorytransaction/state.go +++ b/internal/kernel/repositorytransaction/state.go @@ -1,6 +1,7 @@ package repositorytransaction import ( + "errors" "fmt" "os" "path" @@ -8,6 +9,15 @@ import ( "sort" ) +var errTargetSnapshotChanged = errors.New("repository transaction target snapshot changed") + +func recoveryObservationFailure(transactionID, failureClass string, err error) (Result, error) { + if !errors.Is(err, errTargetSnapshotChanged) { + return Result{}, err + } + return Result{FailureClass: failureClass, State: StateRecoveryRequired, TransactionID: transactionID}, nil +} + func resultWithObservedPrefix(root *os.Root, plan Plan, result Result) Result { prefix, err := classifyPrefix(root, plan) if err == nil { @@ -28,27 +38,26 @@ 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 } + if operation.Action == ActionUnchanged { + if !equalSnapshot(observed, operation.After) { + return 0, errTargetSnapshotChanged + } + continue + } switch { case equalSnapshot(observed, operation.After): if seenBefore { - return 0, fmt.Errorf("repository transaction target vector is not a legal prefix") + return 0, errTargetSnapshotChanged } prefix++ case equalSnapshot(observed, operation.Before): seenBefore = true default: - return 0, fmt.Errorf("repository transaction target state is unknown") + return 0, errTargetSnapshotChanged } } return prefix, nil @@ -56,8 +65,11 @@ func classifyPrefix(root *os.Root, plan Plan) (int, error) { 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") + if err != nil { + return err + } + if prefix != expectedPrefix { + return errTargetSnapshotChanged } return nil } @@ -78,7 +90,7 @@ func validateExecutablePlan(plan Plan, rootID string) error { 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) { + if operation.After.Exists && !contentMatches(operation.afterContent, operation.After) || !operation.After.Exists && len(operation.afterContent) != 0 { return fmt.Errorf("repository transaction plan after content is invalid") } if operation.Before.Exists && !contentMatches(operation.beforeContent, operation.Before) { @@ -108,8 +120,10 @@ func verifyCreatedDirectories(root *os.Root, plan Plan) error { if err != nil { return err } - for _, directory := range missing { - directorySet[directory] = struct{}{} + if operation.After.Exists { + for _, directory := range missing { + directorySet[directory] = struct{}{} + } } } want := make([]string, 0, len(directorySet)) diff --git a/internal/kernel/repositorytransaction/terminal_identity_test.go b/internal/kernel/repositorytransaction/terminal_identity_test.go new file mode 100644 index 0000000..3449fbc --- /dev/null +++ b/internal/kernel/repositorytransaction/terminal_identity_test.go @@ -0,0 +1,148 @@ +package repositorytransaction + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func TestTerminalReceiptDesiredIdentityAdmission(t *testing.T) { + for _, mutation := range []string{"bound", "legacy", "missing", "malformed", "legacy-extra", "future"} { + t.Run(mutation, func(t *testing.T) { + receipt := terminalReceipt{AppliedCount: 1, DesiredStateID: "sha256:" + strings.Repeat("a", 64), State: StateApplied, TransactionID: "sha256:" + strings.Repeat("b", 64)} + value := terminalReceiptValue(receipt) + switch mutation { + case "legacy": + delete(value, "desiredStateId") + value["schemaVersion"] = json.Number("1") + receipt.DesiredStateID = "" + case "missing": + delete(value, "desiredStateId") + case "malformed": + value["desiredStateId"] = "not-a-digest" + case "legacy-extra": + value["schemaVersion"] = json.Number("1") + case "future": + value["schemaVersion"] = json.Number("3") + } + actual, err := admitTerminalReceipt(value) + if mutation == "bound" || mutation == "legacy" { + if err != nil || actual != receipt || !reflect.DeepEqual(terminalReceiptValue(actual), value) { + t.Fatalf("terminal identity round trip: %v %#v", err, actual) + } + } else if err == nil { + t.Fatal("invalid terminal identity was admitted") + } + }) + } +} + +func rewriteTerminalAsLegacy(t *testing.T, rootPath, relative string) { + t.Helper() + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + receipt, err := loadTerminalReceipt(root, relative) + if err != nil { + t.Fatal(err) + } + receipt.DesiredStateID = "" + content, err := stablejson.Marshal(terminalReceiptValue(receipt)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootPath, relative, terminalReceiptName), content, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestLegacyTerminalRecoveryPreservesHistoricalIdentity(t *testing.T) { + for _, partial := range []bool{false, true} { + t.Run(map[bool]string{false: "compacted", true: "pending-compaction"}[partial], func(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + plan, err := BuildPlan(ctx, root, []Target{{Path: "owned", Content: []byte("current"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + if partial { + leaveInterruptedPrefix(t, root, plan, 1) + handle, _, err := openRepository(root) + if err != nil { + t.Fatal(err) + } + defer handle.Close() + if err := writeMarker(handle, committedMarker); err != nil { + t.Fatal(err) + } + if err := ensureTerminalReceipt(handle, plan, Result{AppliedCount: 1, AppliedCountKnown: true, State: StateApplied, TransactionID: plan.TransactionID}); err != nil { + t.Fatal(err) + } + rewriteTerminalAsLegacy(t, root, activeDirectory) + } else { + if _, err := Apply(ctx, root, plan); err != nil { + t.Fatal(err) + } + rewriteTerminalAsLegacy(t, root, terminalTombstonePath(plan.TransactionID, StateApplied)) + } + if result, err := Recover(ctx, root, plan.TransactionID, RecoveryResume); err != nil || result.State != StateApplied { + t.Fatalf("legacy historical recovery: %#v %v", result, err) + } + if result, err := ReadTerminalResult(ctx, root, plan.TransactionID); err != nil || result.State != StateApplied { + t.Fatalf("legacy historical result: %#v %v", result, err) + } + current, err := BuildPlan(ctx, root, []Target{{Path: "owned", Content: []byte("current"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + before := snapshotTestTree(t, root) + if _, err := ReplayApplied(ctx, root, current, plan.TransactionID); !errors.Is(err, ErrReplayMismatch) { + t.Fatalf("legacy receipt invented a desired-state association: %v", err) + } + if !reflect.DeepEqual(before, snapshotTestTree(t, root)) { + t.Fatal("legacy replay changed repository state") + } + if result, err := Apply(ctx, root, current); err != nil || result.State != StateAlreadySatisfied { + t.Fatalf("freshly reviewed current plan must remain usable: %#v %v", result, err) + } + }) + } +} + +func TestBoundTerminalRecoveryCompletesInterruptedArchive(t *testing.T) { + ctx := context.Background() + rootPath := t.TempDir() + plan, err := BuildPlan(ctx, rootPath, []Target{{Path: "owned", Content: []byte("after"), Mode: 0o644}}) + if err != nil { + t.Fatal(err) + } + leaveInterruptedPrefix(t, rootPath, plan, 1) + root, _, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := writeMarker(root, committedMarker); err != nil { + t.Fatal(err) + } + if err := ensureTerminalReceipt(root, plan, Result{AppliedCount: 1, AppliedCountKnown: true, State: StateApplied, TransactionID: plan.TransactionID}); err != nil { + t.Fatal(err) + } + result, err := Recover(ctx, rootPath, plan.TransactionID, RecoveryResume) + if err != nil || result.State != StateApplied || result.RecoveredBy != RecoveryResume { + t.Fatalf("bound archive recovery: %#v %v", result, err) + } + retained, err := readRetainedTerminalReceipt(root, plan.TransactionID) + if err != nil || retained.RecoveredBy != "" || retained.DesiredStateID != plan.DesiredStateID { + t.Fatalf("persisted result was rewritten or identity lost: %#v %v", retained, err) + } +} diff --git a/internal/kernel/repositorytransaction/terminal_receipt.go b/internal/kernel/repositorytransaction/terminal_receipt.go index abb5fbe..441415d 100644 --- a/internal/kernel/repositorytransaction/terminal_receipt.go +++ b/internal/kernel/repositorytransaction/terminal_receipt.go @@ -19,16 +19,17 @@ const ( ) type terminalReceipt struct { - AppliedCount int - FailureClass string - RecoveredBy string - State string - TransactionID string + AppliedCount int + DesiredStateID string + FailureClass string + RecoveredBy string + State string + TransactionID string } // 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. +// transaction without consuming it. Historical inspection does not establish +// current target state or authorize acknowledgement replay; use ReplayApplied. func ReadTerminalResult(ctx context.Context, rootPath, transactionID string) (Result, error) { admittedID, err := admit.SHA256Ref(transactionID, "repository transaction terminal transactionId") if err != nil { @@ -53,24 +54,32 @@ func ReadTerminalResult(ctx context.Context, rootPath, transactionID string) (Re if err := ctx.Err(); err != nil { return Result{}, fmt.Errorf("read repository transaction terminal result cancelled: %w", err) } + receipt, err := readRetainedTerminalReceipt(root, admittedID) + return receipt.result(), err +} + +func readRetainedTerminalReceipt(root *os.Root, admittedID string) (terminalReceipt, error) { entries, err := controlEntries(root) if err != nil { - return Result{}, err + return terminalReceipt{}, 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") + if err != nil { + return terminalReceipt{}, err + } + if !found || terminal.TransactionID != admittedID { + return terminalReceipt{}, ErrReplayMismatch } 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") + return terminalReceipt{}, 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 terminalReceipt{}, fmt.Errorf("repository transaction terminal result is invalid") } - return receipt.result(), nil + return receipt, nil } func (receipt terminalReceipt) result() Result { @@ -94,7 +103,7 @@ func ensureTerminalReceipt(root *os.Root, plan Plan, result Result) error { return err } else if exists { got, err := loadTerminalReceipt(root, activeDirectory) - if err != nil || got != want { + if err != nil || !terminalReceiptMatchesPlan(got, want) { return fmt.Errorf("repository transaction terminal receipt contradicts terminal state") } return discardOwnedTemporaryFile(root, activeDirectory+"/"+terminalReceiptTempName) @@ -114,11 +123,12 @@ func terminalReceiptFromResult(plan Plan, result Result) (terminalReceipt, error 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, + AppliedCount: result.AppliedCount, + DesiredStateID: plan.DesiredStateID, + FailureClass: result.FailureClass, + RecoveredBy: result.RecoveredBy, + State: result.State, + TransactionID: result.TransactionID, }, nil } @@ -147,12 +157,25 @@ 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", "failureClass", "recoveredBy", "schemaVersion", "state", "terminalKind", "transactionId"}, "repository transaction terminal receipt"); err != nil { + bound := admit.JSONNumberEquals(record["schemaVersion"], 2) + keys := []string{"appliedCount", "failureClass", "recoveredBy", "schemaVersion", "state", "terminalKind", "transactionId"} + if bound { + keys = append(keys, "desiredStateId") + } + if err := admit.KnownKeys(record, keys, "repository transaction terminal receipt"); err != nil { return terminalReceipt{}, err } - if record["terminalKind"] != "proofkit.repository-terminal-receipt" || !admit.JSONNumberEquals(record["schemaVersion"], 1) { + if record["terminalKind"] != "proofkit.repository-terminal-receipt" || !bound && !admit.JSONNumberEquals(record["schemaVersion"], 1) { return terminalReceipt{}, fmt.Errorf("repository transaction terminal receipt identity is invalid") } + desiredStateID := "" + if bound { + var err error + desiredStateID, err = admit.SHA256Ref(record["desiredStateId"], "repository transaction terminal receipt desiredStateId") + if err != nil { + return terminalReceipt{}, err + } + } 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") @@ -193,11 +216,11 @@ func admitTerminalReceipt(raw any) (terminalReceipt, error) { 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 + return terminalReceipt{AppliedCount: int(appliedCount), DesiredStateID: desiredStateID, FailureClass: failureClass, RecoveredBy: recoveredBy, State: state, TransactionID: transactionID}, nil } func terminalReceiptValue(receipt terminalReceipt) map[string]any { - return map[string]any{ + value := map[string]any{ "appliedCount": json.Number(intString(receipt.AppliedCount)), "failureClass": nullableText(receipt.FailureClass), "recoveredBy": nullableText(receipt.RecoveredBy), @@ -206,4 +229,18 @@ func terminalReceiptValue(receipt terminalReceipt) map[string]any { "terminalKind": "proofkit.repository-terminal-receipt", "transactionId": receipt.TransactionID, } + if receipt.DesiredStateID != "" { + value["schemaVersion"] = json.Number("2") + value["desiredStateId"] = receipt.DesiredStateID + } + return value +} + +// Legacy receipts retain their historical fields during recovery; only a bound +// generation-2 receipt can subsequently establish the desired-state relation. +func terminalReceiptMatchesPlan(receipt, expected terminalReceipt) bool { + if receipt.DesiredStateID == "" { + expected.DesiredStateID = "" + } + return receipt == expected } diff --git a/internal/kernel/repositorytransaction/testdata/predecessor-v1.json b/internal/kernel/repositorytransaction/testdata/predecessor-v1.json new file mode 100644 index 0000000..0ce3ebc --- /dev/null +++ b/internal/kernel/repositorytransaction/testdata/predecessor-v1.json @@ -0,0 +1,199 @@ +{ + "sourceCommit": "4401e966746b4170d904cfaf23e02dd0514dd536", + "referenceRootId": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "cases": [ + { + "name": "create-empty", + "initial": null, + "targets": [ + { + "path": "a", + "content": "", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:306937591b1b170bed7de45796481a38664ffdff5466b200fe4020d3a888ddae\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"create\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": false,\n \"mode\": \"0000\",\n \"sha256\": null\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:9ae4c69258cad6218829999aa4393988145f6a54adcfa842c0f31c80e469c831\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"a\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:306937591b1b170bed7de45796481a38664ffdff5466b200fe4020d3a888ddae\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"create\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": false,\n \"mode\": \"0000\",\n \"sha256\": null\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:9ae4c69258cad6218829999aa4393988145f6a54adcfa842c0f31c80e469c831\"\n}\n" + }, + { + "name": "create-text", + "initial": null, + "targets": [ + { + "path": "nested/a", + "content": "new\n", + "mode": 432 + } + ], + "plan": "{\n \"createdDirectories\": [\n \"nested\"\n ],\n \"desiredStateId\": \"sha256:0e2165c73d2e97800f3a9c806b16e63a247550d7f02646cf8e7f4b8a6058ebbf\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"create\",\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0660\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": false,\n \"mode\": \"0000\",\n \"sha256\": null\n },\n \"path\": \"nested/a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:9370392d9c6438be58b8337ed540f4b5a3caa5f287f1d6093dd1296d45cdce94\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0660\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"path\": \"nested/a\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [\n \"nested\"\n ],\n \"desiredStateId\": \"sha256:0e2165c73d2e97800f3a9c806b16e63a247550d7f02646cf8e7f4b8a6058ebbf\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"create\",\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0660\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": false,\n \"mode\": \"0000\",\n \"sha256\": null\n },\n \"path\": \"nested/a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:9370392d9c6438be58b8337ed540f4b5a3caa5f287f1d6093dd1296d45cdce94\"\n}\n" + }, + { + "name": "replace-empty", + "initial": [ + { + "path": "a", + "content": "old\n", + "mode": 420 + } + ], + "targets": [ + { + "path": "a", + "content": "", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:306937591b1b170bed7de45796481a38664ffdff5466b200fe4020d3a888ddae\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:01d09d19c2139a46aebfb577780d123d7396e97201bc7ead210a2ebff8239dee\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:44368e67f4dcff5ea06591b45f8f7a0474c2efa30232445f218e1863592b751c\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"a\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:306937591b1b170bed7de45796481a38664ffdff5466b200fe4020d3a888ddae\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:01d09d19c2139a46aebfb577780d123d7396e97201bc7ead210a2ebff8239dee\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:44368e67f4dcff5ea06591b45f8f7a0474c2efa30232445f218e1863592b751c\"\n}\n" + }, + { + "name": "replace-text", + "initial": [ + { + "path": "a", + "content": "", + "mode": 384 + } + ], + "targets": [ + { + "path": "a", + "content": "new\n", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:1c25f35d42e166ee4f0ee0f74fbad463f7d269036ab4ad13e17038d873a26ccc\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:25b3ff3803d822c645503cce7ce58826a0826098f9def39ef3d52501a8bdec7d\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"path\": \"a\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:1c25f35d42e166ee4f0ee0f74fbad463f7d269036ab4ad13e17038d873a26ccc\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:25b3ff3803d822c645503cce7ce58826a0826098f9def39ef3d52501a8bdec7d\"\n}\n" + }, + { + "name": "mode-only", + "initial": [ + { + "path": "a", + "content": "same\n", + "mode": 384 + } + ], + "targets": [ + { + "path": "a", + "content": "same\n", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:37cbbdd80eafc28781b0a12bef71002a0f829a1295234f91f5b45c55a44a689c\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"before\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:8152e5b62c731b06044ec2c1ddb23c9216583c7417c5dba454ddba6d8336eba7\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:37cbbdd80eafc28781b0a12bef71002a0f829a1295234f91f5b45c55a44a689c\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"before\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:8152e5b62c731b06044ec2c1ddb23c9216583c7417c5dba454ddba6d8336eba7\"\n}\n" + }, + { + "name": "unchanged-empty", + "initial": [ + { + "path": "a", + "content": "", + "mode": 420 + } + ], + "targets": [ + { + "path": "a", + "content": "", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:306937591b1b170bed7de45796481a38664ffdff5466b200fe4020d3a888ddae\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"unchanged\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:2ea429ae5ce3a82a958bfba6387f070d83ff7479d4a1a7fa7f3d9205cc3daf87\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"a\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:306937591b1b170bed7de45796481a38664ffdff5466b200fe4020d3a888ddae\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"unchanged\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:2ea429ae5ce3a82a958bfba6387f070d83ff7479d4a1a7fa7f3d9205cc3daf87\"\n}\n" + }, + { + "name": "unchanged-text", + "initial": [ + { + "path": "a", + "content": "same\n", + "mode": 420 + } + ], + "targets": [ + { + "path": "a", + "content": "same\n", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:37cbbdd80eafc28781b0a12bef71002a0f829a1295234f91f5b45c55a44a689c\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"unchanged\",\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"before\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:3908cbffca16d257f4ea11826c36c2fc3d5ec0a594d771382efcbbfb70ea7950\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:37cbbdd80eafc28781b0a12bef71002a0f829a1295234f91f5b45c55a44a689c\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"unchanged\",\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"before\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:3908cbffca16d257f4ea11826c36c2fc3d5ec0a594d771382efcbbfb70ea7950\"\n}\n" + }, + { + "name": "mixed", + "initial": [ + { + "path": "a", + "content": "same\n", + "mode": 420 + }, + { + "path": "old/b", + "content": "old\n", + "mode": 384 + } + ], + "targets": [ + { + "path": "old/b", + "content": "new\n", + "mode": 420 + }, + { + "path": "created/deep/c", + "content": "", + "mode": 420 + }, + { + "path": "a", + "content": "same\n", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [\n \"created\",\n \"created/deep\"\n ],\n \"desiredStateId\": \"sha256:e27b871502216044504a6c95caff9c1e7d992786abbe6ad7def45e2c45a62a2f\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"unchanged\",\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"before\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n },\n {\n \"action\": \"create\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": false,\n \"mode\": \"0000\",\n \"sha256\": null\n },\n \"path\": \"created/deep/c\"\n },\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"before\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:01d09d19c2139a46aebfb577780d123d7396e97201bc7ead210a2ebff8239dee\"\n },\n \"path\": \"old/b\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:1471a1831e368f5bb9d33a38912a066bc01710f92caed70de5fd6b0be8bcf154\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n },\n {\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"path\": \"created/deep/c\"\n },\n {\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"path\": \"old/b\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [\n \"created\",\n \"created/deep\"\n ],\n \"desiredStateId\": \"sha256:e27b871502216044504a6c95caff9c1e7d992786abbe6ad7def45e2c45a62a2f\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"unchanged\",\n \"after\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"before\": {\n \"byteCount\": 5,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:a6328afc76e9db71da297ebff4b0d3e7a7eb3b01d917c05a6573fef121b6ecb6\"\n },\n \"path\": \"a\"\n },\n {\n \"action\": \"create\",\n \"after\": {\n \"byteCount\": 0,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n },\n \"before\": {\n \"byteCount\": 0,\n \"exists\": false,\n \"mode\": \"0000\",\n \"sha256\": null\n },\n \"path\": \"created/deep/c\"\n },\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:7aa7a5359173d05b63cfd682e3c38487f3cb4f7f1d60659fe59fab1505977d4c\"\n },\n \"before\": {\n \"byteCount\": 4,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:01d09d19c2139a46aebfb577780d123d7396e97201bc7ead210a2ebff8239dee\"\n },\n \"path\": \"old/b\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:1471a1831e368f5bb9d33a38912a066bc01710f92caed70de5fd6b0be8bcf154\"\n}\n" + }, + { + "name": "recovery-pair", + "initial": [ + { + "path": "a", + "content": "old-a\n", + "mode": 420 + }, + { + "path": "b", + "content": "old-b\n", + "mode": 384 + } + ], + "targets": [ + { + "path": "a", + "content": "new-a\n", + "mode": 420 + }, + { + "path": "b", + "content": "new-b\n", + "mode": 420 + } + ], + "plan": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:a8b2d1b510c8c228e17e9c708038652aed4eb4d23d4ee62fe47d0840ca6572b5\",\n \"nonClaims\": [\n \"Public transaction plan re-admission proves canonical paths and byte identities, not executable payload bytes; re-admitted plans cannot be applied.\",\n \"Repository transactions do not establish semantic correctness, owner approval, Git cleanliness, merge authority, release authority, rollout, or production readiness.\",\n \"Repository transactions do not prove power-loss durability or protection from non-cooperative same-user writers.\",\n \"Repository transactions do not provide simultaneous multi-file visibility to arbitrary readers.\"\n ],\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:54578a36036ade573160b2eb3ba5d8557897aa8c6b4ce4742e556d37eb81c680\"\n },\n \"before\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:96cdfb91ba2c74be3baa1902d9b100039a4d67decdef72a3ee1e67c886cbb875\"\n },\n \"path\": \"a\"\n },\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:550d594f5d87bd540b72a207fd32c27894f378088bd355d4f0b2bb7d43e58df9\"\n },\n \"before\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:28434c80688e88d8f2955a9d77b92594343aa1cd8b168b9001fe5096c97c8022\"\n },\n \"path\": \"b\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:4d125f2814e6dea3fa0cde050e40486e60473e6cadfc05bff71a413dc42ad0a6\",\n \"transactionKind\": \"proofkit.repository-write-plan\"\n}\n", + "desired": "{\n \"desiredStateKind\": \"proofkit.repository-desired-state\",\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"targets\": [\n {\n \"after\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:54578a36036ade573160b2eb3ba5d8557897aa8c6b4ce4742e556d37eb81c680\"\n },\n \"path\": \"a\"\n },\n {\n \"after\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:550d594f5d87bd540b72a207fd32c27894f378088bd355d4f0b2bb7d43e58df9\"\n },\n \"path\": \"b\"\n }\n ]\n}\n", + "journal": "{\n \"createdDirectories\": [],\n \"desiredStateId\": \"sha256:a8b2d1b510c8c228e17e9c708038652aed4eb4d23d4ee62fe47d0840ca6572b5\",\n \"journalKind\": \"proofkit.repository-write-journal\",\n \"operations\": [\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:54578a36036ade573160b2eb3ba5d8557897aa8c6b4ce4742e556d37eb81c680\"\n },\n \"before\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:96cdfb91ba2c74be3baa1902d9b100039a4d67decdef72a3ee1e67c886cbb875\"\n },\n \"path\": \"a\"\n },\n {\n \"action\": \"replace\",\n \"after\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0644\",\n \"sha256\": \"sha256:550d594f5d87bd540b72a207fd32c27894f378088bd355d4f0b2bb7d43e58df9\"\n },\n \"before\": {\n \"byteCount\": 6,\n \"exists\": true,\n \"mode\": \"0600\",\n \"sha256\": \"sha256:28434c80688e88d8f2955a9d77b92594343aa1cd8b168b9001fe5096c97c8022\"\n },\n \"path\": \"b\"\n }\n ],\n \"rootId\": \"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\n \"schemaVersion\": 1,\n \"transactionId\": \"sha256:4d125f2814e6dea3fa0cde050e40486e60473e6cadfc05bff71a413dc42ad0a6\"\n}\n" + } + ] +} diff --git a/internal/kernel/repositorytransaction/v1_compatibility_test.go b/internal/kernel/repositorytransaction/v1_compatibility_test.go new file mode 100644 index 0000000..40682c9 --- /dev/null +++ b/internal/kernel/repositorytransaction/v1_compatibility_test.go @@ -0,0 +1,189 @@ +package repositorytransaction + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "io/fs" + "os" + "strings" + "testing" + + "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" +) + +type predecessorTarget struct { + Path string `json:"path"` + Content string `json:"content"` + Mode fs.FileMode `json:"mode"` +} + +type predecessorCase struct { + Name string `json:"name"` + Initial []predecessorTarget `json:"initial"` + Targets []predecessorTarget `json:"targets"` + Plan string `json:"plan"` + Desired string `json:"desired"` + Journal string `json:"journal"` +} + +type predecessorFixture struct { + SourceCommit string `json:"sourceCommit"` + ReferenceRootID string `json:"referenceRootId"` + Cases []predecessorCase `json:"cases"` +} + +func loadPredecessorVectors(t *testing.T) (string, []predecessorCase) { + t.Helper() + content, err := os.ReadFile("testdata/predecessor-v1.json") + if err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(content)) != "408326cf744dda64f5cb73bc6de4cece4136ff1425f21ef850516d052c54b1e1" { + t.Fatal("independent predecessor fixture changed") + } + fixture, err := admission.DecodeTypedJSON[predecessorFixture](bytes.NewReader(content), MaximumJournalBytes) + if err != nil { + t.Fatal(err) + } + if fixture.SourceCommit != "4401e966746b4170d904cfaf23e02dd0514dd536" || len(fixture.Cases) != 9 { + t.Fatal("predecessor provenance or coverage changed") + } + return fixture.ReferenceRootID, fixture.Cases +} + +func TestPresentOnlyPlansPreserveIndependentPredecessorBytes(t *testing.T) { + rootID, cases := loadPredecessorVectors(t) + for _, fixture := range cases { + t.Run(fixture.Name, func(t *testing.T) { + root := t.TempDir() + for _, initial := range fixture.Initial { + mustWriteTestFile(t, root, initial.Path, initial.Content, initial.Mode) + } + targets := make([]Target, 0, len(fixture.Targets)) + for _, target := range fixture.Targets { + targets = append(targets, Target{Path: target.Path, Content: []byte(target.Content), Mode: target.Mode}) + } + plan, err := BuildPlan(context.Background(), root, targets) + if err != nil { + t.Fatal(err) + } + plan.RootID = rootID + plan.DesiredStateID, err = digest.StableJSONSHA256Ref(desiredStateIdentityValue(plan)) + if err != nil { + t.Fatal(err) + } + plan.TransactionID, err = digest.StableJSONSHA256Ref(planIdentityValue(plan)) + if err != nil { + t.Fatal(err) + } + for _, pair := range []struct { + actual any + expected string + }{ + {plan.JSONValue(), fixture.Plan}, {desiredStateIdentityValue(plan), fixture.Desired}, {journalValue(plan), fixture.Journal}, + } { + actual, err := stablejson.Marshal(pair.actual) + if err != nil || string(actual) != pair.expected { + t.Fatalf("predecessor bytes drifted: %v", err) + } + } + if _, err := AdmitPlanOutput(decodePredecessorObject(t, fixture.Plan)); err != nil { + t.Fatalf("old plan rejected: %v", err) + } + if _, err := admitJournal(decodePredecessorObject(t, fixture.Journal)); err != nil { + t.Fatalf("old journal rejected: %v", err) + } + }) + } +} + +func TestRecoverIndependentPredecessorJournalAndStagedBytes(t *testing.T) { + rootID, cases := loadPredecessorVectors(t) + fixture := cases[len(cases)-1] + if fixture.Name != "recovery-pair" { + t.Fatal("missing independent recovery fixture") + } + for _, action := range []string{RecoveryResume, RecoveryRollback} { + for prefix := 0; prefix <= len(fixture.Targets); prefix++ { + t.Run(fmt.Sprintf("%s-prefix-%d", action, prefix), func(t *testing.T) { + rootPath := t.TempDir() + root, actualRootID, err := openRepository(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + // Only root-dependent fields change. Shapes and staged bytes come + // from the old owner, never the new journal/plan serializers. + desired := decodePredecessorObject(t, strings.ReplaceAll(fixture.Desired, rootID, actualRootID)) + desiredID, err := digest.StableJSONSHA256Ref(desired) + if err != nil { + t.Fatal(err) + } + oldPlan := decodePredecessorObject(t, fixture.Plan) + oldDesired := oldPlan["desiredStateId"].(string) + oldTransaction := oldPlan["transactionId"].(string) + identity := decodePredecessorObject(t, strings.ReplaceAll(strings.ReplaceAll(fixture.Plan, rootID, actualRootID), oldDesired, desiredID)) + delete(identity, "transactionId") + delete(identity, "transactionKind") + delete(identity, "nonClaims") + transactionID, err := digest.StableJSONSHA256Ref(identity) + if err != nil { + t.Fatal(err) + } + journal := strings.NewReplacer(rootID, actualRootID, oldDesired, desiredID, oldTransaction, transactionID).Replace(fixture.Journal) + mustWriteTestFile(t, rootPath, journalPath, journal, 0o600) + if err := os.Chmod(rootPath+"/"+ControlRoot, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(rootPath+"/"+ControlDirectory, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(rootPath+"/"+activeDirectory, 0o700); err != nil { + t.Fatal(err) + } + for index, initial := range fixture.Initial { + actual := initial + if index < prefix { + actual = fixture.Targets[index] + } + mustWriteTestFile(t, rootPath, actual.Path, actual.Content, actual.Mode) + mustWriteTestFile(t, rootPath, beforeObjectPath(index), initial.Content, 0o600) + mustWriteTestFile(t, rootPath, afterObjectPath(index), fixture.Targets[index].Content, 0o600) + } + if err := writeMarker(root, readyMarker); err != nil { + t.Fatal(err) + } + result, err := Recover(context.Background(), rootPath, transactionID, action) + want := StateApplied + files := fixture.Targets + if action == RecoveryRollback { + want, files = StateRolledBack, fixture.Initial + } + if err != nil || result.State != want || result.TransactionID != transactionID || result.RecoveredBy != action { + t.Fatalf("old recovery: %#v %v", result, err) + } + for _, file := range files { + assertTestFile(t, rootPath, file.Path, file.Content, file.Mode) + } + assertNoActiveTransaction(t, rootPath) + }) + } + } +} + +func decodePredecessorObject(t *testing.T, text string) map[string]any { + t.Helper() + raw, err := admission.DecodeJSON(bytes.NewReader([]byte(text)), MaximumJournalBytes) + if err != nil { + t.Fatal(err) + } + value, ok := raw.(map[string]any) + if !ok { + t.Fatal("predecessor object required") + } + return value +} diff --git a/internal/tools/coveragemetrics/main_test.go b/internal/tools/coveragemetrics/main_test.go index 526ef5b..818d671 100644 --- a/internal/tools/coveragemetrics/main_test.go +++ b/internal/tools/coveragemetrics/main_test.go @@ -593,6 +593,15 @@ func TestBindingWitnessSelectorsRequireExactCriticalInventories(t *testing.T) { "proofkit.agent-workflow.catalog-prerequisite-causality", "proofkit.agent-workflow.cli-presentation-capability-product", "proofkit.agent-workflow.installed-carrier-smoke-closure", + "proofkit.agent-workflow.integration-lifecycle-cli", + "proofkit.agent-workflow.integration-lifecycle-npm-carrier", + "proofkit.agent-workflow.integration-lifecycle-pair-recognition", + "proofkit.agent-workflow.integration-lifecycle-version-edge", + "proofkit.agent-workflow.integration-lifecycle-version-edge-mutants", + "proofkit.agent-workflow.integration-lifecycle-wheel-carrier", + "proofkit.agent-workflow.integration-lifecycle-whole-operation", + "proofkit.agent-workflow.integration-recover-busy-stream", + "proofkit.agent-workflow.integration-update-effect-oracle", "proofkit.agent-workflow.native-evidence-guidance-purity", "proofkit.agent-workflow.native-evidence-guidance-slot-closure", "proofkit.agent-workflow.no-ambient-authority", @@ -735,6 +744,22 @@ func TestBindingWitnessSelectorsRequireExactCriticalInventories(t *testing.T) { t.Fatalf("command-drift error=%v", err) } }) + if bindings.Bindings[index].RequirementID == "REQ-PROOFKIT-WORKFLOW-019" { + t.Run(scenarioID+"/execution-command-class-drift", func(t *testing.T) { + mutated := cloneBindingFile(bindings) + mutated.Bindings[index].CommandIDs = []string{"proofkit.package-artifact"} + if err := validateBindingWitnessSelectorsAtRoot(root, mutated); err == nil || !strings.Contains(err.Error(), "commandIds=") { + t.Fatalf("managed lifecycle command-class drift error=%v", err) + } + }) + t.Run(scenarioID+"/environment-class-drift", func(t *testing.T) { + mutated := cloneBindingFile(bindings) + mutated.Bindings[index].EnvironmentClasses = []string{"local-python"} + if err := validateBindingWitnessSelectorsAtRoot(root, mutated); err == nil || !strings.Contains(err.Error(), "environmentClasses=") { + t.Fatalf("managed lifecycle environment-class drift error=%v", err) + } + }) + } if scenarioID == "proofkit.agent-workflow.project-navigation-installed-carriers" { t.Run(scenarioID+"/execution-command-class-drift", func(t *testing.T) { mutated := cloneBindingFile(bindings) diff --git a/internal/tools/coveragemetrics/required_inventory.go b/internal/tools/coveragemetrics/required_inventory.go index f2318a0..16eb6a8 100644 --- a/internal/tools/coveragemetrics/required_inventory.go +++ b/internal/tools/coveragemetrics/required_inventory.go @@ -271,6 +271,60 @@ func requiredBindingWitnessInventory() map[inventoryKey]requiredInventoryEntry { witnessPath: "internal/app/public_abi_mutation_test.go", selectors: []string{"TestIntegrationVersionEdgeRejectsUndeclaredPublicABIDrift"}, }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-lifecycle-cli"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/app/agent_integration_lifecycle_command_test.go", + selectors: []string{"TestIntegrationApplyCLI", "TestIntegrationLifecycleInvocationAndPresentation", "TestIntegrationPlanCLI", "TestIntegrationRecoverCLI"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-lifecycle-npm-carrier"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/tools/packageverify/workflow_carrier_test.go", + selectors: []string{"TestInstalledNPMWorkflowCarrierClosure"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-lifecycle-pair-recognition"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/command/agentintegration/lifecycle_test.go", + selectors: []string{"TestLifecycleBaselineCanonicalAdmission", "TestLifecyclePairRecognition"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-lifecycle-version-edge"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/app/integration_version_edge_test.go", + selectors: []string{"TestManagedIntegrationVersionEdgeClosesDeclaredPublicABIDelta"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-lifecycle-version-edge-mutants"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/app/public_abi_mutation_test.go", + selectors: []string{"TestManagedIntegrationVersionEdgeRejectsUndeclaredPublicABIDrift"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-lifecycle-wheel-carrier"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/tools/pythonpackage/workflow_carrier_test.go", + selectors: []string{"TestInstalledPythonWorkflowCarrierClosure"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-lifecycle-whole-operation"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/command/agentintegration/lifecycle_test.go", + selectors: []string{"TestLifecycleAppliesReplaysAndPreservesNeighbors", "TestLifecycleRejectsChangedIdentityAndLocalEdits"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-recover-busy-stream"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/app/agent_integration_lifecycle_unix_test.go", + selectors: []string{"TestIntegrationRecoverBusyCLIIsClassified"}, + }, + {"REQ-PROOFKIT-WORKFLOW-019", "proofkit.agent-workflow.integration-update-effect-oracle"}: { + commandIDs: []string{"proofkit.go-test"}, + environmentClasses: []string{"local-go"}, + witnessPath: "internal/tools/workflowsmoke/integration_lifecycle_mutation_test.go", + selectors: []string{"TestInstalledLifecycleWitnessRejectsUpdateWithoutEffects"}, + }, {"REQ-PROOFKIT-PACKAGE-001", "proofkit.package-boundary.root-export-and-deep-import-denial"}: { witnessPath: "internal/tools/packageverify/main_test.go", selectors: []string{"TestVerifyRootPackageRejectsEachForbiddenRootEntry"}, diff --git a/internal/tools/releasechange/record.go b/internal/tools/releasechange/record.go index a3b954e..757f8e5 100644 --- a/internal/tools/releasechange/record.go +++ b/internal/tools/releasechange/record.go @@ -190,6 +190,7 @@ func RenderMarkdown(record Record, npmPackage, pythonPackage string, pypiPublish lines = append(lines, "", "GitHub Release assets and checksums are archive and provenance evidence, not package-manager dependency authority.", "", "## Rollback", "", + "- First follow the migration and persistent-state compatibility restrictions above; changing a package pin does not roll back repository state.", fmt.Sprintf("- Pin npm consumers to the previous admitted version %s with `npm install --save-dev --save-exact %s@%s`.", record.PreviousVersion, npmPackage, record.PreviousVersion), ) if pypiPublished { diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 901d6dc..52adf71 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -194,13 +194,20 @@ 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{} +var currentBreakingChanges = []Change{ + {ChangeID: "proofkit.repository-transaction.bound-terminal-replay", Summary: "New terminal receipts use schema v2 to bind each completed transaction to its exact desired-state identity. Legacy schema-v1 receipts remain readable and recoverable, but cannot authorize acknowledgement replay; a newly reviewed plan is required. Roots containing v2 receipts are not compatible with older binaries."}, +} var currentAdditions = []Change{ - {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."}, + {ChangeID: "proofkit.agent-integration.managed-lifecycle", Summary: "Add explicit integration plan, apply and recover routes for reviewed install, update and removal of the selected tool bootstrap. Preserve local edits and neighboring instructions with one native two-target transaction and a cooperative baseline."}, + {ChangeID: "proofkit.repository-transaction.desired-absence", Summary: "Support exact desired absence, guarded deletion and recovery through versioned native journals while preserving present-only v1 bytes and identities. Bind idempotent current-state replay to the pending-state and retained-result checks under one native lock."}, + {ChangeID: "proofkit.repository-transaction.recovery-observation", Summary: "Preserve operational target-observation failures through recovery and both CLI consumers instead of presenting them as observed state mismatches. Keep target and control state unchanged on pre-effect refusal, including temporary-journal publication, and retain cleanup outcomes after the final committed effect."}, } -var currentMigrationSteps = []string{} +var currentMigrationSteps = []string{ + "For an acknowledgement retry backed by a legacy terminal receipt, run adopt materialize plan again and review both current identities before apply; do not reuse the old transaction identity.", + "Use this or a later supporting binary for roots containing v2 journals or terminal receipts. Completing recovery does not make retained v2 receipts downgrade-compatible; do not delete private control state as a downgrade shortcut.", +} func validateCurrentChangeRecord(record Record, notes string) error { if !slices.Equal(record.BreakingChanges, currentBreakingChanges) { @@ -220,7 +227,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.10.1", + "# @research-engineering/agentic-proofkit 0.11.0", "", "## Breaking Contract Changes", "", @@ -242,7 +249,8 @@ func currentExpectedReleaseNotes() string { "", "## Migration", "", - "No consumer migration is required.", + "Migration is required:", + "", ) for _, step := range currentMigrationSteps { lines = append(lines, "- "+step) @@ -263,14 +271,15 @@ func currentExpectedReleaseNotes() string { "- 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.", + "- Managed integration baselines are cooperative byte/mode bookkeeping, not authenticated origin or protection against coordinated same-user edits. File lifecycle does not prove native host discovery, instruction loading, or approved-launcher invocation.", + "- Desired-absence journals and newly retained terminal receipts use schema v2. Earlier binaries reject these records without effects. Present-only v1 plan/journal bytes and historical recovery remain supported; finishing recovery does not enable downgrade of retained v2 receipts.", "", "## Install", "", "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.10.1", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.11.0", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -281,7 +290,8 @@ func currentExpectedReleaseNotes() string { "", "## Rollback", "", - "- 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`.", + "- First follow the migration and persistent-state compatibility restrictions above; changing a package pin does not roll back repository state.", + "- Pin npm consumers to the previous admitted version 0.10.1 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.10.1`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/internal/tools/workflowsmoke/integration_lifecycle_mutation_test.go b/internal/tools/workflowsmoke/integration_lifecycle_mutation_test.go new file mode 100644 index 0000000..aceb01b --- /dev/null +++ b/internal/tools/workflowsmoke/integration_lifecycle_mutation_test.go @@ -0,0 +1,77 @@ +package workflowsmoke_test + +import ( + "context" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/tools/workflowsmoke" +) + +func TestInstalledLifecycleWitnessRejectsUpdateWithoutEffects(t *testing.T) { + for _, selectedTool := range []string{"codex", "claude"} { + t.Run(selectedTool, func(t *testing.T) { + if err := workflowsmoke.Verify(t.Context(), applicationRunner); err != nil { + t.Fatalf("positive lifecycle control failed: %v", err) + } + interceptions := 0 + var replay *workflowsmoke.Result + run := func(ctx context.Context, invocation workflowsmoke.Invocation) (workflowsmoke.Result, error) { + args := invocation.Args + operation := slices.Index(args, "--operation") + if len(args) < 2 || args[0] != "integration" || args[1] != "apply" || operation < 0 || operation+1 >= len(args) || args[operation+1] != "update" { + return applicationRunner(ctx, invocation) + } + toolIndex, rootIndex := slices.Index(args, "--tool"), slices.Index(args, "--repo-root") + if toolIndex < 0 || rootIndex < 0 || toolIndex+1 >= len(args) || rootIndex+1 >= len(args) { + t.Fatal("update witness omitted its exact tool or root") + } + tool, root := args[toolIndex+1], args[rootIndex+1] + if tool != selectedTool { + return applicationRunner(ctx, invocation) + } + interceptions++ + if replay != nil { + return *replay, nil + } + bootstrap := ".agents/skills/agentic-proofkit/SKILL.md" + if tool == "claude" { + bootstrap = ".claude/skills/agentic-proofkit/SKILL.md" + } + paths := []string{bootstrap, "proofkit/integrations/" + tool + ".v1.json"} + before := make([][]byte, len(paths)) + for index, path := range paths { + content, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatal(err) + } + before[index] = content + } + result, err := applicationRunner(ctx, invocation) + if err != nil || result.ExitCode != 0 { + t.Fatalf("positive update control failed: %v", err) + } + // Keep valid process/replay outcomes while mutating only the disk effect. + second, err := applicationRunner(ctx, invocation) + if err != nil || second.ExitCode != 0 { + t.Fatalf("positive replay control failed: %v", err) + } + replay = &second + for index, path := range paths { + if err := os.WriteFile(filepath.Join(root, path), before[index], 0o644); err != nil { + t.Fatal(err) + } + } + return result, nil + } + if err := workflowsmoke.Verify(t.Context(), run); err == nil || err.Error() != "installed lifecycle did not preserve source bytes" { + t.Fatalf("update-effect mutation must fail at the source-byte oracle: %v", err) + } + if interceptions != 2 { + t.Fatalf("update-effect mutation intercepted %d calls, want apply and replay", interceptions) + } + }) + } +} diff --git a/internal/tools/workflowsmoke/integration_lifecycle_smoke.go b/internal/tools/workflowsmoke/integration_lifecycle_smoke.go new file mode 100644 index 0000000..25482fb --- /dev/null +++ b/internal/tools/workflowsmoke/integration_lifecycle_smoke.go @@ -0,0 +1,161 @@ +package workflowsmoke + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/agentintegration" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +func verifyIntegrationLifecycle(ctx context.Context, run Runner, tool string, source map[string]any) (returnErr error) { + root, err := os.MkdirTemp("", "proofkit-managed-smoke-") + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, os.RemoveAll(root)) }() + content, contentOK := source["content"].(string) + relative, pathOK := source["targetPath"].(string) + if !contentOK || !pathOK || !filepath.IsLocal(relative) { + return fmt.Errorf("managed integration source is incomplete") + } + neighbor := filepath.Join(root, "AGENTS.md") + if err := os.WriteFile(neighbor, []byte("protected instructions\n"), 0o644); err != nil { + return err + } + for _, operation := range []string{"install", "update", "remove"} { + if operation == "update" { + if err := seedPriorIntegration(ctx, root, tool, content); err != nil { + return err + } + if _, err := integrationLifecycleRecord(ctx, run, []string{"integration", "check", "--tool", tool, "--repo-root", root}, 2, "proofkit.integration-check.v1", "stale"); err != nil { + return err + } + } + planArgs := []string{"integration", "plan", "--tool", tool, "--operation", operation, "--repo-root", root} + before, err := integrationTree(root) + if err != nil { + return err + } + plan, err := integrationLifecycleRecord(ctx, run, planArgs, 0, "proofkit.integration-plan.v1", "ready") + if err != nil { + return err + } + after, err := integrationTree(root) + if err != nil || !reflect.DeepEqual(before, after) { + return fmt.Errorf("installed lifecycle plan mutated the repository") + } + transaction, err := repositorytransaction.AdmitPlanOutput(plan["transaction"]) + if err != nil { + return err + } + apply := []string{"integration", "apply", "--tool", tool, "--operation", operation, "--repo-root", root, "--expect-transaction", transaction.TransactionID, "--expect-desired-state", transaction.DesiredStateID} + for attempt := 0; attempt < 2; attempt++ { + receipt, err := integrationLifecycleRecord(ctx, run, apply, 0, "proofkit.integration-receipt.v1", "passed") + if err != nil { + return err + } + result, err := repositorytransaction.AdmitResultOutput(receipt["transactionResult"]) + if err != nil || result.TransactionID != transaction.TransactionID || (attempt == 1 && result.State != repositorytransaction.StateAlreadySatisfied) { + return fmt.Errorf("installed lifecycle receipt or replay differs") + } + } + for _, path := range []string{relative, "proofkit/integrations/" + tool + ".v1.json"} { + actual, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if operation == "remove" { + if !os.IsNotExist(err) { + return fmt.Errorf("installed lifecycle remove retained a selected file") + } + } else if err != nil || path == relative && string(actual) != content { + return fmt.Errorf("installed lifecycle did not preserve source bytes") + } + } + if operation != "update" { + recover := []string{"integration", "recover", "--repo-root", root, "--transaction", transaction.TransactionID, "--action", "resume"} + receipt, err := integrationLifecycleRecord(ctx, run, recover, 0, "proofkit.integration-receipt.v1", "passed") + if err != nil { + return err + } + if receipt["tool"] != nil || receipt["expectedDesiredStateId"] != nil || receipt["operation"] != "recover" { + return fmt.Errorf("installed recovery invented current tool authority") + } + } + } + if err := os.WriteFile(filepath.Join(root, relative), []byte("local edit\n"), 0o644); err != nil { + return err + } + before, err := integrationTree(root) + if err != nil { + return err + } + blocked, err := integrationLifecycleRecord(ctx, run, []string{"integration", "plan", "--tool", tool, "--operation", "install", "--repo-root", root}, 1, "proofkit.integration-plan.v1", "blocked") + if err != nil { + return err + } + if blocked["failureClass"] != "unrecognized_bootstrap" || blocked["transaction"] != nil { + return fmt.Errorf("installed lifecycle did not preserve local edit conflict") + } + after, err := integrationTree(root) + if err != nil || !reflect.DeepEqual(before, after) { + return fmt.Errorf("installed lifecycle conflict changed files") + } + protected, err := os.ReadFile(neighbor) + if err != nil || string(protected) != "protected instructions\n" { + return fmt.Errorf("installed lifecycle changed adjacent instructions") + } + return nil +} + +// This Source-produced prior is a synthetic fixture, not a released version. +// Seeding uses the native owner; the installed carrier must perform the update. +func seedPriorIntegration(ctx context.Context, root, tool, currentContent string) error { + capabilities := make([]agentintegration.Capability, 0) + for _, command := range agentintegration.ConsumedCommands() { + capabilities = append(capabilities, agentintegration.Capability{Command: command, Route: []string{command}, ContractDigest: "sha256:" + strings.Repeat("1", 64)}) + } + prior, err := agentintegration.Source(tool, capabilities) + if err != nil || prior.Content() == currentContent { + return fmt.Errorf("prior integration fixture must have distinct admitted source bytes") + } + plan, err := agentintegration.PlanLifecycle(ctx, root, prior, agentintegration.OperationUpdate) + if err != nil { + return err + } + transaction, err := repositorytransaction.AdmitPlanOutput(plan.JSONValue()["transaction"]) + if err != nil { + return err + } + receipt, err := agentintegration.ApplyLifecycle(ctx, root, prior, agentintegration.OperationUpdate, transaction.TransactionID, transaction.DesiredStateID) + if err != nil || receipt.ExitCode() != 0 { + return fmt.Errorf("seed prior integration fixture failed") + } + return nil +} + +func integrationLifecycleRecord(ctx context.Context, run Runner, args []string, wantExit int, kind, state string) (map[string]any, error) { + invocationContext, cancel := context.WithTimeout(ctx, invocationTimeout) + defer cancel() + result, err := run(invocationContext, unreadInvocation(args...)) + if err != nil { + return nil, err + } + if result.ExitCode != wantExit || len(result.Stderr) != 0 { + return nil, fmt.Errorf("installed integration lifecycle process outcome differs") + } + value, err := admission.DecodeJSON(bytes.NewReader(result.Stdout), defaultMaximumStdoutBytes) + if err != nil { + return nil, err + } + record, ok := value.(map[string]any) + if !ok || record["kind"] != kind || record["state"] != state { + return nil, fmt.Errorf("installed integration lifecycle report outcome differs") + } + return record, nil +} diff --git a/internal/tools/workflowsmoke/integration_smoke.go b/internal/tools/workflowsmoke/integration_smoke.go index 8f49691..dc8bd9b 100644 --- a/internal/tools/workflowsmoke/integration_smoke.go +++ b/internal/tools/workflowsmoke/integration_smoke.go @@ -64,6 +64,9 @@ func verifyIntegrations(ctx context.Context, run Runner) (returnErr error) { if err := verifyIntegrationStates(ctx, run, root, tool, source); err != nil { return err } + if err := verifyIntegrationLifecycle(ctx, run, tool, source); err != nil { + return err + } } return verifyFailure(ctx, run, "integration explicit tool", unreadInvocation("integration", "source"), "requires --tool") } diff --git a/internal/tools/workflowsmoke/workflow_smoke_test.go b/internal/tools/workflowsmoke/workflow_smoke_test.go index 0b16a5a..a90c387 100644 --- a/internal/tools/workflowsmoke/workflow_smoke_test.go +++ b/internal/tools/workflowsmoke/workflow_smoke_test.go @@ -35,6 +35,9 @@ func TestVerifyRejectsCarrierContractMutations(t *testing.T) { apply func(workflowsmoke.Result) workflowsmoke.Result }{ {name: "integration source identity", match: "integration source --tool codex --format json", apply: replaceStdout(`{"kind":"wrong"}`)}, + {name: "managed plan missing transaction", match: "integration plan --tool codex --operation install --repo-root ", matchPrefix: true, apply: replaceStdout(`{"kind":"proofkit.integration-plan.v1","state":"ready","transaction":null}`)}, + {name: "managed apply identity", match: "integration apply --tool codex --operation install --repo-root ", matchPrefix: true, apply: replaceStdoutFragment(`"kind": "proofkit.integration-receipt.v1"`, `"kind": "wrong"`)}, + {name: "managed recovery tool authority", match: "integration recover --repo-root ", matchPrefix: true, apply: replaceStdoutFragment(`"tool": null`, `"tool": "codex"`)}, {name: "integration source text suffix", match: "integration source --tool claude --format text", apply: appendStdout("surplus\n")}, {name: "integration missing promoted", match: "integration check --tool codex --repo-root ", matchPrefix: true, apply: func(result workflowsmoke.Result) workflowsmoke.Result { result.ExitCode = 0 diff --git a/package-lock.json b/package-lock.json index b0c6ba9..3b77a11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.10.1", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.10.1", + "version": "0.11.0", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 954e3be..263b942 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.1", + "version": "0.11.0", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index fcc6d31..1fb8b35 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -112,7 +112,7 @@ "rootDefinitionDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:2940a39db54e51167e3f3373aa38525fc734fddc3902ac7d7ed1fdf326066cfe", + "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -143,17 +143,17 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:2940a39db54e51167e3f3373aa38525fc734fddc3902ac7d7ed1fdf326066cfe", + "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", "evidenceClass": "source_checkout" }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", + "canonicalDigest": "sha256:005381c2fc6760a2a225a4853a2d75c3fc044286c2819cfd09908301d15b3b1b", "evidenceClass": "source_checkout" } ], @@ -167,7 +167,8 @@ "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" + "root-shape-only definition proofkit.adoption-materialization.apply-output.v1.root-shape; nested fields, types, and cardinalities remain native-owner claims", + "Replay requires a retained generation-2 terminal receipt binding the exact desiredStateId under the native lock; generation-1 receipts remain recoverable but acknowledgement retries require a newly reviewed plan. Roots with generation-2 receipts require a supporting binary even after recovery completes." ], "ownerRequirementRefs": [ "REQ-PROOFKIT-PACKAGE-002", @@ -250,7 +251,7 @@ "rootDefinitionDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:2940a39db54e51167e3f3373aa38525fc734fddc3902ac7d7ed1fdf326066cfe", + "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -281,17 +282,17 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:2940a39db54e51167e3f3373aa38525fc734fddc3902ac7d7ed1fdf326066cfe", + "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", "evidenceClass": "source_checkout" }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", + "canonicalDigest": "sha256:005381c2fc6760a2a225a4853a2d75c3fc044286c2819cfd09908301d15b3b1b", "evidenceClass": "source_checkout" } ], @@ -395,17 +396,17 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:2940a39db54e51167e3f3373aa38525fc734fddc3902ac7d7ed1fdf326066cfe", + "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", "evidenceClass": "source_checkout" }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", + "canonicalDigest": "sha256:005381c2fc6760a2a225a4853a2d75c3fc044286c2819cfd09908301d15b3b1b", "evidenceClass": "source_checkout" } ], @@ -1186,7 +1187,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, { @@ -2425,6 +2426,144 @@ ] } }, + { + "command": "integration-apply", + "route": [ + "integration", + "apply" + ], + "input": "none", + "stdin": false, + "inputPointer": false, + "scopeClass": "explicit_filesystem_mutation", + "outputModes": [ + "json", + "text" + ], + "allowedFlags": [ + "--color", + "--expect-desired-state", + "--expect-transaction", + "--format", + "--operation", + "--repo-root", + "--tool" + ], + "requiredFlags": [ + "--expect-desired-state", + "--expect-transaction", + "--operation", + "--repo-root", + "--tool" + ], + "singleOccurrenceFlags": [ + "--color", + "--expect-desired-state", + "--expect-transaction", + "--format", + "--operation", + "--repo-root", + "--tool" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--operation": [ + "install", + "remove", + "update" + ], + "--tool": [ + "claude", + "codex" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--color", + "requiredFlagValues": [ + { + "flag": "--format", + "value": "text" + } + ], + "requiredFlags": [] + } + ], + "outputContract": { + "contractId": "proofkit.integration-apply.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.integration-apply.output.v1.root-shape", + "rootDefinitionDigest": "sha256:9072801d4aa7a9820e81f0de02ff04e22ced6188958c31d2f3682d01d2cc84ed", + "nativeSources": [ + { + "path": "internal/app", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/command/agentintegration", + "canonicalDigest": "sha256:e03209a37e8c7cc7c59c56c80c2906b789091e8de25679a6fe37704bfb2009c4", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/kernel/repositorytransaction", + "canonicalDigest": "sha256:005381c2fc6760a2a225a4853a2d75c3fc044286c2819cfd09908301d15b3b1b", + "evidenceClass": "source_checkout" + } + ], + "nativeOutputWitnessSelector": { + "path": "internal/app/agent_integration_lifecycle_command_test.go", + "test": "TestIntegrationApplyCLI", + "command": "go test ./internal/app -run '^TestIntegrationApplyCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "kind=proofkit.integration-receipt.v1; state=passed|blocked|failed|recovery_required|cleanup_required|durability_unknown; transactionResult is the native owner projection or null", + "JSON is the default; text is opt-in with color=auto|never, default never; color requires explicit --format text and ANSI requires a capable TTY without NO_COLOR", + "No input, stdin, input pointer, output-file, implicit tool selection, package installation or native host activation", + "Invocation flags and SHA256 identity references are admitted before repository I/O; unsafe routes, I/O, pre-effect cancellation and output errors use exit 1 and non-disclosing stderr without a classified JSON packet", + "Ready plans and passed receipts exit 0; classified conflict, recovery, cleanup and durability outcomes exit 1 with exactly one report and empty stderr", + "Selected fixed bootstrap and proofkit/integrations/.v1.json form one native transaction; canonical baseline is cooperative byte/mode bookkeeping, not authenticated origin", + "Apply requires both reviewed transaction and desired-state identities; replay requires exact current desired files, no pending transaction and the retained applied receipt under one native lock", + "root-shape-only definition proofkit.integration-apply.output.v1.root-shape; nested field shapes, leaf types and relational semantics remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-033", + "REQ-PROOFKIT-WORKFLOW-019" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--operation": [ + "install", + "remove", + "update" + ], + "--tool": [ + "claude", + "codex" + ] + } + } + }, { "command": "integration-check", "route": [ @@ -2473,17 +2612,17 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, { "path": "internal/command/agentintegration", - "canonicalDigest": "sha256:d85cf0fc38bbd28f86ececb3e41f9e04fca7f2741507bf006cd1a55500484505", + "canonicalDigest": "sha256:e03209a37e8c7cc7c59c56c80c2906b789091e8de25679a6fe37704bfb2009c4", "evidenceClass": "source_checkout" }, { "path": "internal/kernel/repositorytransaction", - "canonicalDigest": "sha256:927d0a6fcdd5bfcb3961b78cc0bad7ccad1d991f3bdb2c7af1f476afcd215bfc", + "canonicalDigest": "sha256:005381c2fc6760a2a225a4853a2d75c3fc044286c2819cfd09908301d15b3b1b", "evidenceClass": "source_checkout" }, { @@ -2527,6 +2666,260 @@ } } }, + { + "command": "integration-plan", + "route": [ + "integration", + "plan" + ], + "input": "none", + "stdin": false, + "inputPointer": false, + "scopeClass": "explicit_filesystem_scan", + "outputModes": [ + "json", + "text" + ], + "allowedFlags": [ + "--color", + "--format", + "--operation", + "--repo-root", + "--tool" + ], + "requiredFlags": [ + "--operation", + "--repo-root", + "--tool" + ], + "singleOccurrenceFlags": [ + "--color", + "--format", + "--operation", + "--repo-root", + "--tool" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--operation": [ + "install", + "remove", + "update" + ], + "--tool": [ + "claude", + "codex" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--color", + "requiredFlagValues": [ + { + "flag": "--format", + "value": "text" + } + ], + "requiredFlags": [] + } + ], + "outputContract": { + "contractId": "proofkit.integration-plan.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.integration-plan.output.v1.root-shape", + "rootDefinitionDigest": "sha256:2f1c3c16d0a794ed7f2cbc8a71062e8f8d1d0a6076980a729b6f62dfb131cedd", + "nativeSources": [ + { + "path": "internal/app", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/command/agentintegration", + "canonicalDigest": "sha256:e03209a37e8c7cc7c59c56c80c2906b789091e8de25679a6fe37704bfb2009c4", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/kernel/repositorytransaction", + "canonicalDigest": "sha256:005381c2fc6760a2a225a4853a2d75c3fc044286c2819cfd09908301d15b3b1b", + "evidenceClass": "source_checkout" + } + ], + "nativeOutputWitnessSelector": { + "path": "internal/app/agent_integration_lifecycle_command_test.go", + "test": "TestIntegrationPlanCLI", + "command": "go test ./internal/app -run '^TestIntegrationPlanCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "kind=proofkit.integration-plan.v1; state=ready|blocked|recovery_required; ready contains an owner-admitted descriptive transaction, conflict has transaction=null", + "JSON is the default; text is opt-in with color=auto|never, default never; color requires explicit --format text and ANSI requires a capable TTY without NO_COLOR", + "No input, stdin, input pointer, output-file, implicit tool selection, package installation or native host activation", + "Invocation flags and SHA256 identity references are admitted before repository I/O; unsafe routes, I/O, pre-effect cancellation and output errors use exit 1 and non-disclosing stderr without a classified JSON packet", + "Ready plans and passed receipts exit 0; classified conflict, recovery, cleanup and durability outcomes exit 1 with exactly one report and empty stderr", + "Selected fixed bootstrap and proofkit/integrations/.v1.json form one native transaction; canonical baseline is cooperative byte/mode bookkeeping, not authenticated origin", + "Install never overwrites unknown or stale unbaselined content; update requires a matched prior baseline; remove deletes only a matched pair or valid orphan baseline, never adjacent instructions", + "root-shape-only definition proofkit.integration-plan.output.v1.root-shape; nested field shapes, leaf types and relational semantics remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-033", + "REQ-PROOFKIT-WORKFLOW-019" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--operation": [ + "install", + "remove", + "update" + ], + "--tool": [ + "claude", + "codex" + ] + } + } + }, + { + "command": "integration-recover", + "route": [ + "integration", + "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": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--action": [ + "resume", + "rollback" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--color", + "requiredFlagValues": [ + { + "flag": "--format", + "value": "text" + } + ], + "requiredFlags": [] + } + ], + "outputContract": { + "contractId": "proofkit.integration-recover.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.integration-recover.output.v1.root-shape", + "rootDefinitionDigest": "sha256:c833d7c58535cc55b77c0cbbac27899ff3eb3be3ec9ceddad24ed17808f3b7a9", + "nativeSources": [ + { + "path": "internal/app", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/command/agentintegration", + "canonicalDigest": "sha256:e03209a37e8c7cc7c59c56c80c2906b789091e8de25679a6fe37704bfb2009c4", + "evidenceClass": "source_checkout" + }, + { + "path": "internal/kernel/repositorytransaction", + "canonicalDigest": "sha256:005381c2fc6760a2a225a4853a2d75c3fc044286c2819cfd09908301d15b3b1b", + "evidenceClass": "source_checkout" + } + ], + "nativeOutputWitnessSelector": { + "path": "internal/app/agent_integration_lifecycle_command_test.go", + "test": "TestIntegrationRecoverCLI", + "command": "go test ./internal/app -run '^TestIntegrationRecoverCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "kind=proofkit.integration-receipt.v1; state=passed|blocked|failed|recovery_required|cleanup_required|durability_unknown; transactionResult is the native owner projection or null", + "JSON is the default; text is opt-in with color=auto|never, default never; color requires explicit --format text and ANSI requires a capable TTY without NO_COLOR", + "No input, stdin, input pointer, output-file, implicit tool selection, package installation or native host activation", + "Invocation flags and SHA256 identity references are admitted before repository I/O; unsafe routes, I/O, pre-effect cancellation and output errors use exit 1 and non-disclosing stderr without a classified JSON packet", + "Ready plans and passed receipts exit 0; classified conflict, recovery, cleanup and durability outcomes exit 1 with exactly one report and empty stderr", + "Recovery requires only root, transaction and action; tool and expectedDesiredStateId are null; historical recovery does not prove current installed or removed state", + "A committed transaction cannot be rolled back; native incomplete transactions retain the native resume and rollback rules", + "root-shape-only definition proofkit.integration-recover.output.v1.root-shape; nested field shapes, leaf types and relational semantics remain native-owner claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-033", + "REQ-PROOFKIT-WORKFLOW-019" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--action": [ + "resume", + "rollback" + ] + } + } + }, { "command": "integration-source", "route": [ @@ -2572,12 +2965,12 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, { "path": "internal/command/agentintegration", - "canonicalDigest": "sha256:d85cf0fc38bbd28f86ececb3e41f9e04fca7f2741507bf006cd1a55500484505", + "canonicalDigest": "sha256:e03209a37e8c7cc7c59c56c80c2906b789091e8de25679a6fe37704bfb2009c4", "evidenceClass": "source_checkout" } ], @@ -3247,7 +3640,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, { @@ -6537,7 +6930,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -6566,7 +6959,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:8cf495d309f3447cc7743e8c2e9ff303e5f4bb78d69eeff27fd48d37fa47d605", + "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -10980,6 +11373,54 @@ }, "canonicalDigest": "sha256:e6dacd55cace039ca8e24bdec628b1498d0c12bbfcdb60c46854ab2047c3f2d7" }, + { + "definitionId": "proofkit.integration-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", + "kind", + "nonClaims", + "operation", + "schemaVersion", + "state", + "tool", + "transactionResult" + ], + "requiredFields": [ + "expectedDesiredStateId", + "expectedTransactionId", + "failureClass", + "kind", + "nonClaims", + "operation", + "schemaVersion", + "state", + "tool", + "transactionResult" + ], + "rootKind": "object", + "variantId": "01-root", + "when": [ + "default JSON mode" + ] + } + ] + }, + "canonicalDigest": "sha256:9072801d4aa7a9820e81f0de02ff04e22ced6188958c31d2f3682d01d2cc84ed" + }, { "definitionId": "proofkit.integration-check.output.v1.root-shape", "schemaVersion": 1, @@ -11024,6 +11465,100 @@ }, "canonicalDigest": "sha256:309c5f43b8d5596d88859819ed59e4486926d75d5f41d2b2b6e252389f5913ca" }, + { + "definitionId": "proofkit.integration-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": [ + "failureClass", + "kind", + "nonClaims", + "operation", + "recoveryTransactionId", + "schemaVersion", + "state", + "tool", + "transaction" + ], + "requiredFields": [ + "failureClass", + "kind", + "nonClaims", + "operation", + "recoveryTransactionId", + "schemaVersion", + "state", + "tool", + "transaction" + ], + "rootKind": "object", + "variantId": "01-root", + "when": [ + "default JSON mode" + ] + } + ] + }, + "canonicalDigest": "sha256:2f1c3c16d0a794ed7f2cbc8a71062e8f8d1d0a6076980a729b6f62dfb131cedd" + }, + { + "definitionId": "proofkit.integration-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", + "kind", + "nonClaims", + "operation", + "schemaVersion", + "state", + "tool", + "transactionResult" + ], + "requiredFields": [ + "expectedDesiredStateId", + "expectedTransactionId", + "failureClass", + "kind", + "nonClaims", + "operation", + "schemaVersion", + "state", + "tool", + "transactionResult" + ], + "rootKind": "object", + "variantId": "01-root", + "when": [ + "default JSON mode" + ] + } + ] + }, + "canonicalDigest": "sha256:c833d7c58535cc55b77c0cbbac27899ff3eb3be3ec9ceddad24ed17808f3b7a9" + }, { "definitionId": "proofkit.integration-source.output.v1.root-shape", "schemaVersion": 1, diff --git a/proofkit/command-families.v1.json b/proofkit/command-families.v1.json index d0582be..20bfb75 100644 --- a/proofkit/command-families.v1.json +++ b/proofkit/command-families.v1.json @@ -30,9 +30,12 @@ { "familyId": "agent-integrations", "label": "Agent integrations", - "purpose": "Generate portable bootstrap source and check generated-byte freshness without installation or host activation.", + "purpose": "Generate and check portable bootstrap source, then explicitly plan, apply or recover its managed file lifecycle without claiming native host activation.", "commands": [ + "integration-apply", "integration-check", + "integration-plan", + "integration-recover", "integration-source" ] }, diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 8d429d3..58d2599 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -943,6 +943,18 @@ "Cancellation after the final pre-emission checkpoint cannot retract accepted output, and a caller-provided writer that accepts a prefix before failure is not an atomic sink.", "Phase5A is generation/check only, not managed installation, update, removal, host activation, permission grant, semantic full proof, merge approval, or production readiness." ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "ownerId": "proofkit.agent-workflow", + "specPath": "docs/specs/proofkit-agent-workflow/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": [ + "A completed file lifecycle does not prove host discovery, instruction loading, approved-launcher invocation, permission grants, native verification, or production readiness.", + "Recovery reports a historical transaction, not current installed/removed state or post-return stability; cancellation after the final effect cannot retract a committed operation.", + "The baseline is cooperative bookkeeping, not authenticated origin, owner approval, or protection from coordinated same-user edits or rollback." + ] } ], "bindings": [ @@ -7113,6 +7125,324 @@ "environmentClasses": [ "local-go" ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-lifecycle-pair-recognition", + "witnessId": "proofkit.agent-integration.integration-lifecycle-pair-recognition-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/agentintegration/lifecycle_test.go", + "witnessSelectors": [ + { + "selector": "TestLifecycleBaselineCanonicalAdmission", + "command": "go test ./internal/command/agentintegration -run '^TestLifecycleBaselineCanonicalAdmission$'" + }, + { + "selector": "TestLifecyclePairRecognition", + "command": "go test ./internal/command/agentintegration -run '^TestLifecyclePairRecognition$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-lifecycle-whole-operation", + "witnessId": "proofkit.agent-integration.integration-lifecycle-whole-operation-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/agentintegration/lifecycle_test.go", + "witnessSelectors": [ + { + "selector": "TestLifecycleAppliesReplaysAndPreservesNeighbors", + "command": "go test ./internal/command/agentintegration -run '^TestLifecycleAppliesReplaysAndPreservesNeighbors$'" + }, + { + "selector": "TestLifecycleRejectsChangedIdentityAndLocalEdits", + "command": "go test ./internal/command/agentintegration -run '^TestLifecycleRejectsChangedIdentityAndLocalEdits$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-lifecycle-cli", + "witnessId": "proofkit.agent-integration.integration-lifecycle-cli-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/agent_integration_lifecycle_command_test.go", + "witnessSelectors": [ + { + "selector": "TestIntegrationApplyCLI", + "command": "go test ./internal/app -run '^TestIntegrationApplyCLI$'" + }, + { + "selector": "TestIntegrationLifecycleInvocationAndPresentation", + "command": "go test ./internal/app -run '^TestIntegrationLifecycleInvocationAndPresentation$'" + }, + { + "selector": "TestIntegrationPlanCLI", + "command": "go test ./internal/app -run '^TestIntegrationPlanCLI$'" + }, + { + "selector": "TestIntegrationRecoverCLI", + "command": "go test ./internal/app -run '^TestIntegrationRecoverCLI$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-lifecycle-version-edge", + "witnessId": "proofkit.agent-integration.integration-lifecycle-version-edge-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/integration_version_edge_test.go", + "witnessSelectors": [ + { + "selector": "TestManagedIntegrationVersionEdgeClosesDeclaredPublicABIDelta", + "command": "go test ./internal/app -run '^TestManagedIntegrationVersionEdgeClosesDeclaredPublicABIDelta$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-lifecycle-version-edge-mutants", + "witnessId": "proofkit.agent-integration.integration-lifecycle-version-edge-mutants-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/public_abi_mutation_test.go", + "witnessSelectors": [ + { + "selector": "TestManagedIntegrationVersionEdgeRejectsUndeclaredPublicABIDrift", + "command": "go test ./internal/app -run '^TestManagedIntegrationVersionEdgeRejectsUndeclaredPublicABIDrift$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-lifecycle-npm-carrier", + "witnessId": "proofkit.agent-integration.integration-lifecycle-npm-carrier-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/packageverify/workflow_carrier_test.go", + "witnessSelectors": [ + { + "selector": "TestInstalledNPMWorkflowCarrierClosure", + "command": "go test ./internal/tools/packageverify -run '^TestInstalledNPMWorkflowCarrierClosure$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-lifecycle-wheel-carrier", + "witnessId": "proofkit.agent-integration.integration-lifecycle-wheel-carrier-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/pythonpackage/workflow_carrier_test.go", + "witnessSelectors": [ + { + "selector": "TestInstalledPythonWorkflowCarrierClosure", + "command": "go test ./internal/tools/pythonpackage -run '^TestInstalledPythonWorkflowCarrierClosure$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.repository-transaction.desired-absence-action-and-admission", + "witnessId": "proofkit.repository-transaction.desired-absence-action-and-admission-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/delete_test.go", + "witnessSelectors": [ + { + "selector": "TestAbsentTargetRejectsPayloadAndNoncanonicalVersionWithoutMutation", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestAbsentTargetRejectsPayloadAndNoncanonicalVersionWithoutMutation$'" + }, + { + "selector": "TestAbsentTargetsDistinguishDeletionEmptyAndUnchanged", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestAbsentTargetsDistinguishDeletionEmptyAndUnchanged$'" + }, + { + "selector": "TestDeleteChecksLastBeforeImageAndPreservesForeignRecreation", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestDeleteChecksLastBeforeImageAndPreservesForeignRecreation$'" + }, + { + "selector": "TestMixedAbsentTargetDoesNotRequireOrCreateItsParents", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestMixedAbsentTargetDoesNotRequireOrCreateItsParents$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.repository-transaction.desired-absence-recovery-and-interruption", + "witnessId": "proofkit.repository-transaction.desired-absence-recovery-and-interruption-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/delete_recovery_test.go", + "witnessSelectors": [ + { + "selector": "TestDeletionProcessInterruptionAtMutationBoundaries", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestDeletionProcessInterruptionAtMutationBoundaries$'" + }, + { + "selector": "TestDeletionRecoversEveryMixedPrefixAndHistoricalResult", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestDeletionRecoversEveryMixedPrefixAndHistoricalResult$'" + }, + { + "selector": "TestDeletionRejectsMissingCorruptAndUnexpectedStagedObjects", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestDeletionRejectsMissingCorruptAndUnexpectedStagedObjects$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.repository-transaction.current-state-locked-replay", + "witnessId": "proofkit.repository-transaction.current-state-locked-replay-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/replay_test.go", + "witnessSelectors": [ + { + "selector": "TestReplayAppliedRequiresCurrentNativePlanAndExactRetainedResult", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestReplayAppliedRequiresCurrentNativePlanAndExactRetainedResult$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.repository-transaction.predecessor-v1-compatibility", + "witnessId": "proofkit.repository-transaction.predecessor-v1-compatibility-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/v1_compatibility_test.go", + "witnessSelectors": [ + { + "selector": "TestPresentOnlyPlansPreserveIndependentPredecessorBytes", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestPresentOnlyPlansPreserveIndependentPredecessorBytes$'" + }, + { + "selector": "TestRecoverIndependentPredecessorJournalAndStagedBytes", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestRecoverIndependentPredecessorJournalAndStagedBytes$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-033", + "scenarioId": "proofkit.repository-transaction.bound-terminal-identity", + "witnessId": "proofkit.repository-transaction.bound-terminal-identity-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/repositorytransaction/terminal_identity_test.go", + "witnessSelectors": [ + { + "selector": "TestBoundTerminalRecoveryCompletesInterruptedArchive", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestBoundTerminalRecoveryCompletesInterruptedArchive$'" + }, + { + "selector": "TestLegacyTerminalRecoveryPreservesHistoricalIdentity", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestLegacyTerminalRecoveryPreservesHistoricalIdentity$'" + }, + { + "selector": "TestTerminalReceiptDesiredIdentityAdmission", + "command": "go test ./internal/kernel/repositorytransaction -run '^TestTerminalReceiptDesiredIdentityAdmission$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-recover-busy-stream", + "witnessId": "proofkit.agent-workflow.integration-recover-busy-stream-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/agent_integration_lifecycle_unix_test.go", + "witnessSelectors": [ + { + "selector": "TestIntegrationRecoverBusyCLIIsClassified", + "command": "go test ./internal/app -run '^TestIntegrationRecoverBusyCLIIsClassified$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-WORKFLOW-019", + "scenarioId": "proofkit.agent-workflow.integration-update-effect-oracle", + "witnessId": "proofkit.agent-workflow.integration-update-effect-oracle-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/workflowsmoke/integration_lifecycle_mutation_test.go", + "witnessSelectors": [ + { + "selector": "TestInstalledLifecycleWitnessRejectsUpdateWithoutEffects", + "command": "go test ./internal/tools/workflowsmoke -run '^TestInstalledLifecycleWitnessRejectsUpdateWithoutEffects$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] } ], "witnessCommands": [ diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 46e995a..73d593f 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,18 +1,34 @@ { "schemaVersion": 2, - "previousVersion": "0.10.0", - "version": "0.10.1", - "changeClass": "compatible", - "breakingChanges": [], + "previousVersion": "0.10.1", + "version": "0.11.0", + "changeClass": "breaking", + "breakingChanges": [ + { + "changeId": "proofkit.repository-transaction.bound-terminal-replay", + "summary": "New terminal receipts use schema v2 to bind each completed transaction to its exact desired-state identity. Legacy schema-v1 receipts remain readable and recoverable, but cannot authorize acknowledgement replay; a newly reviewed plan is required. Roots containing v2 receipts are not compatible with older binaries." + } + ], "additions": [ { - "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." + "changeId": "proofkit.agent-integration.managed-lifecycle", + "summary": "Add explicit integration plan, apply and recover routes for reviewed install, update and removal of the selected tool bootstrap. Preserve local edits and neighboring instructions with one native two-target transaction and a cooperative baseline." + }, + { + "changeId": "proofkit.repository-transaction.desired-absence", + "summary": "Support exact desired absence, guarded deletion and recovery through versioned native journals while preserving present-only v1 bytes and identities. Bind idempotent current-state replay to the pending-state and retained-result checks under one native lock." + }, + { + "changeId": "proofkit.repository-transaction.recovery-observation", + "summary": "Preserve operational target-observation failures through recovery and both CLI consumers instead of presenting them as observed state mismatches. Keep target and control state unchanged on pre-effect refusal, including temporary-journal publication, and retain cleanup outcomes after the final committed effect." } ], "migration": { - "required": false, - "steps": [] + "required": true, + "steps": [ + "For an acknowledgement retry backed by a legacy terminal receipt, run adopt materialize plan again and review both current identities before apply; do not reuse the old transaction identity.", + "Use this or a later supporting binary for roots containing v2 journals or terminal receipts. Completing recovery does not make retained v2 receipts downgrade-compatible; do not delete private control state as a downgrade shortcut." + ] }, "platformRequirements": [ "Published Darwin package binaries require macOS 13.0 or later on arm64 and x86_64." @@ -26,7 +42,8 @@ "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." + "Managed integration baselines are cooperative byte/mode bookkeeping, not authenticated origin or protection against coordinated same-user edits. File lifecycle does not prove native host discovery, instruction loading, or approved-launcher invocation.", + "Desired-absence journals and newly retained terminal receipts use schema v2. Earlier binaries reject these records without effects. Present-only v1 plan/journal bytes and historical recovery remain supported; finishing recovery does not enable downgrade of retained v2 receipts." ], "rollback": { "strategy": "previous_admitted_version" diff --git a/scripts/workflow_package_gate_oracle_test.go b/scripts/workflow_package_gate_oracle_test.go index 30437f1..3e82604 100644 --- a/scripts/workflow_package_gate_oracle_test.go +++ b/scripts/workflow_package_gate_oracle_test.go @@ -20,7 +20,7 @@ const mergeSatisfyingProducerEnv = "PROOFKIT_MERGE_SATISFYING_PRODUCER" const requiredPlatformSmokeOwnerCommand = "go run ./internal/tools/packagebuild current && ./dist/agentic-proofkit --help >/dev/null && go run ./internal/tools/pythonpackage build-current && go run ./internal/tools/pythonpackage verify-current" const setupVerifiedNPMActionSHA256 = "73e760391a9f93b95034aec8edd12af081051ce6859d3114e3561c4d255d061f" -const ciSourceQualityStepInventorySHA256 = "08f0a637b09bc8071ae771d57cf7a74493fd392aa10a2e952e9e73de00e591c7" +const ciSourceQualityStepInventorySHA256 = "f4eb399e1a1fecbbb9367ae3b45ab4ca38e3ed282dbb88ee31e567f6c1bd2a9a" const ciBrowserRuntimeStepInventorySHA256 = "75774851de2c5d6a020d2ee9aa4d1c877d4d9d879b4272e715bdaaf7f7da9cec" const releaseCandidateStepInventorySHA256 = "32077f0273db85606716b297776fec5c1777e96cf25b695995846d5b259ce0aa"