From f2394e03a887c7a7c96aaef5dd0c37d4f690d173 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Fri, 24 Jul 2026 14:28:52 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ c7d80b2f9948 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 35 +- boatstack/cmd/boatstack-helper/main.go | 83 ++- boatstack/export.go | 2 +- boatstack/mutation.go | 590 ++++++++++++++++++ boatstack/mutation_test.go | 440 +++++++++++++ boatstack/mutation_undo.go | 158 +++++ boatstack/mutation_undo_test.go | 111 ++++ boatstack/plan.go | 247 +++++++- boatstack/plan_test.go | 74 +++ boatstack/references/failure-moves.md | 1 + boatstack/references/workflow.md | 8 +- boatstack/safety.go | 11 +- docs/evidence-engineered-coding.md | 4 +- docs/public-claims.json | 24 +- labs/diagram-json/plan.lock.json | 2 +- ...6-07-24-transactional-mutation-boundary.md | 14 + 17 files changed, 1741 insertions(+), 65 deletions(-) create mode 100644 boatstack/mutation.go create mode 100644 boatstack/mutation_test.go create mode 100644 boatstack/mutation_undo.go create mode 100644 boatstack/mutation_undo_test.go create mode 100644 release-notes/2026-07-24-transactional-mutation-boundary.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfeeacb..f362ad2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/4dcec85043e6fec7558e7bdff97cb2babe3af7c2/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/c7d80b2f99481f8065a5fc5f60ee4d41958f5efa/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index cc211b9..57f8535 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 62113, - "estimated_tokens": 15529, + "characters": 65626, + "estimated_tokens": 16407, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "89b36205a93d738d4052c1269e8994076c08526a29eb645a90fe869e85514981", + "CONTRIBUTING.md": "543fc4bc84309b116b5aee230ed129ee81567cc2ad968bb8326f1306f332a858", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,7 +40,7 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "a094a0be3b64ad4626edf9e73e31cf89e707e0bfaa0f7af89ee49f3381a7e6ad", + "boatstack/cmd/boatstack-helper/main.go": "c6e1982a63d3e637ef70b8442d22d4fa5308da30e618c205cc3524f1c3b23a85", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", @@ -50,7 +50,7 @@ "boatstack/delivery.go": "ea53af0e702ec3668a563a5f786dcac2e095362285ca6093b7ed71ec495a0a48", "boatstack/delivery_test.go": "564ad2029a8412de7953967b1acdf377e6c87b6f6e3465d1f8741a4813f2949f", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "228d193a1dd6c506600c29f29387761ffdac8a32acc3921c8825013212395f3d", + "boatstack/export.go": "9d2b83b6075b3715599a3c19afb3a7ec8d2a006f8af624e3807f3b1fe7620065", "boatstack/export_test.go": "67eb890728d20630925d6e4e90d2a97ec025195ba5c54994c1b098ab72721dca", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", @@ -64,12 +64,16 @@ "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/migrate.go": "eaf589e2b266238068e42c6d78e01dc040266d28e342cb24f09e33e8541749b3", "boatstack/migrate_test.go": "9f4bda2fb158c5e54bcc0242dace1da3c1965f9846a213c573956a35b7d1724e", + "boatstack/mutation.go": "59fc9e92105d8ec20f854af9898cde037ab0e3e46c453794838dbfc65fecdd6d", + "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", + "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", + "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", "boatstack/next.go": "ce2660e1d7649e0356ee4708aff8ce9ee5961165cf3026df4b8974528c703f71", "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", "boatstack/operation.go": "62f97bf2091f33eb2ca91915bf08bee73d53387611b673e849355bfd516ca467", "boatstack/operation_test.go": "2d624eaba342b2c81b45cdf50918a65a9c002b5376a02041b24180658ee6a25a", - "boatstack/plan.go": "6854d744a14e60eb285216ddc90c41085d6dde08ca5cae497e6863a703123b2f", - "boatstack/plan_test.go": "be866076d6932668cefab3ceb6d888394c4d989d03edb28b48221e43f860b630", + "boatstack/plan.go": "ddb5148113454502a57c45a7dc2e514b9d6c191b5035bbee10f15e2fe5e06ddf", + "boatstack/plan_test.go": "53477515165a910910b9175bfa33574548cf0d0f3be48e175ec3a775a12d30bb", "boatstack/plan_validation.go": "412f06750832fe46f01190ea5e475fc6f6ea59c8ba78131f94ec031053a405d2", "boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa", "boatstack/planning.go": "ef4507a9fecc900f0691372c50883f328c9232c3dfd6986fbde26fb7ef436ae3", @@ -83,11 +87,11 @@ "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", - "boatstack/references/failure-moves.md": "03eb5d2c11fd1b58bcee43d96626d45ffc466e785926c5ef0b73e351ffdc87ec", + "boatstack/references/failure-moves.md": "59cb691e618cedd30e53a1e0371c1c7c66f6164e41571ae7e717c42987a1d4a5", "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "b38abdf69533f75471278c9fa69fd8f0e6b744ee891cf5b3223e92e75b4e4bbb", + "boatstack/references/workflow.md": "3cb4eef7c7c519f2c07c192b369e9c8af60ac86873be3da918a7f60a343834a4", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", @@ -96,7 +100,7 @@ "boatstack/runtime.go": "d1e95895002ea2b27199b6e05b33c4c6e20f63455a44f63ca3cfeedecfc23420", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", - "boatstack/safety.go": "7f914279b3900d7357e965322a943263713b1fdfb24c1e0a3b9942f69c126ae3", + "boatstack/safety.go": "9e1dd0a40524304b6eb3e0ad9f24ebe12bd1a72794b77bd74b4f79e81992765c", "boatstack/safety_test.go": "01f28bc3bfcb6bdd47b307e309e36bbc1921b6426ad0b777d81fe4131200c37e", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", @@ -119,10 +123,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "5babc3a8484d8b17a11e9c8014ac13837c6f12489211cb822048d9e1017892e5", + "docs/evidence-engineered-coding.md": "a3272e12807c4dc62305171b6c6e29dbdbb3ff6bc142b3e123b6909485b7d79c", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "f314270c5ed1a55bbef5f3ddbcb5596693dbee9374e5f0d3df8838cefbd68052", - "docs/public-claims.json": "7e57c6835d1b9244be1afb60f0ec5498b68b6ae147863391a1793d41e1f2056a", + "docs/public-claims.json": "e462355d1628110e97fc2ccd1747c16f83cf76536b01950afbb0d9ca7cd1f40b", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -136,7 +140,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "4364d0ec4235fbff6448066adc1e12f3403d9018b146d0438e633ea9e2732386", + "labs/diagram-json/plan.lock.json": "e6b695720de7ae1761bd489b63ead5fe5ea2204d66aec74ada991c67d9ac66c3", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -210,12 +214,13 @@ "release-notes/2026-07-23-sync-title-contract.md": "2869d6d084ea60402e57ffe985d0fc4cd83ef9bb09958cc53e349157d3383202", "release-notes/2026-07-23-visual-evidence-external-host.md": "09edbe5e6e1bfc866cf5ee744a5001d678f7a67f0f330cf43bd5157eedf04276", "release-notes/2026-07-24-ignored-deliveries-publication-authority.md": "a25f8469316276101490c79a57c1a236c18072dfbb23682bb1778871d247067d", - "release-notes/2026-07-24-repair-state-recovery.md": "daaa12deb51a5f647178d6164ea5b4bcd29bf5482b77d90002429f69e0da5dd0" + "release-notes/2026-07-24-repair-state-recovery.md": "daaa12deb51a5f647178d6164ea5b4bcd29bf5482b77d90002429f69e0da5dd0", + "release-notes/2026-07-24-transactional-mutation-boundary.md": "38819a4811edbc99a9d8a77983aedbd0589bdbbf849da4991d3e21a1b319a65c" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "4dcec85043e6fec7558e7bdff97cb2babe3af7c2", + "commit": "c7d80b2f99481f8065a5fc5f60ee4d41958f5efa", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index aba7e00..dd2cbbe 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -642,6 +642,83 @@ func repairStateCommand(arguments []string) int { return 0 } +func mutationStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("mutation-status", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose managed-artifact mutation receipts should be listed") + mutation := flags.String("mutation", "", "optional specific mutation id to inspect") + jsonOutput := flags.Bool("json", false, "print the structured mutation receipt(s)") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if strings.TrimSpace(*mutation) != "" { + receipt, ok, err := boatstack.GetMutationReceipt(*repo, *mutation) + if err != nil { + return fail(err) + } + if !ok { + fmt.Fprintf(os.Stderr, "no mutation receipt for %s\n", *mutation) + return 1 + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(receipt) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Printf("Mutation: %s\nKind: %s\nStatus: %s\nRecorded: %s\nScope: %s\n", receipt.MutationID, receipt.Kind, receipt.Status, receipt.RecordedAt, strings.Join(receipt.Scope, ", ")) + } + return 0 + } + receipts, err := boatstack.ListMutationReceipts(*repo) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(receipts) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + if len(receipts) == 0 { + fmt.Println("No mutation receipts.") + } + for _, receipt := range receipts { + fmt.Printf("%s\t%s\t%s\t%s\n", receipt.MutationID, receipt.Kind, receipt.Status, receipt.RecordedAt) + } + } + return 0 +} + +func undoCommand(arguments []string) int { + flags := flag.NewFlagSet("undo", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository containing the managed-artifact mutation to reverse") + mutation := flags.String("mutation", "", "mutation id to undo (its receipt is the inverse command)") + jsonOutput := flags.Bool("json", false, "print the structured undo receipt") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if strings.TrimSpace(*mutation) == "" { + fmt.Fprintln(os.Stderr, "undo requires --mutation ") + return 2 + } + receipt, err := boatstack.UndoManagedMutation(*repo, *mutation) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(receipt) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Printf("Undo: %s\nKind: %s\nStatus: %s\nScope: %s\n", receipt.MutationID, receipt.Kind, receipt.Status, strings.Join(receipt.Scope, ", ")) + } + return 0 +} + func runPreflightCommand(arguments []string) int { flags := flag.NewFlagSet("run-preflight", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Git state should be verified before boatstack run") @@ -1074,7 +1151,7 @@ func workspaceSyncCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -1116,6 +1193,10 @@ func run() int { return recoveryStatusCommand(os.Args[2:]) case "repair-state": return repairStateCommand(os.Args[2:]) + case "mutation-status": + return mutationStatusCommand(os.Args[2:]) + case "undo": + return undoCommand(os.Args[2:]) case "run-preflight": return runPreflightCommand(os.Args[2:]) case "record-change": diff --git a/boatstack/export.go b/boatstack/export.go index e3bb24e..9b7d52a 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -294,7 +294,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "boatstack-run": "First run the read-only next-status --repo . --json and operation-status --repo . --json. If an operation is executing, wait and report it instead of launching it again; if reconciliation is required, verify its exact postcondition before retrying. If NOT_STARTED, respond Start a Boatstack feature and ask the user for the plan produced in the host conversation, then execute auto-plan with its path via --plan (Boatstack does not scan directories for plans) without Git preflight, pausing at its normal decision or approval boundary; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage. The delivery state's durable repair_attempt is the budget; stop after three complete automated repair-and-gate cycles even across new turns, host restarts, or async notifications. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence. Durable operation receipts store execution facts and retry budgets, never autonomous workflow intent. Report the feature, active slice, stages completed, completion or pause reason, durable repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If an earlier hand-authored draft was never registered and its plan cannot be verified, the guard denies every product mutation at INVALID_STATE with next operation repair-state; run repair-state to quarantine that unregistered malformed draft and return to auto-plan. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", "plan-gate": "Run check-plan read-only and present its plan fingerprint, baseline product diff fingerprint, changed paths, exact baseline diff when non-empty, and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the displayed baseline fingerprint, omitting it only when the baseline is clean, so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.", - "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", + "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. activate-plan promotes the compiled task graph, test matrix, evidence ledger, and the plan lock together through the transactional mutation boundary as one mutation, so all four land all-or-nothing with a reversible receipt and a failed or interrupted promote leaves the prior state unchanged rather than half-written. The boundary is closed under inversion: mutation-status lists the receipts and undo --mutation reverses a managed-artifact promotion (redo is undo of the undo receipt), with undo refusing to reverse an activation once a delivery gate would be stranded; this governs Boatstack-generated artifacts only, never source code. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", "repair": "First run recovery-status --repo . with the user's exact free-form requested change, its observed source stage, bounded evidence when available, and --json. This resolver covers both active and current-branch published deliveries. On repair_active, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts; classify the request and invoke record-change before any product edit. On draft_corrective_child, invoke record-change on the published parent, preserve its lock, receipts, slices, and publication evidence, and automatically prepare the suggested one-slice child plan with parent_delivery, exact correction, inherited intent, observed failure, returned existing_diff_sha256 and existing_changed_paths, verification requirements, and the resolved PR destination. Lead with The PR needs a corrective delivery. I prepared it for your approval. Then pause at the normal fingerprinted plan approval boundary; never reuse the parent's approval. An open PR reuses its verified head branch and is updated after fresh gates and publication confirmation. A merged or closed PR uses a fresh branch and PR; when a fingerprinted correction diff already exists, leave the original worktree untouched and transfer that exact reviewed diff into the fresh child only after approval. PUBLISHED_UNKNOWN may be drafted but its destination remains blocking at publication. Stop on BLOCKED and ask one targeted feature question using the returned blockers. If no managed target exists, continue ordinary conversation. Never discard pre-existing correction edits, edit runtime state directly, or bypass test, review, and ship gates. Never ask the user to repeat a denied push or PR mutation. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. If the active slice contains a systemic_boundary task, the evidence must prove the verification_oracle actively blocked or normalized a violation attempt (negative test). External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. For relevant PR visual scenarios, use repository-owned capture first, then the host browser against the existing development server, one supplied launch instruction, or an approved machine-only runtime. Do not edit repository dependencies or configuration for capture. Review the exact PNGs for secrets and private data and import their temporary manifest with record-pr-visual-evidence. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", "review-gate": "Read delivery-status and review the active slice's actual diff against authorized intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact. When workflow.independent_review_for_high_risk is true and changed paths match project.high_risk_paths, use a human peer or separate agent and pass --reviewer-identity plus --review-method human_peer or separate_agent. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject changed or untested diffs, disallowed gaps, missing reviewer provenance, and malformed required changelog evidence. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", diff --git a/boatstack/mutation.go b/boatstack/mutation.go new file mode 100644 index 0000000..18f7fe9 --- /dev/null +++ b/boatstack/mutation.go @@ -0,0 +1,590 @@ +package boatstack + +// Transactional mutation boundary. +// +// Coding agents propose changes; this deterministic boundary decides what +// becomes managed-artifact state. A MutationSet is validated in scratch, +// promoted through one atomic all-or-nothing write, and recorded as a receipt +// that both permits the next state and makes the change reversible without +// fresh model reasoning. +// +// The boundary is the bounded actuator required by nonblocking supervisory +// control: whenever the guard removes the raw ability to write, this API is the +// sanctioned way to reach the next valid state. A rejected precondition (stale +// base, outdated authority) never persists an identity, so a proposal recomputed +// against current state can still succeed — rejection never deadlocks. +// +// It reuses Boatstack's existing integrity primitives: SHA256* for content +// identity, atomicWriteMode (temp+fsync+rename) for promotion, gitCommonDir for +// the receipt store, rejectSymlinkComponents/resolveRepositoryRelativePath for +// path safety, and the operationID scheme + an O_EXCL lock mirroring +// withOperationLock for idempotency and mutual exclusion. + +import ( + "encoding/base64" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + mutationSchemaVersion = 1 + MutationProtocol = "operator.mutation.v1" +) + +// Sentinel errors let callers (and tests) distinguish deterministic refusals +// from genuine I/O faults. Every refusal below leaves accepted state unchanged. +var ( + ErrMutationInvalidCandidate = errors.New("mutation candidate failed validation before promotion") + ErrMutationStaleBase = errors.New("mutation rejected: a base artifact changed since it was read") + ErrMutationOutdatedAuthority = errors.New("mutation rejected: supervisor authority changed since it was authorized") + ErrMutationVerificationFailed = errors.New("mutation rolled back: post-write verification failed") + ErrMutationScope = errors.New("mutation operation falls outside its declared scope") + ErrMutationConflict = errors.New("mutation cannot be undone: the artifact diverged from its recorded post-image") +) + +// MutationOperation is a single file change within a transaction. Candidate holds +// the exact bytes to promote; the boundary asserts the on-disk image hashes to +// the same value after promotion. When Absent is true the operation deletes the +// path instead (Candidate is ignored), which makes the inverse of a create +// expressible as an ordinary MutationSet — the boundary is closed under +// inversion. +type MutationOperation struct { + Path string // repo-relative, slash-separated + Candidate []byte // exact bytes to promote (ignored when Absent) + Mode fs.FileMode // file mode; 0 means 0o644 + Absent bool // when true, the post-image is the file's absence (delete) +} + +// MutationAuthority binds a mutation to the supervisor state that authorized it. +// Expected is the token the proposal was authorized under; Observed is the token +// recomputed from current supervisor state at apply time. A mismatch means the +// authority has moved on and the mutation must be re-derived. +type MutationAuthority struct { + Expected string + Observed string +} + +// MutationSet is a proposed atomic change to managed artifacts. It is an +// in-process request; the PreCheck/PostCheck hooks are not persisted. +type MutationSet struct { + Protocol string + Kind string + Scope []string // allowed repo-relative paths; every operation path must be listed + Base map[string]string // repo-relative path -> expected pre-image sha256 ("" or absent = must not exist) + Authority MutationAuthority + Operations []MutationOperation + + // PreCheck validates the candidate bytes before anything is promoted. A + // non-zero error means the candidate is invalid and accepted state is left + // untouched (ErrMutationInvalidCandidate). + PreCheck func(candidate map[string][]byte) error + // PostCheck validates the promoted artifacts on disk. A non-zero error + // triggers automatic rollback to the exact pre-image + // (ErrMutationVerificationFailed). + PostCheck func() error +} + +// MutationFileChange records the before/after identity of one promoted path and +// carries the inverse image needed to reverse the change deterministically. +type MutationFileChange struct { + Path string `json:"path"` + ExistedBefore bool `json:"existed_before"` + BeforeSHA256 string `json:"before_sha256,omitempty"` + AfterSHA256 string `json:"after_sha256"` + Mode fs.FileMode `json:"mode"` + BeforeBase64 string `json:"before_base64,omitempty"` // inverse image; empty when the file was absent +} + +// MutationReceipt is the durable record of an applied mutation. It is the single +// source of truth for idempotent replay and for UndoMutation. +type MutationReceipt struct { + SchemaVersion int `json:"schema_version"` + MutationID string `json:"mutation_id"` + Protocol string `json:"protocol"` + Kind string `json:"kind"` + Status string `json:"status"` // APPLIED | ROLLED_BACK | REJECTED | UNDONE + Reason string `json:"reason,omitempty"` + Scope []string `json:"scope"` + Changes []MutationFileChange `json:"changes"` + Authority string `json:"authority_sha256,omitempty"` + RecordedAt string `json:"recorded_at"` +} + +func mutationDirectory(repo string) (string, error) { + common, err := gitCommonDir(repo) + if err != nil { + return "", err + } + return filepath.Join(common, "boatstack", "mutations", "v1"), nil +} + +func mutationPath(repo, id string) (string, error) { + segment, err := safeCacheSegment(id, "mutation id") + if err != nil { + return "", err + } + directory, err := mutationDirectory(repo) + if err != nil { + return "", err + } + path := filepath.Join(directory, segment+".json") + common, err := gitCommonDir(repo) + if err != nil { + return "", err + } + if err := rejectSymlinkComponents(common, path); err != nil { + return "", err + } + return path, nil +} + +// withMutationLock mirrors withOperationLock: an O_EXCL lockfile beside the +// receipt gives mutual exclusion across processes, with stale-lock reclamation. +func withMutationLock(repo, id string, apply func() error) error { + path, err := mutationPath(repo, id) + if err != nil { + return err + } + lock := strings.TrimSuffix(path, ".json") + ".lock" + common, err := gitCommonDir(repo) + if err != nil { + return err + } + if err := rejectSymlinkComponents(common, lock); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil { + return err + } + for attempt := 0; attempt < 100; attempt++ { + file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if openErr == nil { + _, _ = fmt.Fprintf(file, "%d %s\n", os.Getpid(), operationTimestamp()) + _ = file.Close() + defer os.Remove(lock) + return apply() + } + if !os.IsExist(openErr) { + return openErr + } + if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute { + _ = os.Remove(lock) + continue + } + time.Sleep(10 * time.Millisecond) + } + return fmt.Errorf("mutation %s is busy", id) +} + +func loadMutationReceipt(repo, id string) (MutationReceipt, bool, error) { + path, err := mutationPath(repo, id) + if err != nil { + return MutationReceipt{}, false, err + } + value, err := os.ReadFile(path) + if os.IsNotExist(err) { + return MutationReceipt{}, false, nil + } + if err != nil { + return MutationReceipt{}, false, err + } + var receipt MutationReceipt + if err := DecodeJSON("load mutation receipt", path, value, &receipt); err != nil { + return MutationReceipt{}, false, err + } + return receipt, true, nil +} + +func saveMutationReceipt(repo string, receipt MutationReceipt) error { + path, err := mutationPath(repo, receipt.MutationID) + if err != nil { + return err + } + value, err := MarshalJSON(receipt) + if err != nil { + return err + } + return atomicWriteMode(path, value, 0o600) +} + +func mutationMode(mode fs.FileMode) fs.FileMode { + if mode == 0 { + return 0o644 + } + return mode +} + +// currentImage returns the on-disk sha256 of a resolved path, or ("", false) +// when the file is absent. A directory or unreadable file is a hard error. +func currentImage(native string) (string, bool, error) { + value, err := os.ReadFile(native) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return SHA256Bytes(value), true, nil +} + +// mutationIdentity derives the stable id for a MutationSet. It covers what is +// being written (kind + each path and candidate hash) and the declared base, so +// an identical proposal replays and a different proposal is a distinct mutation. +// It deliberately excludes the transient Authority.Observed value so a rejected +// authority check does not fork the identity of the corrected retry. +func mutationIdentity(m MutationSet, ops []resolvedOperation) string { + parts := make([]string, 0, len(ops)) + for _, op := range ops { + parts = append(parts, op.rel+"\x1f"+op.candidateHash+"\x1f"+m.Base[op.rel]) + } + sort.Strings(parts) + fingerprint := SHA256Bytes([]byte(strings.Join(parts, "\x1e"))) + target := SHA256Bytes([]byte(strings.Join(sortedScope(m.Scope), "\x1e"))) + return operationID("mutation\x00"+strings.TrimSpace(m.Kind), target, fingerprint) +} + +func sortedScope(scope []string) []string { + out := append([]string(nil), scope...) + sort.Strings(out) + return out +} + +type resolvedOperation struct { + rel string // repo-relative, slash form + native string // absolute filesystem path + candidate []byte + candidateHash string // "" denotes an absent (deleted) post-image + mode fs.FileMode + absent bool +} + +func (m MutationSet) resolve(repo string) ([]resolvedOperation, error) { + if strings.TrimSpace(m.Protocol) != MutationProtocol { + return nil, fmt.Errorf("mutation protocol must be %s", MutationProtocol) + } + if strings.TrimSpace(m.Kind) == "" { + return nil, fmt.Errorf("mutation requires a kind") + } + if len(m.Operations) == 0 { + return nil, fmt.Errorf("mutation requires at least one operation") + } + scope := map[string]bool{} + for _, path := range m.Scope { + scope[filepath.ToSlash(strings.TrimSpace(path))] = true + } + resolved := make([]resolvedOperation, 0, len(m.Operations)) + seen := map[string]bool{} + for _, op := range m.Operations { + rel := filepath.ToSlash(strings.TrimSpace(op.Path)) + if rel == "" { + return nil, fmt.Errorf("mutation operation path is empty") + } + if !scope[rel] { + return nil, fmt.Errorf("%w: %s", ErrMutationScope, rel) + } + if seen[rel] { + return nil, fmt.Errorf("mutation names %s more than once", rel) + } + seen[rel] = true + native, err := resolveRepositoryRelativePath(repo, rel) + if err != nil { + return nil, err + } + if err := rejectSymlinkComponents(repo, native); err != nil { + return nil, err + } + if op.Absent { + // An absent post-image has no candidate bytes; its hash sentinel is "" + // so identity and receipts distinguish it from any real content. + resolved = append(resolved, resolvedOperation{ + rel: rel, native: native, mode: mutationMode(op.Mode), absent: true, + }) + continue + } + resolved = append(resolved, resolvedOperation{ + rel: rel, native: native, candidate: op.Candidate, + candidateHash: SHA256Bytes(op.Candidate), mode: mutationMode(op.Mode), + }) + } + return resolved, nil +} + +// ApplyMutation validates and promotes a MutationSet as one atomic transaction. +// On success it returns an APPLIED receipt; on a deterministic refusal it returns +// a receipt whose Status explains the refusal along with the matching sentinel +// error, having left every artifact untouched. +func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return MutationReceipt{}, err + } + ops, err := m.resolve(repo) + if err != nil { + return MutationReceipt{}, err + } + id := mutationIdentity(m, ops) + authorityHash := SHA256Bytes([]byte(m.Authority.Expected)) + + var result MutationReceipt + err = withMutationLock(repo, id, func() error { + // Idempotency: an identical proposal whose post-image is already on disk + // replays the recorded receipt without writing again. + if existing, ok, loadErr := loadMutationReceipt(repo, id); loadErr != nil { + return loadErr + } else if ok && existing.Status == "APPLIED" && receiptStillApplied(repo, existing) { + result = existing + return nil + } + + // Preconditions run before any write and before any durable identity, so a + // refusal cannot deadlock a corrected retry. + if strings.TrimSpace(m.Authority.Expected) != strings.TrimSpace(m.Authority.Observed) { + result = MutationReceipt{ + SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, + Kind: m.Kind, Status: "REJECTED", Reason: "outdated supervisor authority", + Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + } + return ErrMutationOutdatedAuthority + } + for _, op := range ops { + expected := strings.TrimSpace(m.Base[op.rel]) + current, exists, imgErr := currentImage(op.native) + if imgErr != nil { + return imgErr + } + observed := "" + if exists { + observed = current + } + if observed != expected { + result = MutationReceipt{ + SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, + Kind: m.Kind, Status: "REJECTED", Reason: "stale base artifact: " + op.rel, + Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + } + return ErrMutationStaleBase + } + } + + // Validate the candidate in scratch (never on the accepted tree). + if m.PreCheck != nil { + candidate := map[string][]byte{} + for _, op := range ops { + candidate[op.rel] = op.candidate + } + if checkErr := m.PreCheck(candidate); checkErr != nil { + result = MutationReceipt{ + SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, + Kind: m.Kind, Status: "REJECTED", Reason: "invalid candidate: " + checkErr.Error(), + Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + } + return fmt.Errorf("%w: %v", ErrMutationInvalidCandidate, checkErr) + } + } + + // Capture the inverse image, then promote every file atomically. + changes := make([]MutationFileChange, 0, len(ops)) + for _, op := range ops { + before, existed, readErr := readInverse(op.native) + if readErr != nil { + return readErr + } + change := MutationFileChange{ + Path: op.rel, ExistedBefore: existed, AfterSHA256: op.candidateHash, Mode: op.mode, + } + if existed { + change.BeforeSHA256 = SHA256Bytes(before) + change.BeforeBase64 = base64.StdEncoding.EncodeToString(before) + } + changes = append(changes, change) + } + + promoted := make([]promotedChange, 0, len(ops)) + promoteErr := func() error { + for i, op := range ops { + if op.absent { + if rmErr := os.Remove(op.native); rmErr != nil && !os.IsNotExist(rmErr) { + return rmErr + } + promoted = append(promoted, promotedChange{change: changes[i], native: op.native}) + if _, exists, imgErr := currentImage(op.native); imgErr != nil { + return imgErr + } else if exists { + return fmt.Errorf("deleted artifact %s is still present after promotion", op.rel) + } + continue + } + if writeErr := atomicWriteMode(op.native, op.candidate, op.mode); writeErr != nil { + return writeErr + } + promoted = append(promoted, promotedChange{change: changes[i], native: op.native}) + got, err := SHA256File(op.native) + if err != nil { + return err + } + if got != op.candidateHash { + return fmt.Errorf("promoted bytes for %s do not match the candidate", op.rel) + } + } + return nil + }() + if promoteErr != nil { + rollbackMutation(promoted) + return promoteErr + } + + // Post-write verification against the real tree; failure rolls back. + if m.PostCheck != nil { + if checkErr := m.PostCheck(); checkErr != nil { + rollbackMutation(promoted) + result = MutationReceipt{ + SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, + Kind: m.Kind, Status: "ROLLED_BACK", Reason: "post-write verification failed: " + checkErr.Error(), + Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(), + } + return fmt.Errorf("%w: %v", ErrMutationVerificationFailed, checkErr) + } + } + + result = MutationReceipt{ + SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol, + Kind: m.Kind, Status: "APPLIED", Scope: sortedScope(m.Scope), + Changes: changes, Authority: authorityHash, RecordedAt: operationTimestamp(), + } + return saveMutationReceipt(repo, result) + }) + return result, err +} + +func readInverse(native string) ([]byte, bool, error) { + value, err := os.ReadFile(native) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return value, true, nil +} + +// promotedChange pairs a recorded change with its resolved filesystem path so +// rollback can restore the exact pre-image without re-resolving. +type promotedChange struct { + change MutationFileChange + native string +} + +// rollbackMutation restores each already-promoted file to its exact pre-image, +// removing files that did not exist before. Best-effort: a failed restore leaves +// the remaining files as-is, and the caller reports the original error. +func rollbackMutation(promoted []promotedChange) { + for i := len(promoted) - 1; i >= 0; i-- { + change := promoted[i].change + native := promoted[i].native + if native == "" { + continue + } + if !change.ExistedBefore { + _ = os.Remove(native) + continue + } + before, decodeErr := base64.StdEncoding.DecodeString(change.BeforeBase64) + if decodeErr != nil { + continue + } + _ = atomicWriteMode(native, before, mutationMode(change.Mode)) + } +} + +// receiptStillApplied reports whether every recorded post-image is still the +// on-disk truth, which is the precondition for treating a repeat call as a +// no-op replay rather than a fresh mutation. +func receiptStillApplied(repo string, receipt MutationReceipt) bool { + for _, change := range receipt.Changes { + native, err := resolveRepositoryRelativePath(repo, change.Path) + if err != nil { + return false + } + current, exists, imgErr := currentImage(native) + if imgErr != nil { + return false + } + if change.AfterSHA256 == "" { + // The recorded post-image is the file's absence (a delete). + if exists { + return false + } + continue + } + if !exists || current != change.AfterSHA256 { + return false + } + } + return true +} + +// UndoMutation deterministically reverses an applied mutation by replaying its +// inverse through the same boundary. The inverse is a first-class MutationSet: +// each recorded before-image becomes the candidate (or an absent operation when +// the file did not exist before), and the recorded post-image becomes the base +// precondition. Because the inverse goes through ApplyMutation it is atomic and +// verified, and it produces its own reversible receipt — so redo is simply +// undoing that returned receipt. The base precondition is the divergence guard: +// if any artifact no longer matches its recorded post-image, the boundary +// refuses with a stale base, which UndoMutation surfaces as ErrMutationConflict +// rather than clobbering newer work. Applying an already-reversed mutation +// replays idempotently through ApplyMutation's identity short-circuit; the +// original receipt is left immutable as history. +func UndoMutation(repoPath, mutationID string) (MutationReceipt, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return MutationReceipt{}, err + } + id := strings.TrimSpace(mutationID) + receipt, ok, err := loadMutationReceipt(repo, id) + if err != nil { + return MutationReceipt{}, err + } + if !ok { + return MutationReceipt{}, fmt.Errorf("no mutation receipt for %s", id) + } + if receipt.Status != "APPLIED" { + return MutationReceipt{}, fmt.Errorf("mutation %s is not in an undoable state (%s)", id, receipt.Status) + } + + scope := make([]string, 0, len(receipt.Changes)) + base := map[string]string{} + ops := make([]MutationOperation, 0, len(receipt.Changes)) + for _, change := range receipt.Changes { + scope = append(scope, change.Path) + base[change.Path] = change.AfterSHA256 // "" means the post-image is absent + if !change.ExistedBefore { + ops = append(ops, MutationOperation{Path: change.Path, Absent: true}) + continue + } + before, decodeErr := base64.StdEncoding.DecodeString(change.BeforeBase64) + if decodeErr != nil { + return MutationReceipt{}, decodeErr + } + ops = append(ops, MutationOperation{Path: change.Path, Candidate: before, Mode: change.Mode}) + } + + inverse := MutationSet{ + Protocol: MutationProtocol, + Kind: "undo:" + receipt.Kind, + Scope: scope, + Base: base, + Operations: ops, + } + undone, applyErr := ApplyMutation(repo, inverse) + if errors.Is(applyErr, ErrMutationStaleBase) { + return undone, fmt.Errorf("%w: %s diverged from its recorded post-image", ErrMutationConflict, receipt.MutationID) + } + return undone, applyErr +} diff --git a/boatstack/mutation_test.go b/boatstack/mutation_test.go new file mode 100644 index 0000000..1a761d9 --- /dev/null +++ b/boatstack/mutation_test.go @@ -0,0 +1,440 @@ +package boatstack + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +func writeRepoFile(t *testing.T, repo, rel, content string) string { + t.Helper() + path := filepath.Join(repo, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return SHA256Bytes([]byte(content)) +} + +func readRepoFile(t *testing.T, repo, rel string) (string, bool) { + t.Helper() + value, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(rel))) + if os.IsNotExist(err) { + return "", false + } + if err != nil { + t.Fatal(err) + } + return string(value), true +} + +func singleFileMutation(rel string, candidate string, base map[string]string) MutationSet { + return MutationSet{ + Protocol: MutationProtocol, + Kind: "test-artifact", + Scope: []string{rel}, + Base: base, + Authority: MutationAuthority{Expected: "authority-1", Observed: "authority-1"}, + Operations: []MutationOperation{{Path: rel, Candidate: []byte(candidate)}}, + } +} + +// TestMutationBoundaryConformance is the acceptance suite for the transactional +// mutation boundary. Each subtest is one of the six required properties stated +// as a black-box behavior of the generic primitive, independent of any artifact. +func TestMutationBoundaryConformance(t *testing.T) { + t.Run("invalid candidate leaves accepted state unchanged", func(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "original") + m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base}) + m.PreCheck = func(candidate map[string][]byte) error { return fmt.Errorf("candidate is not well-formed") } + + receipt, err := ApplyMutation(repo, m) + if !errors.Is(err, ErrMutationInvalidCandidate) { + t.Fatalf("expected invalid-candidate refusal, got %v", err) + } + if receipt.Status != "REJECTED" { + t.Fatalf("expected REJECTED receipt, got %q", receipt.Status) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "original" { + t.Fatalf("accepted state changed after invalid candidate: %q", got) + } + }) + + t.Run("valid candidate promotes exact bytes", func(t *testing.T) { + repo := nextTestRepo(t) + m := singleFileMutation("src/auth.go", "exact-new-bytes", nil) + + receipt, err := ApplyMutation(repo, m) + if err != nil { + t.Fatal(err) + } + if receipt.Status != "APPLIED" { + t.Fatalf("expected APPLIED, got %q", receipt.Status) + } + got, ok := readRepoFile(t, repo, "src/auth.go") + if !ok || got != "exact-new-bytes" { + t.Fatalf("promoted bytes are not exact: %q (present=%v)", got, ok) + } + if receipt.Changes[0].AfterSHA256 != SHA256Bytes([]byte("exact-new-bytes")) { + t.Fatal("receipt after-hash does not match the promoted bytes") + } + }) + + t.Run("post-write validation failure rolls back", func(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "original") + m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base}) + m.PostCheck = func() error { + // The candidate is on disk at this point; prove rollback undoes it. + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "rewritten" { + t.Fatalf("post-check ran before promotion: %q", got) + } + return fmt.Errorf("verification failed") + } + + receipt, err := ApplyMutation(repo, m) + if !errors.Is(err, ErrMutationVerificationFailed) { + t.Fatalf("expected verification-failed rollback, got %v", err) + } + if receipt.Status != "ROLLED_BACK" { + t.Fatalf("expected ROLLED_BACK, got %q", receipt.Status) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "original" { + t.Fatalf("rollback did not restore exact bytes: %q", got) + } + }) + + t.Run("duplicate application is idempotent", func(t *testing.T) { + repo := nextTestRepo(t) + m := singleFileMutation("src/auth.go", "once", nil) + + first, err := ApplyMutation(repo, m) + if err != nil { + t.Fatal(err) + } + second, err := ApplyMutation(repo, m) + if err != nil { + t.Fatal(err) + } + if first.MutationID != second.MutationID { + t.Fatalf("idempotent replay changed identity: %s vs %s", first.MutationID, second.MutationID) + } + if second.Status != "APPLIED" { + t.Fatalf("replay was not reported APPLIED: %q", second.Status) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "once" { + t.Fatalf("idempotent replay corrupted the artifact: %q", got) + } + }) + + t.Run("changed base rejects the stale mutation", func(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "original") + // The base drifts after it was read into the proposal. + writeRepoFile(t, repo, "src/auth.go", "changed-underneath") + m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base}) + + receipt, err := ApplyMutation(repo, m) + if !errors.Is(err, ErrMutationStaleBase) { + t.Fatalf("expected stale-base refusal, got %v", err) + } + if receipt.Status != "REJECTED" { + t.Fatalf("expected REJECTED, got %q", receipt.Status) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "changed-underneath" { + t.Fatalf("stale mutation mutated state: %q", got) + } + }) + + t.Run("changed supervisor authority rejects the outdated mutation", func(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "original") + m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base}) + m.Authority = MutationAuthority{Expected: "authorized-under-S1", Observed: "supervisor-now-S2"} + + receipt, err := ApplyMutation(repo, m) + if !errors.Is(err, ErrMutationOutdatedAuthority) { + t.Fatalf("expected outdated-authority refusal, got %v", err) + } + if receipt.Status != "REJECTED" { + t.Fatalf("expected REJECTED, got %q", receipt.Status) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "original" { + t.Fatalf("outdated-authority mutation mutated state: %q", got) + } + }) +} + +// TestMutationBoundaryPreservesALegalTrajectory encodes the nonblocking +// supervisory-control invariant: a deterministic refusal never deadlocks. A +// mutation recomputed against the current base and authority still reaches the +// next valid state. +func TestMutationBoundaryPreservesALegalTrajectory(t *testing.T) { + t.Run("after stale-base rejection", func(t *testing.T) { + repo := nextTestRepo(t) + stale := writeRepoFile(t, repo, "src/auth.go", "v1") + current := writeRepoFile(t, repo, "src/auth.go", "v2") + + if _, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "v3", map[string]string{"src/auth.go": stale})); !errors.Is(err, ErrMutationStaleBase) { + t.Fatalf("expected stale-base refusal, got %v", err) + } + // Recompute the proposal against current state — a legal trajectory remains. + receipt, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "v3", map[string]string{"src/auth.go": current})) + if err != nil { + t.Fatalf("recomputed mutation was blocked: %v", err) + } + if receipt.Status != "APPLIED" { + t.Fatalf("recomputed mutation did not apply: %q", receipt.Status) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "v3" { + t.Fatalf("recomputed mutation did not reach the next state: %q", got) + } + }) + + t.Run("after outdated-authority rejection", func(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "v1") + outdated := singleFileMutation("src/auth.go", "v2", map[string]string{"src/auth.go": base}) + outdated.Authority = MutationAuthority{Expected: "S1", Observed: "S2"} + if _, err := ApplyMutation(repo, outdated); !errors.Is(err, ErrMutationOutdatedAuthority) { + t.Fatalf("expected outdated-authority refusal, got %v", err) + } + // Re-authorize against the current supervisor token — same identity, now legal. + current := singleFileMutation("src/auth.go", "v2", map[string]string{"src/auth.go": base}) + current.Authority = MutationAuthority{Expected: "S2", Observed: "S2"} + receipt, err := ApplyMutation(repo, current) + if err != nil { + t.Fatalf("re-authorized mutation was blocked: %v", err) + } + if receipt.Status != "APPLIED" { + t.Fatalf("re-authorized mutation did not apply: %q", receipt.Status) + } + }) +} + +func TestApplyMutationRejectsOutOfScopePath(t *testing.T) { + repo := nextTestRepo(t) + m := MutationSet{ + Protocol: MutationProtocol, Kind: "test-artifact", + Scope: []string{"src/allowed.go"}, + Authority: MutationAuthority{Expected: "a", Observed: "a"}, + Operations: []MutationOperation{{Path: "src/secret.go", Candidate: []byte("x")}}, + } + if _, err := ApplyMutation(repo, m); !errors.Is(err, ErrMutationScope) { + t.Fatalf("expected scope refusal, got %v", err) + } + if _, ok := readRepoFile(t, repo, "src/secret.go"); ok { + t.Fatal("out-of-scope path was written") + } +} + +func TestApplyMutationRejectsPathEscape(t *testing.T) { + repo := nextTestRepo(t) + m := singleFileMutation("../escape.go", "x", nil) + if _, err := ApplyMutation(repo, m); err == nil { + t.Fatal("path escaping the repository was accepted") + } +} + +func TestApplyMutationRejectsSymlinkComponent(t *testing.T) { + repo := nextTestRepo(t) + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(repo, "linked")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + m := singleFileMutation("linked/auth.go", "x", nil) + if _, err := ApplyMutation(repo, m); err == nil { + t.Fatal("mutation through a symlinked component was accepted") + } + if _, err := os.Stat(filepath.Join(outside, "auth.go")); err == nil { + t.Fatal("mutation escaped through a symlink") + } +} + +func TestApplyMutationMultiFileRollbackIsAllOrNothing(t *testing.T) { + repo := nextTestRepo(t) + baseA := writeRepoFile(t, repo, "a.txt", "A0") + baseB := writeRepoFile(t, repo, "b.txt", "B0") + // c.txt is absent before the mutation. + m := MutationSet{ + Protocol: MutationProtocol, Kind: "test-artifact", + Scope: []string{"a.txt", "b.txt", "c.txt"}, + Base: map[string]string{"a.txt": baseA, "b.txt": baseB}, + Authority: MutationAuthority{Expected: "a", Observed: "a"}, + Operations: []MutationOperation{ + {Path: "a.txt", Candidate: []byte("A1")}, + {Path: "b.txt", Candidate: []byte("B1")}, + {Path: "c.txt", Candidate: []byte("C1")}, + }, + PostCheck: func() error { return fmt.Errorf("reject the whole batch") }, + } + if _, err := ApplyMutation(repo, m); !errors.Is(err, ErrMutationVerificationFailed) { + t.Fatalf("expected batch rollback, got %v", err) + } + if got, _ := readRepoFile(t, repo, "a.txt"); got != "A0" { + t.Fatalf("a.txt not restored: %q", got) + } + if got, _ := readRepoFile(t, repo, "b.txt"); got != "B0" { + t.Fatalf("b.txt not restored: %q", got) + } + if _, ok := readRepoFile(t, repo, "c.txt"); ok { + t.Fatal("c.txt was created but existed nowhere before the rolled-back batch") + } +} + +func TestApplyMutationConcurrentDuplicatesApplyOnce(t *testing.T) { + repo := nextTestRepo(t) + m := singleFileMutation("src/auth.go", "converged", nil) + + const workers = 8 + var wg sync.WaitGroup + results := make([]MutationReceipt, workers) + errs := make([]error, workers) + wg.Add(workers) + for i := 0; i < workers; i++ { + go func(index int) { + defer wg.Done() + results[index], errs[index] = ApplyMutation(repo, m) + }(i) + } + wg.Wait() + + id := "" + for i := 0; i < workers; i++ { + if errs[i] != nil { + t.Fatalf("worker %d failed: %v", i, errs[i]) + } + if results[i].Status != "APPLIED" { + t.Fatalf("worker %d saw %q", i, results[i].Status) + } + if id == "" { + id = results[i].MutationID + } else if results[i].MutationID != id { + t.Fatalf("concurrent applies diverged in identity: %s vs %s", id, results[i].MutationID) + } + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "converged" { + t.Fatalf("concurrent applies corrupted the artifact: %q", got) + } +} + +func TestUndoMutationRestoresExactBytes(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "before") + receipt, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base})) + if err != nil { + t.Fatal(err) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "after" { + t.Fatalf("setup did not apply: %q", got) + } + undone, err := UndoMutation(repo, receipt.MutationID) + if err != nil { + t.Fatal(err) + } + // Undo is itself a mutation: it lands as an APPLIED inverse receipt whose own + // inverse is the original post-image (that is what makes redo possible). + if undone.Status != "APPLIED" { + t.Fatalf("expected the inverse mutation to be APPLIED, got %q", undone.Status) + } + if undone.MutationID == receipt.MutationID { + t.Fatal("undo did not produce a distinct inverse receipt") + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "before" { + t.Fatalf("undo did not restore exact bytes: %q", got) + } +} + +func TestUndoMutationRemovesFileThatWasAbsentBefore(t *testing.T) { + repo := nextTestRepo(t) + receipt, err := ApplyMutation(repo, singleFileMutation("src/new.go", "created", nil)) + if err != nil { + t.Fatal(err) + } + if _, err := UndoMutation(repo, receipt.MutationID); err != nil { + t.Fatal(err) + } + if _, ok := readRepoFile(t, repo, "src/new.go"); ok { + t.Fatal("undo did not remove a file that was absent before the mutation") + } +} + +// TestUndoMutationIsReversibleAsRedo proves the boundary is closed under +// inversion: undoing an undo receipt restores the original after-image. Because +// undo lands as an ordinary APPLIED mutation, redo needs no separate verb — it is +// just undo of the undo receipt. +func TestUndoMutationIsReversibleAsRedo(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "before") + applied, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base})) + if err != nil { + t.Fatal(err) + } + undone, err := UndoMutation(repo, applied.MutationID) + if err != nil { + t.Fatal(err) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "before" { + t.Fatalf("undo did not restore the before-image: %q", got) + } + // Redo = undo of the undo receipt → the original after-image returns. + redone, err := UndoMutation(repo, undone.MutationID) + if err != nil { + t.Fatal(err) + } + if redone.MutationID == undone.MutationID || redone.Status != "APPLIED" { + t.Fatalf("redo did not land as a distinct APPLIED inverse: %+v", redone) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "after" { + t.Fatalf("redo did not restore the after-image: %q", got) + } +} + +// TestUndoMutationReplayIsIdempotent proves that re-issuing the same undo is a +// no-op: the inverse MutationSet is content-addressed, so the second call replays +// the first undo receipt instead of forking a new one or clobbering later state. +func TestUndoMutationReplayIsIdempotent(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "before") + applied, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base})) + if err != nil { + t.Fatal(err) + } + first, err := UndoMutation(repo, applied.MutationID) + if err != nil { + t.Fatal(err) + } + second, err := UndoMutation(repo, applied.MutationID) + if err != nil { + t.Fatal(err) + } + if first.MutationID != second.MutationID { + t.Fatalf("undo replay forked a new receipt: %s vs %s", first.MutationID, second.MutationID) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "before" { + t.Fatalf("undo replay disturbed restored state: %q", got) + } +} + +func TestUndoMutationRejectsDivergedState(t *testing.T) { + repo := nextTestRepo(t) + base := writeRepoFile(t, repo, "src/auth.go", "before") + receipt, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base})) + if err != nil { + t.Fatal(err) + } + // A later change touched the same file: undo must refuse rather than clobber. + writeRepoFile(t, repo, "src/auth.go", "later-work") + if _, err := UndoMutation(repo, receipt.MutationID); !errors.Is(err, ErrMutationConflict) { + t.Fatalf("expected conflict refusal, got %v", err) + } + if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "later-work" { + t.Fatalf("undo clobbered diverged state: %q", got) + } +} diff --git a/boatstack/mutation_undo.go b/boatstack/mutation_undo.go new file mode 100644 index 0000000..4916888 --- /dev/null +++ b/boatstack/mutation_undo.go @@ -0,0 +1,158 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// featurePathPattern extracts the feature slug from a managed-artifact path such +// as ".product-loop/features//plan.lock.json". The mutation boundary is +// domain-agnostic, but the undo verb needs the owning feature to decide whether a +// reversal would strand delivery state. +var featurePathPattern = regexp.MustCompile(`(?:^|/)\.product-loop/features/([a-z0-9]+(?:-[a-z0-9]+)*)/`) + +// ListMutationReceipts returns every durable mutation receipt in the repository, +// most recent first. It backs the read-only `mutation-status` verb so an agent can +// discover the receipt to reverse (and, for redo, the undo receipt to reverse in +// turn — the boundary is closed under inversion). +func ListMutationReceipts(repoPath string) ([]MutationReceipt, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return nil, err + } + directory, err := mutationDirectory(repo) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(directory) + if os.IsNotExist(err) { + return []MutationReceipt{}, nil + } + if err != nil { + return nil, err + } + receipts := make([]MutationReceipt, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".json") { + continue + } + id := strings.TrimSuffix(name, ".json") + receipt, ok, loadErr := loadMutationReceipt(repo, id) + if loadErr != nil { + return nil, loadErr + } + if ok { + receipts = append(receipts, receipt) + } + } + sort.SliceStable(receipts, func(i, j int) bool { + return receipts[i].RecordedAt > receipts[j].RecordedAt + }) + return receipts, nil +} + +// GetMutationReceipt loads a single receipt by id for `mutation-status --mutation`. +func GetMutationReceipt(repoPath, mutationID string) (MutationReceipt, bool, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return MutationReceipt{}, false, err + } + return loadMutationReceipt(repo, strings.TrimSpace(mutationID)) +} + +// UndoManagedMutation is the agent-facing, state-aware undo verb. It reverses a +// Boatstack-generated managed artifact by re-applying the receipt's inverse through +// the same transactional boundary (UndoMutation), but first refuses any reversal +// that would strand delivery state: undoing a plan activation (or its compiled +// artifacts) once a delivery gate receipt exists would remove the plan lock the +// delivery state depends on, deadlocking the workflow at INVALID_STATE. The +// primitive stays domain-agnostic; this thin wrapper carries the domain guard, +// mirroring how RepairState self-guards. +func UndoManagedMutation(repoPath, mutationID string) (MutationReceipt, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return MutationReceipt{}, err + } + id := strings.TrimSpace(mutationID) + receipt, ok, err := loadMutationReceipt(repo, id) + if err != nil { + return MutationReceipt{}, err + } + if !ok { + return MutationReceipt{}, fmt.Errorf("no mutation receipt for %s", id) + } + if governsPlanActivation(receipt.Kind) { + feature := featureForMutation(receipt) + if feature != "" { + progressed, gate, guardErr := deliveryProgressed(repo, feature) + if guardErr != nil { + return MutationReceipt{}, guardErr + } + if progressed { + return MutationReceipt{}, fmt.Errorf("refusing to undo %s (%s): delivery has progressed for feature %s (%s); undo would strand delivery state without its plan lock", id, receipt.Kind, feature, gate) + } + } + } + return UndoMutation(repo, id) +} + +// governsPlanActivation reports whether a mutation kind promotes the managed plan +// artifacts whose removal a live delivery state depends on. +func governsPlanActivation(kind string) bool { + kind = strings.TrimSpace(kind) + return kind == "plan-activation" || kind == "compiled-plan" +} + +// featureForMutation extracts the owning feature slug from a receipt's changed +// paths, or "" when the receipt does not touch a feature-scoped artifact. +func featureForMutation(receipt MutationReceipt) string { + for _, change := range receipt.Changes { + if match := featurePathPattern.FindStringSubmatch(filepath.ToSlash(change.Path)); match != nil { + return match[1] + } + } + for _, path := range receipt.Scope { + if match := featurePathPattern.FindStringSubmatch(filepath.ToSlash(path)); match != nil { + return match[1] + } + } + return "" +} + +// deliveryProgressed reports whether any delivery gate receipt has been recorded +// for the feature, which is the point past which undoing the plan activation would +// strand delivery state. It returns a short human-readable reason on true. +func deliveryProgressed(repo, feature string) (bool, string, error) { + statePath, err := deliveryStatePath(repo, feature) + if err != nil { + return false, "", err + } + receiptsDir := filepath.Join(filepath.Dir(statePath), "receipts") + sliceDirs, err := os.ReadDir(receiptsDir) + if os.IsNotExist(err) { + return false, "", nil + } + if err != nil { + return false, "", err + } + for _, sliceDir := range sliceDirs { + if !sliceDir.IsDir() { + continue + } + gates, gatesErr := os.ReadDir(filepath.Join(receiptsDir, sliceDir.Name())) + if gatesErr != nil { + return false, "", gatesErr + } + for _, gate := range gates { + if strings.HasSuffix(gate.Name(), ".json") { + return true, fmt.Sprintf("gate receipt %s/%s", sliceDir.Name(), strings.TrimSuffix(gate.Name(), ".json")), nil + } + } + } + return false, "", nil +} diff --git a/boatstack/mutation_undo_test.go b/boatstack/mutation_undo_test.go new file mode 100644 index 0000000..229f12d --- /dev/null +++ b/boatstack/mutation_undo_test.go @@ -0,0 +1,111 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// activationReceiptID returns the id of the APPLIED plan-activation receipt in a +// repository, failing the test if none exists. +func activationReceiptID(t *testing.T, repo string) string { + t.Helper() + receipts, err := ListMutationReceipts(repo) + if err != nil { + t.Fatal(err) + } + for _, receipt := range receipts { + if receipt.Kind == "plan-activation" && receipt.Status == "APPLIED" { + return receipt.MutationID + } + } + t.Fatalf("no APPLIED plan-activation receipt among %d receipts", len(receipts)) + return "" +} + +// TestUndoManagedMutationReversesFreshActivation proves the bounded undo verb +// reverses a plan activation right after it happens (before any delivery gate has +// been recorded): all four managed artifacts are removed by re-applying the +// receipt's inverse through the same boundary. +func TestUndoManagedMutationReversesFreshActivation(t *testing.T) { + root, _, compiled, lock, _ := activatePolicyPlan(t) + id := activationReceiptID(t, root) + if _, err := UndoManagedMutation(root, id); err != nil { + t.Fatalf("undo of a fresh activation should be allowed: %v", err) + } + for _, path := range []string{ + filepath.Join(compiled, "tasks.json"), + filepath.Join(compiled, "test-matrix.json"), + filepath.Join(compiled, "evidence.md"), + lock, + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("undo left managed artifact behind: %s", path) + } + } +} + +// TestUndoManagedMutationRefusedAfterDeliveryGate proves the domain guard: once a +// delivery gate receipt exists, undoing the activation would strand delivery state +// without its plan lock, so the verb refuses (the primitive stays domain-agnostic; +// the guard lives in the verb layer, mirroring RepairState). +func TestUndoManagedMutationRefusedAfterDeliveryGate(t *testing.T) { + root, _, compiled, lock, feature := activatePolicyPlan(t) + id := activationReceiptID(t, root) + + statePath, err := deliveryStatePath(root, feature) + if err != nil { + t.Fatal(err) + } + gateDir := filepath.Join(filepath.Dir(statePath), "receipts", "slice-one") + if err := os.MkdirAll(gateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gateDir, "test.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + _, err = UndoManagedMutation(root, id) + if err == nil || !strings.Contains(err.Error(), "delivery has progressed") { + t.Fatalf("expected undo to be refused after a delivery gate, got %v", err) + } + // The refusal must leave every artifact in place — a rejected undo is a no-op. + for _, path := range []string{filepath.Join(compiled, "tasks.json"), lock} { + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("refused undo removed managed artifact %s: %v", path, statErr) + } + } +} + +// TestControlledPhaseTransitionAllowsBoundedRecoveryVerbs proves the guard +// allowlists the two agent-facing verbs at any stage while still rejecting shell +// metacharacters and non-helper executables. +func TestControlledPhaseTransitionAllowsBoundedRecoveryVerbs(t *testing.T) { + allowed := []struct { + command string + stage string + }{ + {"boatstack-helper mutation-status --repo .", "DELIVERY"}, + {"boatstack-helper mutation-status --repo . --mutation abc --json", "INVALID_STATE"}, + {"boatstack-helper undo --repo . --mutation abc", "INVALID_STATE"}, + {"boatstack-helper undo --repo . --mutation abc --json", "DELIVERY"}, + } + for _, test := range allowed { + if !controlledPhaseTransition(test.command, test.stage) { + t.Fatalf("expected %q to be allowed at stage %s", test.command, test.stage) + } + } + + rejected := []string{ + "boatstack-helper undo --repo . --mutation abc; rm -rf .", + "boatstack-helper undo --repo . --mutation abc && echo done", + "git undo --mutation abc", + "rm -rf .product-loop/plan.lock.json", + } + for _, command := range rejected { + if controlledPhaseTransition(command, "INVALID_STATE") { + t.Fatalf("expected %q to be rejected", command) + } + } +} diff --git a/boatstack/plan.go b/boatstack/plan.go index 0554b25..4af347b 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -694,46 +694,162 @@ func CompilePlanFiles(planPath, outDir string) error { return compilePlanFiles(planPath, outDir, "HUMAN_APPROVED") } +// canonicalizeExistingAncestor resolves symlinks on the deepest existing prefix +// of an absolute path and rejoins any not-yet-created remainder. It lets a target +// path that lives under a symlinked volume (e.g. macOS /var -> /private/var) be +// compared against a symlink-resolved repository root even before its parent +// directories exist. +func canonicalizeExistingAncestor(path string) string { + remainder := "" + current := filepath.Clean(path) + for { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + if remainder == "" { + return resolved + } + return filepath.Join(resolved, remainder) + } + parent := filepath.Dir(current) + if parent == current { + return path + } + remainder = filepath.Join(filepath.Base(current), remainder) + current = parent + } +} + func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error { - plan, err := LoadPlan(planPath) + repoRoot, err := ResolveRepository(filepath.Dir(planPath)) if err != nil { return err } - sourcePlan, err := SourcePlanForStructuredPlan(planPath) + artifacts, err := compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus) if err != nil { return err } - if err := CheckSourcePlan(sourcePlan); err != nil { + // Promote the compiled plan (three files that must land together) through the + // transactional mutation boundary: an all-or-nothing atomic write with an + // automatic rollback on post-write verification failure and a durable receipt. + // This replaces three independent non-atomic os.WriteFile calls that could + // leave a compiled graph without its evidence ledger on a mid-write crash. + mutation := MutationSet{ + Protocol: MutationProtocol, + Kind: "compiled-plan", + Scope: artifacts.scope, + Base: artifacts.base, + Authority: MutationAuthority{Expected: artifacts.authority, Observed: artifacts.authority}, + Operations: artifacts.ops, + PostCheck: artifacts.postCheck, + } + if _, err := ApplyMutation(repoRoot, mutation); err != nil { return err } - repoRoot, _ := ResolveRepository(filepath.Dir(planPath)) + return nil +} + +// compiledArtifacts holds the compiled trio's mutation operations, their scope, +// per-path base preconditions, the authorizing plan fingerprint, the compiled +// tasks.json bytes (so the plan lock can bind their hash without a disk read), +// and a post-write verifier. It is the shared spine of both the compiled-plan +// mutation and the fully atomic plan-activation mutation. +type compiledArtifacts struct { + ops []MutationOperation + scope []string + base map[string]string + authority string + tasksJSON []byte + postCheck func() error +} + +func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) (compiledArtifacts, error) { + plan, err := LoadPlan(planPath) + if err != nil { + return compiledArtifacts{}, err + } + sourcePlan, err := SourcePlanForStructuredPlan(planPath) + if err != nil { + return compiledArtifacts{}, err + } + if err := CheckSourcePlan(sourcePlan); err != nil { + return compiledArtifacts{}, err + } opts := &ValidatePlanOptions{ PlanPath: planPath, RepoRoot: repoRoot, } tasks, matrix, evidence, err := CompilePlan(plan, opts) if err != nil { - return err + return compiledArtifacts{}, err } tasks["structured_plan_status"] = structuredPlanStatus - if err := os.MkdirAll(outDir, 0o755); err != nil { - return err - } tasksJSON, err := MarshalJSON(tasks) if err != nil { - return err + return compiledArtifacts{}, err } matrixJSON, err := MarshalJSON(matrix) if err != nil { - return err + return compiledArtifacts{}, err } - if err := os.WriteFile(filepath.Join(outDir, "tasks.json"), tasksJSON, 0o644); err != nil { - return err + absOut, err := filepath.Abs(outDir) + if err != nil { + return compiledArtifacts{}, err + } + // Canonicalize through symlinks so repository-relative resolution matches the + // symlink-resolved repo root (e.g. /var -> /private/var on macOS). The output + // directory (and one or more of its parents) may not exist yet, so resolve the + // deepest existing ancestor and rejoin the not-yet-created remainder. + absOut = canonicalizeExistingAncestor(absOut) + relTasks, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "tasks.json")) + if err != nil { + return compiledArtifacts{}, err } - if err := os.WriteFile(filepath.Join(outDir, "test-matrix.json"), matrixJSON, 0o644); err != nil { - return err + relMatrix, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "test-matrix.json")) + if err != nil { + return compiledArtifacts{}, err + } + relEvidence, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "evidence.md")) + if err != nil { + return compiledArtifacts{}, err + } + authority := "" + if check, checkErr := CheckPlan(planPath); checkErr == nil { + authority = check.Fingerprint } - return os.WriteFile(filepath.Join(outDir, "evidence.md"), []byte(evidence), 0o644) + scope := []string{relTasks, relMatrix, relEvidence} + base := map[string]string{} + for _, rel := range scope { + if hash, hashErr := SHA256File(filepath.Join(repoRoot, filepath.FromSlash(rel))); hashErr == nil { + base[rel] = hash + } + } + postCheck := func() error { + for _, rel := range []string{relTasks, relMatrix} { + value, readErr := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(rel))) + if readErr != nil { + return readErr + } + if validateErr := ValidateJSON("verify promoted compiled plan", rel, value); validateErr != nil { + return validateErr + } + } + info, statErr := os.Stat(filepath.Join(repoRoot, filepath.FromSlash(relEvidence))) + if statErr != nil || info.Size() == 0 { + return fmt.Errorf("promoted evidence ledger is missing or empty") + } + return nil + } + return compiledArtifacts{ + ops: []MutationOperation{ + {Path: relTasks, Candidate: tasksJSON}, + {Path: relMatrix, Candidate: matrixJSON}, + {Path: relEvidence, Candidate: []byte(evidence)}, + }, + scope: scope, + base: base, + authority: authority, + tasksJSON: tasksJSON, + postCheck: postCheck, + }, nil } type ApprovalOptions struct { @@ -920,9 +1036,18 @@ func ActivatePlan(options ActivationOptions) error { } else if statePath, statePathErr := deliveryStatePath(repo, stringValue(check.Plan["feature_id"])); statePathErr == nil && fileExists(statePath) { return fmt.Errorf("managed delivery state exists without its plan lock; do not reset delivery progress") } - if err := compilePlanFiles(options.PlanPath, options.OutDir, structuredPlanStatus); err != nil { + // Assemble the single activation MutationSet: the compiled trio plus the plan + // lock, all promoted all-or-nothing through the transactional boundary so no + // crash or failed post-write check can leave a compiled graph without its lock + // (or a lock without its graph). The bytes are built before the atomic promote; + // nothing is written to disk until ApplyMutation succeeds end to end. + mutation, err := activationMutation(repo, options, structuredPlanStatus, approval) + if err != nil { return err } + // Baseline drift guard runs immediately before the atomic promote so a product + // change concurrent with approval cannot be sealed into the lock. The compiled + // output directory and the lock path are excluded from the baseline. currentBaseline, err := productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.ApprovalPath, options.OutputPath, options.OutDir) if err != nil { return err @@ -930,15 +1055,62 @@ func ActivatePlan(options ActivationOptions) error { if currentBaseline.DiffSHA256 != baseline.DiffSHA256 || strings.Join(currentBaseline.ChangedPaths, "\x00") != strings.Join(baseline.ChangedPaths, "\x00") { return fmt.Errorf("pre-activation product baseline drifted before the plan lock could be created; expected paths %s, observed paths %s", strings.Join(baseline.ChangedPaths, ", "), strings.Join(currentBaseline.ChangedPaths, ", ")) } - if err := CreateApprovalLock(approval); err != nil { - return err - } - if err := CheckApprovalLock(approval); err != nil { + if _, err := ApplyMutation(repo, mutation); err != nil { return err } return initializeDeliveryState(repo, stringValue(check.Plan["feature_id"]), options.PlanPath, options.OutputPath) } +// activationMutation assembles the four-artifact activation MutationSet: the +// compiled trio (tasks.json, test-matrix.json, evidence.md) and the plan lock. +// The lock binds the compiled task graph hash from the in-memory candidate bytes +// so all four land in a single atomic promote, and its PostCheck verifies the +// promoted lock against the plan/spec/source-plan and the promoted task graph. +func activationMutation(repoRoot string, options ActivationOptions, structuredPlanStatus string, approval ApprovalOptions) (MutationSet, error) { + artifacts, err := compileArtifacts(repoRoot, options.PlanPath, options.OutDir, structuredPlanStatus) + if err != nil { + return MutationSet{}, err + } + lockBytes, err := buildApprovalLock(approval, SHA256Bytes(artifacts.tasksJSON)) + if err != nil { + return MutationSet{}, err + } + absLock, err := filepath.Abs(options.OutputPath) + if err != nil { + return MutationSet{}, err + } + absLock = canonicalizeExistingAncestor(absLock) + relLock, err := repositoryRelativePath(repoRoot, absLock) + if err != nil { + return MutationSet{}, err + } + scope := append(append([]string{}, artifacts.scope...), relLock) + base := map[string]string{} + for rel, hash := range artifacts.base { + base[rel] = hash + } + if hash, hashErr := SHA256File(filepath.Join(repoRoot, filepath.FromSlash(relLock))); hashErr == nil { + base[relLock] = hash + } + ops := append(append([]MutationOperation{}, artifacts.ops...), MutationOperation{Path: relLock, Candidate: lockBytes}) + trioPostCheck := artifacts.postCheck + mutation := MutationSet{ + Protocol: MutationProtocol, + Kind: "plan-activation", + Scope: scope, + Base: base, + Authority: MutationAuthority{Expected: artifacts.authority, Observed: artifacts.authority}, + Operations: ops, + PostCheck: func() error { + if err := trioPostCheck(); err != nil { + return err + } + return CheckApprovalLock(approval) + }, + } + return mutation, nil +} + func gitCommit(directory string) string { command := exec.Command("git", "-C", directory, "rev-parse", "HEAD") value, err := command.Output() @@ -948,25 +1120,32 @@ func gitCommit(directory string) string { return strings.TrimSpace(string(value)) } -func CreateApprovalLock(options ApprovalOptions) error { +// buildApprovalLock constructs the plan.lock.json bytes. The compiled task graph +// hash is supplied by the caller rather than read from disk so the lock can be +// promoted in the same atomic MutationSet as tasks.json — during activation the +// task graph does not yet exist on disk when the lock bytes are assembled. +func buildApprovalLock(options ApprovalOptions, tasksSHA256 string) ([]byte, error) { mode := strings.ToLower(strings.TrimSpace(options.AuthorizationMode)) if mode == "" { mode = "human" } if mode != "human" && mode != "policy" { - return fmt.Errorf("authorization mode must be human or policy") + return nil, fmt.Errorf("authorization mode must be human or policy") } if mode == "human" && strings.TrimSpace(options.ApprovedBy) == "" { - return fmt.Errorf("approved-by must name the human who explicitly approved the plan") + return nil, fmt.Errorf("approved-by must name the human who explicitly approved the plan") } if err := checkApprovalSourcePlan(options); err != nil { - return err + return nil, err } - for _, path := range []string{options.SpecPath, options.PlanPath, options.TasksPath} { + for _, path := range []string{options.SpecPath, options.PlanPath} { if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() { - return fmt.Errorf("required approved artifact does not exist: %s", path) + return nil, fmt.Errorf("required approved artifact does not exist: %s", path) } } + if strings.TrimSpace(tasksSHA256) == "" { + return nil, fmt.Errorf("required approved artifact does not exist: %s", options.TasksPath) + } approvedAt := options.ApprovedAt if approvedAt == "" { approvedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) @@ -978,7 +1157,6 @@ func CreateApprovalLock(options ApprovalOptions) error { specHash, _ := SHA256File(options.SpecPath) sourcePlanHash, _ := SHA256File(options.SourcePlanPath) planHash, _ := SHA256File(options.PlanPath) - tasksHash, _ := SHA256File(options.TasksPath) baselinePaths := options.BaselineChangedPaths if baselinePaths == nil { baselinePaths = []string{} @@ -996,7 +1174,7 @@ func CreateApprovalLock(options ApprovalOptions) error { "plan_path": options.PlanPath, "plan_sha256": planHash, "task_graph_path": options.TasksPath, - "task_graph_sha256": tasksHash, + "task_graph_sha256": tasksSHA256, "invalidated_at": nil, "invalidation_reason": nil, "baseline_diff_sha256": options.BaselineDiffSHA256, @@ -1006,7 +1184,18 @@ func CreateApprovalLock(options ApprovalOptions) error { lock["approved_by"] = options.ApprovedBy lock["approved_at"] = approvedAt } - value, err := MarshalJSON(lock) + return MarshalJSON(lock) +} + +func CreateApprovalLock(options ApprovalOptions) error { + if info, err := os.Stat(options.TasksPath); err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("required approved artifact does not exist: %s", options.TasksPath) + } + tasksHash, err := SHA256File(options.TasksPath) + if err != nil { + return err + } + value, err := buildApprovalLock(options, tasksHash) if err != nil { return err } diff --git a/boatstack/plan_test.go b/boatstack/plan_test.go index 408a6d5..e256114 100644 --- a/boatstack/plan_test.go +++ b/boatstack/plan_test.go @@ -198,6 +198,80 @@ func TestPolicyActivationCreatesTypedLockWithoutApproval(t *testing.T) { } } +// activatePolicyPlan sets up a committed repo and activates a policy-mode plan, +// returning the paths a caller needs to assert on the promoted artifacts. It is +// the shared fixture for the atomic-activation and managed-undo tests. +func activatePolicyPlan(t *testing.T) (root, planPath, compiled, lock, feature string) { + t.Helper() + root = t.TempDir() + _, _, planPath = writePlanInputs(t, root, true) + runGit(t, root, "init", "-b", "main") + runGit(t, root, "config", "user.name", "Boatstack Test") + runGit(t, root, "config", "user.email", "boatstack@example.invalid") + writeActivationConfig(t, root, false) + runGit(t, root, "add", ".") + runGit(t, root, "commit", "-m", "record policy-activated planning inputs") + // Lay the managed artifacts out under the feature directory, as the real + // workflow does, so the undo verb's domain guard can resolve the owning + // feature from the promoted paths. + feature = "feature-one" + featureDir := filepath.Join(root, ".product-loop", "features", feature) + compiled = filepath.Join(featureDir, "compiled") + lock = filepath.Join(featureDir, "plan.lock.json") + options := ActivationOptions{PlanPath: planPath, OutDir: compiled, OutputPath: lock, SourceCommit: "test"} + if err := ActivatePlan(options); err != nil { + t.Fatal(err) + } + return root, planPath, compiled, lock, feature +} + +// TestActivationPromotesFourArtifactsAtomically proves activation lands the +// compiled trio and the plan lock as one transactional mutation: a single +// receipt whose four recorded post-images match the bytes on disk. Because +// ApplyMutation is all-or-nothing (proven at the primitive level), one receipt +// covering all four files is the structural guarantee that no partial set can be +// left behind. +func TestActivationPromotesFourArtifactsAtomically(t *testing.T) { + root, _, compiled, lock, _ := activatePolicyPlan(t) + artifacts := []string{ + filepath.Join(compiled, "tasks.json"), + filepath.Join(compiled, "test-matrix.json"), + filepath.Join(compiled, "evidence.md"), + lock, + } + for _, path := range artifacts { + if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() { + t.Fatalf("expected activated artifact %s", path) + } + } + receipts, err := ListMutationReceipts(root) + if err != nil { + t.Fatal(err) + } + var activation *MutationReceipt + for i := range receipts { + if receipts[i].Kind == "plan-activation" && receipts[i].Status == "APPLIED" { + activation = &receipts[i] + break + } + } + if activation == nil { + t.Fatalf("no APPLIED plan-activation receipt among %d receipts", len(receipts)) + } + if len(activation.Changes) != 4 { + t.Fatalf("expected one atomic mutation over four artifacts, got %d changes", len(activation.Changes)) + } + for _, change := range activation.Changes { + hash, err := SHA256File(filepath.Join(root, filepath.FromSlash(change.Path))) + if err != nil { + t.Fatalf("promoted artifact %s missing: %v", change.Path, err) + } + if change.AfterSHA256 != hash { + t.Fatalf("receipt post-image for %s does not match disk", change.Path) + } + } +} + func TestCurrentCursorSingleJSONFencePlanIsAccepted(t *testing.T) { root := t.TempDir() _, _, planPath := writePlanInputs(t, root, false) diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 7724254..1e1ce1d 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -24,6 +24,7 @@ Select a move only after locating the failure below its surface symptom. “Time | Post-publication correction routing | CI, review, or a denied push targets work already marked published | Resolve branch and recorded PR identity; append the observation; draft an independently approved corrective child | Treating PR creation as completion or asking the user to bypass the guard | | Unobserved side-effect completion | The same visible state could mean not started, executing, succeeded with a lost response, or failed | Durable operation receipt; exact lease; observe completion; reconcile the expected postcondition before retry | Conversation-scoped retry loops, duplicate PRs, or phantom success | | Unregistered malformed draft lockout | A hand-authored feature `plan.md` never passed through the helper, so a `CheckPlan` failure escalates to `INVALID_STATE` and the guard denies every product mutation, including the prescribed recovery | `repair-state` quarantines the draft out of `features/` and returns the workflow to `auto-plan`, refusing any directory with a lock, `pr.md`, delivery state, or tracked files | Loosening candidate selection so a genuinely invalid plan silently unblocks product edits | +| Non-transactional multi-file promote | A managed artifact spans files that must land together (e.g. the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, and the `plan.lock.json` that binds them), but independent non-atomic writes can leave a partial set on a crash or a failed post-write check | Promote the whole set through the transactional mutation boundary as one mutation: base-hash preconditions, supervisor-authority binding, atomic all-or-nothing write, post-write verification with automatic rollback, and a reversible receipt whose inverse bytes make the boundary closed under inversion — `undo` re-applies the inverse as a mutation (with redo as undo-of-the-undo), and a domain guard refuses reversal once a delivery gate would be stranded | Patching consistency after the fact with hash guards instead of making the promote atomic, persisting a rejected identity so a corrected retry deadlocks, or undoing an activation that strands live delivery state | ## Lessons encoded from the benchmark campaign diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 43341f3..580918a 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -36,6 +36,10 @@ A published delivery cannot be reset. Its correction uses a deterministic new fe `repair-state` is the bounded recovery for the one state `recovery-status` cannot resolve: an unregistered feature draft whose `plan.md` never passed through the helper, so it has no plan lock and no delivery state. A malformed such draft makes the guard fail closed at `INVALID_STATE` and deny every product mutation. `repair-state` quarantines that directory out of `.product-loop/features/` into `/boatstack/quarantine//` — reversible, never a hard delete — and returns the workflow to `auto-plan`. It resolves the sole malformed candidate when `--feature` is omitted and refuses ambiguity. It acts only on a directory carrying no durable authority: it refuses any feature with a valid saved plan, a plan lock, a `pr.md`, a managed delivery state, git-tracked files, or an active or published delivery. The guard allowlists it independent of stage but still rejects shell metacharacters and non-helper executables; gating for registered, active, or published deliveries is unchanged. +The **transactional mutation boundary** is the standing form of the same principle `repair-state` applies once: a supervisor that removes an actuator must still expose a bounded actuator capable of reaching every valid state, including reversing its own last move. When Boatstack promotes a managed artifact that spans files which must land together, the candidate bytes are submitted as a mutation set that the runtime confirms against per-file base hashes and a supervisor-authority token, writes atomically all-or-nothing, verifies after the write, and rolls back automatically on failure — recording a reversible receipt with per-file before/after hashes. A rejected mutation persists no identity, so a candidate recomputed against the current base and authority applies cleanly: refusal is fail-closed but never a deadlock. Plan activation promotes all four of its managed artifacts — the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, and the `plan.lock.json` — through a single mutation, so they land or fail together. + +The boundary is **closed under inversion**: every receipt carries its own inverse bytes and *is* the undo command. Undo re-applies that inverse as an ordinary mutation through the same boundary (an explicit absent operation expresses a delete, so the inverse of a create is a first-class mutation), which makes undo atomic and verified, makes the base precondition the conflict guard (undo refuses rather than clobbering later work), and makes **redo just an undo of the undo receipt**. Two bounded verbs expose this and are allowlisted by the guard at any stage (like `repair-state`, still rejecting shell metacharacters and non-helper executables): `mutation-status` (read-only) lists or inspects receipts so an agent can find the one to reverse, and `undo --mutation ` reverses it. `undo` is state-aware — it refuses to reverse a plan activation once a delivery gate receipt exists, so it can never strand delivery state without its lock. This boundary governs Boatstack's own generated managed artifacts only, never coding-agent source-code editing. + The `SOURCE_PLAN` file is required from entry through completion of `BUILD`. After build, its path and hash remain recorded for provenance, but `TEST_GATE`, `REVIEW_GATE`, and `SHIP_GATE` do not require the original file to be present. ## Irreversible-operation boundary @@ -264,8 +268,8 @@ At the host's normal Build transition, first confirm the host is in an execution 1. parse and validate the marked structured block in `plan.md`; 2. hash the complete source plan, spec, `plan.md`, and pre-activation product baseline, matching them to `approval.md` when human approval is enabled; 3. compile the task graph, requirement-test traceability rows, and evidence skeleton without adding semantics; -4. record authorization mode, timestamp, source commit, artifact hashes, and baseline diff/path provenance in plan-lock schema v2, plus approver provenance only for human authorization; -5. write the lock last and recheck it before permitting implementation. +4. record authorization mode, timestamp, source commit, artifact hashes (the compiled task-graph hash bound from the in-memory candidate), and baseline diff/path provenance in plan-lock schema v2, plus approver provenance only for human authorization; +5. promote all four artifacts (`compiled/tasks.json`, `compiled/test-matrix.json`, `compiled/evidence.md`, and `plan.lock.json`) through the transactional mutation boundary as one mutation, whose post-write check re-validates the compiled JSON, asserts the evidence ledger is non-empty, and rechecks the promoted lock before permitting implementation — so all four land all-or-nothing and the reversible receipt can undo the whole activation. Activation also initializes ignored, worktree-local Git delivery state bound to the lock. One implicit `delivery` slice preserves the ordinary one-feature/one-PR flow. An diff --git a/boatstack/safety.go b/boatstack/safety.go index 7b49864..2743536 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -95,7 +95,7 @@ func controlledPhaseTransition(command, stage string) bool { readOnlyHelpers := map[string]bool{ "check-plan": true, "check-source-plan": true, "next-status": true, "delivery-status": true, "recovery-status": true, "repair-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, - "doctor": true, "version": true, + "doctor": true, "version": true, "mutation-status": true, } if readOnlyHelpers[fields[1]] { return true @@ -108,6 +108,15 @@ func controlledPhaseTransition(command, stage string) bool { if fields[1] == "repair-state" { return true } + // undo is the bounded actuator that reverses a Boatstack-generated managed + // artifact by re-applying its receipt's inverse through the same transactional + // mutation boundary. Like repair-state it is a stage-independent recovery verb; + // it mutates but self-guards (UndoManagedMutation refuses to strand delivery + // state and the boundary's stale-base precondition refuses to clobber later + // work), so it is not a read-only helper. + if fields[1] == "undo" { + return true + } switch stage { case "DRAFT_PLAN": return fields[1] == "planning-write" || fields[1] == "record-approval" diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 9194a73..a0119a6 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **15529 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **16407 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`4dcec85043e6fec7558e7bdff97cb2babe3af7c2`](https://github.com/operatorstack/intelligence-flow/tree/4dcec85043e6fec7558e7bdff97cb2babe3af7c2/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`c7d80b2f99481f8065a5fc5f60ee4d41958f5efa`](https://github.com/operatorstack/intelligence-flow/tree/c7d80b2f99481f8065a5fc5f60ee4d41958f5efa/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index ccf3e63..0a1cfb5 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "4dcec85043e6fec7558e7bdff97cb2babe3af7c2", + "source_commit": "c7d80b2f99481f8065a5fc5f60ee4d41958f5efa", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:4dcec85043e6fec7558e7bdff97cb2babe3af7c2" + "last_verified_version": "source:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 2b19297..8036d5b 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "4dcec85043e6fec7558e7bdff97cb2babe3af7c2", + "source_commit": "c7d80b2f99481f8065a5fc5f60ee4d41958f5efa", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-24-transactional-mutation-boundary.md b/release-notes/2026-07-24-transactional-mutation-boundary.md new file mode 100644 index 0000000..1c82f6f --- /dev/null +++ b/release-notes/2026-07-24-transactional-mutation-boundary.md @@ -0,0 +1,14 @@ +### Plan activation promotes its managed artifacts through a transactional mutation boundary, with a proper undo + +Activating a plan produces four managed artifacts that must be consistent with one another — the compiled `tasks.json`, `test-matrix.json`, and `evidence.md`, plus the `plan.lock.json` that binds them to the approved spec, source plan, and plan. Until now the compiled trio was written with three independent, non-atomic `os.WriteFile` calls and the lock with a fourth, separate write, so a crash or a failed verification mid-write could leave a compiled task graph without its evidence ledger, a partially rewritten graph, or a lock that pointed at a task graph that never landed. Consistency was only patched after the fact by hash and idempotency guards, and there was no single point that promoted the set all-or-nothing or that could reverse it. + +`activate-plan` now assembles all four artifacts as one candidate `MutationSet` and routes it through a transactional mutation boundary. The bytes — including the lock, whose task-graph hash is bound from the in-memory compiled candidate rather than a disk read — are validated in isolation and promoted atomically: either every artifact lands as its exact validated bytes or none do. Each target carries a base precondition (its expected before-hash), so a stale candidate written against a file that changed underneath is rejected with the accepted state untouched. A supervisor authority token bound to the plan fingerprint rejects a candidate authorized against a state the supervisor has since advanced past. After the bytes are on disk a post-write verification re-reads and re-validates the promoted JSON, asserts the evidence ledger is non-empty, and re-checks the promoted lock against the plan, spec, and source plan; if any check fails, the boundary automatically rolls every file back to its captured before-bytes. + +**The boundary is closed under inversion, which is what makes undo correct rather than best-effort.** Every successful promotion emits a durable receipt with per-file before/after hashes and inverse bytes — the receipt *is* the undo command. Undo re-applies that inverse *as an ordinary mutation* through the same boundary (an explicit absent/delete operation expresses "this file did not exist before", so the inverse of a create is a first-class `MutationSet`). Three properties fall out for free: undo is itself atomic and verified; undo records a new receipt whose own inverse is the original after-state, so **redo is just undo of the undo receipt** — no separate verb; and the base precondition *is* the conflict guard — if anything changed a file since it was written, undo refuses (`ErrMutationConflict`) instead of clobbering later work. Undo replay is idempotent: re-issuing the same undo replays its receipt rather than forking a new one. + +Two bounded, agent-facing verbs expose this so operations stay smooth and never deadlock: + +- **`mutation-status --repo . [--mutation ] [--json]`** (read-only): list or inspect mutation receipts, so an agent can find the receipt to reverse — or, for redo, the undo receipt to reverse in turn. +- **`undo --repo . --mutation [--json]`**: reverse a managed artifact through the guarded inverse. Both verbs are allowlisted by the safety guard at any stage (like `repair-state`), still rejecting shell metacharacters and non-helper executables. `undo` is state-aware: before reversing a plan activation it refuses if a delivery gate receipt already exists, because removing the lock a live delivery depends on would strand delivery state. The primitive stays domain-agnostic; the guard lives in the verb layer. + +This is the standing form of the recovery principle behind `repair-state`: a supervisor that removes an actuator must still expose a bounded actuator capable of reaching every valid state — including reversing its own last move. A rejected mutation never persists an identity, so a candidate recomputed against the current base and authority applies cleanly; refusal is fail-closed but never a deadlock. **This boundary governs Boatstack's own generated managed artifacts only** — the compiled plan and its lock — never coding-agent source-code editing, which stays outside the boundary.