diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c914f11..e480197 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/76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75/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/666b0631c31ee9509a4b3cb0f1242fe865a97c24/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 a25d091..0138eaa 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 73472, - "estimated_tokens": 18368, + "characters": 76152, + "estimated_tokens": 19038, "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": "0a0005c09ebc1aa11103648517f7a14cdc4a19f4de07ec8fb121c1597477ff9d", + "CONTRIBUTING.md": "e937fe6f4cedacad98619d49538da76a0543f95990b2d51db271ffd03bcc1280", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -47,7 +47,8 @@ "boatstack/config_documentation_test.go": "0632366edc5e88145bb080083ea03c6515da07b0162ce404d63e51bb5bc0774e", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "d9c8fdaa8cdc94a885e7d08c872dc2c4c851fa1e29b0db7cb2e8e0893cd36380", + "boatstack/delivery.go": "f7c780d9860e0d5456ecfad5f61242337b2ba1068fceec9eda7232f24374c03c", + "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", "boatstack/export.go": "9d2b83b6075b3715599a3c19afb3a7ec8d2a006f8af624e3807f3b1fe7620065", @@ -74,7 +75,7 @@ "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", "boatstack/operation.go": "62f97bf2091f33eb2ca91915bf08bee73d53387611b673e849355bfd516ca467", "boatstack/operation_test.go": "2d624eaba342b2c81b45cdf50918a65a9c002b5376a02041b24180658ee6a25a", - "boatstack/plan.go": "ddb5148113454502a57c45a7dc2e514b9d6c191b5035bbee10f15e2fe5e06ddf", + "boatstack/plan.go": "7209de3a97b134cd6e5224cf6e798b5af47b0a4163ae2f81a8ec0c1ff84031d6", "boatstack/plan_test.go": "53477515165a910910b9175bfa33574548cf0d0f3be48e175ec3a775a12d30bb", "boatstack/plan_validation.go": "412f06750832fe46f01190ea5e475fc6f6ea59c8ba78131f94ec031053a405d2", "boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa", @@ -93,11 +94,11 @@ "boatstack/reexec_windows.go": "f5335c8c28cb4e89048b058b1c4d12f78644f99acb4f6167ff60e622dfb9e742", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", - "boatstack/references/failure-moves.md": "e1cdba05cb49817d8246eee45ab4a1ba4d691cd0ef3bd6325ee1d795ffa00b0e", + "boatstack/references/failure-moves.md": "fe4c867b06b0913cec202631a0a8beced52394d356bfd37b5fa43a9c977ebae2", "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "e14a324f91b8c6012956168ff838cc6f64ae4cde61666d086174a2a4525dc683", + "boatstack/references/workflow.md": "fad616acd737818d0125655cd5d81d52dc39aff6242a6925c1a43dc38658b36e", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", @@ -131,10 +132,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "473afd9bd7f52f901d3046844279694d5259bfeaf708d5cf9eb859a1448f98e1", + "docs/evidence-engineered-coding.md": "65056f8f744b7ef5693bad2c3fd8c7f3ebff3989098ee074ede8ebc250bc1373", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "f314270c5ed1a55bbef5f3ddbcb5596693dbee9374e5f0d3df8838cefbd68052", - "docs/public-claims.json": "e2758cfc1f3a089212a5fcdbb75db503c84833c7ea509f4c50a2a50c88939335", + "docs/public-claims.json": "a5923a6b53d922b3d515e64ce91d03ea4c251c7fcb00ef39be6ac0d056b11018", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -148,7 +149,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": "3fbb0fbd8cead40c6d48e57ed88552a2ce2ef82b85c5983a4acf1d04f577947e", + "labs/diagram-json/plan.lock.json": "bb709ce18133d8d49a8b96249bb2e2d534d37f004b01df9027a6c3dd070dcd6a", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -225,13 +226,14 @@ "release-notes/2026-07-24-ignored-deliveries-publication-authority.md": "a25f8469316276101490c79a57c1a236c18072dfbb23682bb1778871d247067d", "release-notes/2026-07-24-provenance-verified-binary-install.md": "b64f3a8dbd750afb28bf6964489eb0f5f8519efa65fcee871db64b9e60c2fdb2", "release-notes/2026-07-24-publication-nonblocking-control.md": "2b9d8ea817896783273a843ec183fbf00bb2f7ac420b4ce3409aeda7f59b7fb5", + "release-notes/2026-07-24-reactivation-preserves-published-progress.md": "77233df3d6955e8f7a076c301ce7cbff84ba138ab812f18ddd97785600fd3d22", "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": "76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75", + "commit": "666b0631c31ee9509a4b3cb0f1242fe865a97c24", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 283d94a..df2cf91 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -334,28 +334,137 @@ func initializeDeliveryState(repo, feature, planPath, lockPath string) error { if err != nil { return err } - previousLocks := []string{} - repairAttempt := 0 if existing, loadErr := LoadDeliveryState(repo, feature); loadErr == nil { + // Re-activating the exact same lock is a no-op: never disturb progress. if existing.PlanLockHash == lockHash { return nil } - if existing.ActiveIndex >= len(existing.Slices) { - return fmt.Errorf("published delivery %s is immutable; activate the correction under a new feature id with parent_delivery=%s", feature, feature) + // A plan amendment mid-delivery must preserve every already-published + // slice. deliveryDefinitions freshly recomputes ALL slices from the new + // plan, so a naive re-initialize would reset ActiveIndex to 0 and strand + // slices whose PR is already open or merged. Reconcile instead: keep the + // published prefix (and its BUILD pointer) and adopt the amended + // definitions only for the not-yet-published tail. + if err := validateAmendmentPreservesProgress(existing, slices); err != nil { + return err } - previousLocks = append(previousLocks, existing.PreviousPlanLocks...) - if existing.PlanLockHash != "" { - previousLocks = append(previousLocks, existing.PlanLockHash) - } - repairAttempt = existing.RepairAttempt + return saveDeliveryState(repo, reconcileAmendedDeliveryState(existing, slices, lockHash)) } return saveDeliveryState(repo, DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, - PreviousPlanLocks: previousLocks, ActiveIndex: 0, Slices: slices, Mode: "NORMAL", RepairAttempt: repairAttempt, + ActiveIndex: 0, Slices: slices, Mode: "NORMAL", ParentDelivery: strings.TrimSpace(stringValue(plan["parent_delivery"])), }) } +// guardReactivationPreservesProgress lets ActivatePlan reject a +// progress-destroying amendment before it promotes any artifact. It is a no-op +// when no managed delivery exists yet (first activation) or when the amendment +// only touches the not-yet-published tail. An idempotent same-plan re-activation +// never reaches this guard: ActivatePlan short-circuits on the matching lock. +func guardReactivationPreservesProgress(repo, feature, planPath string) error { + existing, err := LoadDeliveryState(repo, feature) + if err != nil { + return nil + } + plan, err := LoadPlan(planPath) + if err != nil { + return err + } + newSlices, err := deliveryDefinitions(plan) + if err != nil { + return err + } + return validateAmendmentPreservesProgress(existing, newSlices) +} + +// equalStrings reports slice equality treating nil and empty as the same, so a +// definition round-tripped through JSON (where an empty list may deserialize as +// nil) compares equal to a freshly recomputed one. +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// deliveryDefinitionMatches reports whether two slices carry the same task +// composition and scope. Runtime fields (Status, PRURL, PRState, branches) are +// intentionally ignored: an amendment may not alter what an already-published +// slice built, but PR/branch bookkeeping is delivery state, not definition. +func deliveryDefinitionMatches(a, b DeliverySlice) bool { + return a.ID == b.ID && + equalStrings(a.TaskIDs, b.TaskIDs) && + equalStrings(a.AffectedPaths, b.AffectedPaths) && + equalStrings(a.AcceptanceCriteria, b.AcceptanceCriteria) +} + +// validateAmendmentPreservesProgress refuses a re-activation that would alter, +// drop, or reorder any already-published delivery slice. Slices in +// [0, ActiveIndex) have shipped — their branch, PR, and gate receipts are bound +// to the definition that shipped — so a change there must go through a corrective +// child delivery, never an in-place reset. The not-yet-published tail +// [ActiveIndex, len) is freely recomputable, so amending it (e.g. widening a +// building slice's affected_paths) is allowed. +func validateAmendmentPreservesProgress(existing DeliveryState, newSlices []DeliverySlice) error { + if existing.ActiveIndex >= len(existing.Slices) { + return fmt.Errorf("published delivery %s is immutable; activate the correction under a new feature id with parent_delivery=%s", existing.Feature, existing.Feature) + } + for i := 0; i < existing.ActiveIndex; i++ { + old := existing.Slices[i] + if i >= len(newSlices) { + return fmt.Errorf("amendment drops published delivery slice %s; draft a corrective child delivery instead of resetting delivery progress", old.ID) + } + if newSlices[i].ID != old.ID { + return fmt.Errorf("amendment reorders or renames published delivery slice %s (now %s at position %d); draft a corrective child delivery instead of resetting delivery progress", old.ID, newSlices[i].ID, i) + } + if !deliveryDefinitionMatches(old, newSlices[i]) { + return fmt.Errorf("amendment changes published delivery slice %s, whose pull request is bound to what shipped; draft a corrective child delivery instead of re-activating it in place", old.ID) + } + } + return nil +} + +// reconcileAmendedDeliveryState preserves the published prefix and its BUILD +// pointer while adopting the amended plan's definitions for the not-yet-published +// tail. The pointer never moves backward and shipped slices keep their PR, +// branches, and status; only the active slice onward is recomputed (its prior +// gate receipts are already invalidated by the new plan lock, so it correctly +// restarts at BUILD). Callers MUST have passed validateAmendmentPreservesProgress +// first. +func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliverySlice, lockHash string) DeliveryState { + merged := make([]DeliverySlice, 0, len(newSlices)) + merged = append(merged, existing.Slices[:existing.ActiveIndex]...) + for i := existing.ActiveIndex; i < len(newSlices); i++ { + slice := newSlices[i] + if i == existing.ActiveIndex { + slice.Status = "BUILD" + } else { + slice.Status = "PENDING" + } + merged = append(merged, slice) + } + previousLocks := append([]string{}, existing.PreviousPlanLocks...) + if existing.PlanLockHash != "" { + previousLocks = append(previousLocks, existing.PlanLockHash) + } + return DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, + Feature: existing.Feature, + PlanLockHash: lockHash, + PreviousPlanLocks: previousLocks, + ActiveIndex: existing.ActiveIndex, + Slices: merged, + Mode: "NORMAL", + ParentDelivery: existing.ParentDelivery, + } +} + func archiveDeliveryReceipt(repo, feature, sliceID, gate, observationID string) (string, error) { path, err := deliveryReceiptPath(repo, feature, sliceID, gate) if err != nil { diff --git a/boatstack/delivery_reactivation_test.go b/boatstack/delivery_reactivation_test.go new file mode 100644 index 0000000..bdcff18 --- /dev/null +++ b/boatstack/delivery_reactivation_test.go @@ -0,0 +1,317 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// publishedThenBuilding is the canonical partially-delivered state the incident +// hit: slice "a" shipped (PR open or merged), the pointer advanced, slice "b" is +// mid-build. pr_state is a parameter because real projects were observed to leave +// it empty even on merged PRs, so the fix must key "published" off the pointer and +// Status, never pr_state. +func publishedThenBuilding(prState string) DeliveryState { + return DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, + Feature: "f", + PlanLockHash: "old-lock", + ActiveIndex: 1, + Slices: []DeliverySlice{ + {ID: "a", Title: "First", Status: "PUBLISHED", PRURL: "https://x/pr/1", PRState: prState, TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, + {ID: "b", Title: "Second", Status: "TEST_PASSED", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go"}}, + }, + } +} + +func TestValidateAmendmentPreservesProgressBoundaries(t *testing.T) { + base := publishedThenBuilding("OPEN") + + t.Run("tail-only amendment is allowed", func(t *testing.T) { + newSlices := []DeliverySlice{ + {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, + {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, + } + if err := validateAmendmentPreservesProgress(base, newSlices); err != nil { + t.Fatalf("widening the building tail slice must be allowed: %v", err) + } + }) + + t.Run("nothing published yet is always allowed", func(t *testing.T) { + fresh := base + fresh.ActiveIndex = 0 + newSlices := []DeliverySlice{{ID: "z", TaskIDs: []string{"T-9"}, AffectedPaths: []string{"z.go"}}} + if err := validateAmendmentPreservesProgress(fresh, newSlices); err != nil { + t.Fatalf("pre-publication amendment must be allowed: %v", err) + } + }) + + t.Run("changing a published slice is refused", func(t *testing.T) { + newSlices := []DeliverySlice{ + {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go", "a-widened.go"}}, + {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go"}}, + } + err := validateAmendmentPreservesProgress(base, newSlices) + if err == nil || !strings.Contains(err.Error(), "changes published delivery slice a") || !strings.Contains(err.Error(), "corrective child") { + t.Fatalf("changing a published slice must route to a corrective child: %v", err) + } + }) + + t.Run("renaming a published slice is refused", func(t *testing.T) { + newSlices := []DeliverySlice{ + {ID: "renamed", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, + {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go"}}, + } + err := validateAmendmentPreservesProgress(base, newSlices) + if err == nil || !strings.Contains(err.Error(), "reorders or renames published delivery slice a") { + t.Fatalf("renaming a published slice must be refused: %v", err) + } + }) + + t.Run("dropping a published slice is refused", func(t *testing.T) { + newSlices := []DeliverySlice{} + err := validateAmendmentPreservesProgress(base, newSlices) + if err == nil || !strings.Contains(err.Error(), "drops published delivery slice a") { + t.Fatalf("dropping a published slice must be refused: %v", err) + } + }) + + t.Run("a fully published delivery stays immutable", func(t *testing.T) { + done := base + done.ActiveIndex = len(done.Slices) + err := validateAmendmentPreservesProgress(done, base.Slices) + if err == nil || !strings.Contains(err.Error(), "is immutable") { + t.Fatalf("fully published delivery must remain immutable: %v", err) + } + }) +} + +// TestReconcileAmendedDeliveryStatePreservesPrefixAndPointer is the heart of the +// fix: the published prefix (and everything about it) survives verbatim, the +// pointer does not move, and only the tail adopts the amended definitions — a +// newly added tail slice appears PENDING. +func TestReconcileAmendedDeliveryStatePreservesPrefixAndPointer(t *testing.T) { + existing := publishedThenBuilding("MERGED") + newSlices := []DeliverySlice{ + {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def + {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice + {ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice + } + + result := reconcileAmendedDeliveryState(existing, newSlices, "new-lock") + + if result.ActiveIndex != 1 { + t.Fatalf("reconcile moved the BUILD pointer: ActiveIndex=%d want 1", result.ActiveIndex) + } + // Published prefix preserved verbatim, including PR bookkeeping. + if got := result.Slices[0]; got.ID != "a" || got.Status != "PUBLISHED" || got.PRURL != "https://x/pr/1" || got.PRState != "MERGED" { + t.Fatalf("published prefix not preserved: %#v", got) + } + // Building slice adopts the amended definition and restarts at BUILD. + if got := result.Slices[1]; got.ID != "b" || got.Status != "BUILD" || !equalStrings(got.AffectedPaths, []string{"b.go", "b-extra.go"}) { + t.Fatalf("building slice not reconciled: %#v", got) + } + // New tail slice is PENDING. + if got := result.Slices[2]; got.ID != "c" || got.Status != "PENDING" { + t.Fatalf("new tail slice not appended as PENDING: %#v", got) + } + if result.PlanLockHash != "new-lock" { + t.Fatalf("plan lock hash not updated: %q", result.PlanLockHash) + } + if !equalStrings(result.PreviousPlanLocks, []string{"old-lock"}) { + t.Fatalf("previous plan lock not recorded: %#v", result.PreviousPlanLocks) + } +} + +func markPrefixPublished(t *testing.T, repo, feature, sliceID, url, prState string) { + t.Helper() + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + found := -1 + for i := range state.Slices { + if state.Slices[i].ID == sliceID { + found = i + } + } + if found < 0 { + t.Fatalf("slice %s not found in state", sliceID) + } + state.Slices[found].Status = "PUBLISHED" + state.Slices[found].PRURL = url + state.Slices[found].PRState = prState + state.ActiveIndex = found + 1 + if state.ActiveIndex < len(state.Slices) { + state.Slices[state.ActiveIndex].Status = "BUILD" + } + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } +} + +func reactivateWithAmendedPlan(t *testing.T, repo, feature string, mutate func(plan map[string]any)) error { + t.Helper() + dir := filepath.Join(repo, ".product-loop", "features", feature) + plan := twoSlicePlan() + plan["feature_id"] = feature + plan["spec_path"] = "feature-spec.md" + if mutate != nil { + mutate(plan) + } + planPath := filepath.Join(dir, "plan.md") + writeMarkdownPlan(t, planPath, plan, true) + check, err := CheckPlan(planPath) + if err != nil { + t.Fatalf("amended plan is invalid: %v", err) + } + approvalPath := filepath.Join(dir, "approval.md") + writeApprovalReceipt(t, approvalPath, check.Fingerprint) + return ActivatePlan(ActivationOptions{ + PlanPath: planPath, + ApprovalPath: approvalPath, + OutDir: filepath.Join(dir, "compiled"), + OutputPath: filepath.Join(dir, "plan.lock.json"), + SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), + }) +} + +// TestReactivationPreservesPublishedPrefixThroughActivatePlan reproduces the +// exact incident end to end: slice one is published/merged, the pointer is at +// slice two, and an approved amendment widens slice two's scope. Before the fix, +// re-activation reset the pointer to 0 and dropped slice one back to BUILD. +func TestReactivationPreservesPublishedPrefixThroughActivatePlan(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + markPrefixPublished(t, repo, feature, "phase-one", "https://example.invalid/pr/328", "MERGED") + + err := reactivateWithAmendedPlan(t, repo, feature, func(plan map[string]any) { + second := plan["tasks"].([]any)[1].(map[string]any) + second["affected_paths"] = []any{"second.go", "second_extra.go"} + }) + if err != nil { + t.Fatalf("amending the building tail slice must succeed: %v", err) + } + + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + if state.ActiveIndex != 1 { + t.Fatalf("re-activation reset the pointer: ActiveIndex=%d want 1", state.ActiveIndex) + } + if s := state.Slices[0]; s.ID != "phase-one" || s.Status != "PUBLISHED" || s.PRURL != "https://example.invalid/pr/328" || s.PRState != "MERGED" { + t.Fatalf("published+merged slice one was not preserved: %#v", s) + } + if s := state.Slices[1]; s.ID != "phase-two" || s.Status != "BUILD" { + t.Fatalf("building slice two not preserved as active: %#v", s) + } + if !containsString(state.Slices[1].AffectedPaths, "second_extra.go") { + t.Fatalf("amended scope not adopted by the tail slice: %#v", state.Slices[1].AffectedPaths) + } + if len(state.PreviousPlanLocks) != 1 { + t.Fatalf("previous plan lock not recorded on amendment: %#v", state.PreviousPlanLocks) + } +} + +// TestReactivationRefusalLeavesPriorStateIntact proves the guard runs before the +// transactional promote, so refusing to alter a published slice leaves both the +// plan lock and the delivery state byte-for-byte unchanged (no half-apply). +func TestReactivationRefusalLeavesPriorStateIntact(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + markPrefixPublished(t, repo, feature, "phase-one", "https://example.invalid/pr/328", "OPEN") + + dir := filepath.Join(repo, ".product-loop", "features", feature) + lockPath := filepath.Join(dir, "plan.lock.json") + statePath, err := deliveryStatePath(repo, feature) + if err != nil { + t.Fatal(err) + } + lockBefore, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + stateBefore, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + + err = reactivateWithAmendedPlan(t, repo, feature, func(plan map[string]any) { + first := plan["tasks"].([]any)[0].(map[string]any) + first["affected_paths"] = []any{"feature.go", "feature_widened.go"} // touches the PUBLISHED slice + }) + if err == nil || !strings.Contains(err.Error(), "corrective child") { + t.Fatalf("amending a published slice must be refused with a corrective-child directive: %v", err) + } + + lockAfter, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + stateAfter, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if string(lockBefore) != string(lockAfter) { + t.Fatalf("refused re-activation half-applied the plan lock:\nbefore=%s\nafter=%s", lockBefore, lockAfter) + } + if string(stateBefore) != string(stateAfter) { + t.Fatalf("refused re-activation mutated delivery state:\nbefore=%s\nafter=%s", stateBefore, stateAfter) + } +} + +// TestReactivationIdempotentAndBenignPrePublish covers the two non-destructive +// paths that must keep working: re-activating the identical plan is a no-op, and +// amending before anything is published simply recomputes at the head. +func TestReactivationIdempotentAndBenignPrePublish(t *testing.T) { + t.Run("identical re-activation is a no-op", func(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + statePath, err := deliveryStatePath(repo, feature) + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if err := reactivateWithAmendedPlan(t, repo, feature, nil); err != nil { + t.Fatalf("identical re-activation errored: %v", err) + } + after, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatalf("identical re-activation changed state:\nbefore=%s\nafter=%s", before, after) + } + }) + + t.Run("pre-publication amendment recomputes at the head", func(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + if err := reactivateWithAmendedPlan(t, repo, feature, func(plan map[string]any) { + second := plan["tasks"].([]any)[1].(map[string]any) + second["affected_paths"] = []any{"second.go", "second_extra.go"} + }); err != nil { + t.Fatalf("pre-publication amendment must succeed: %v", err) + } + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + if state.ActiveIndex != 0 || state.Slices[0].Status != "BUILD" { + t.Fatalf("pre-publication amendment mis-seated the head: %#v", state) + } + if !containsString(state.Slices[1].AffectedPaths, "second_extra.go") { + t.Fatalf("amended tail scope not adopted: %#v", state.Slices[1].AffectedPaths) + } + }) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/boatstack/plan.go b/boatstack/plan.go index 4af347b..b694ad3 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -1036,6 +1036,13 @@ 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") } + // Refuse an amendment that would alter, drop, or reorder an already-published + // delivery slice BEFORE any artifact is promoted, so a rejected re-activation + // leaves the prior plan lock and delivery state fully intact rather than + // half-applied. The same invariant is re-checked in initializeDeliveryState. + if err := guardReactivationPreservesProgress(repo, stringValue(check.Plan["feature_id"]), options.PlanPath); err != nil { + return err + } // 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 diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 5909135..dde81a5 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -28,6 +28,7 @@ Select a move only after locating the failure below its surface symptom. “Time | Premature supervisory pointer advance | A durable supervisory pointer/state advances on request-success and revokes the correction actuator for a target whose postcondition (CI, merge) is not yet observed, so the stranded target can never be re-addressed | Separate the advance from correctability: keep a bounded in-place actuator for a non-terminal target (re-gate/re-publish the same open PR) and a bounded forward actuator once it is terminal (corrective child); resolve addressability network-free from a persisted terminal-state cache, never advance a supervisory pointer past an unobserved postcondition | Serializing legitimately-parallel work by refusing to advance, or persisting an identity/status that deadlocks the corrected retry | | 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 | | Provenance-blind runtime install | A write path stamps an artifact's declared identity (version/commit) from one origin — the running process's compile-time globals — while binding its integrity proof (checksum) to a different origin — the passed bytes; every checksum gate passes because the lock is internally consistent, but the binary self-reports a third value and the version gate fail-closes (clone-wide when the runtime is shared). Symptom: `update -binary ` run by an older helper writes newer bytes into the older version's slot, then every worktree's guard denies at once | Derive the installed artifact's identity from the artifact itself (execute its `version` self-report) and enforce it at the *write* boundary: refuse to install a `-binary` whose self-report disagrees with the running process, and re-exec a cross-version candidate so it installs itself — running becomes installed, so its embedded bundle, constants, slot path, and receipts are all authoritative by construction. Re-hash the just-written slot against its manifest and roll back on mismatch | Executing an untrusted candidate (bounded, operator-invoked only), or converting a recoverable slot mismatch into a hard clone-wide refuse that blocks legitimate upgrades; a per-read self-report exec would tax every guard event, so identity is enforced where it is written, not on the hydration hot path | +| Progress-erasing plan re-lock | Delivery state is keyed to the plan lock hash, so any re-activation that changes the lock (an amended tail, a reordered task, even a whitespace edit) re-derives the slice list — and the re-derivation reset the active pointer to `0`, dropping every already-published slice back to `BUILD`. The guard for this reset only covered the fully-published case, so a *partially* delivered feature (slice one merged, slice two mid-build) silently lost the record of what shipped. Downstream this deadlocks: `undo` refuses to reverse the reset because a live delivery gate would be stranded, and every forward verb re-derives the same wrong state. Symptom: after a benign plan edit the agent loops between `activate-plan` (which re-zeros progress) and `undo`/`run-preflight`/`record-delivery-gate` (all blocked) | Reconcile the amended plan against the existing delivery state instead of resetting it: preserve the published prefix `[0, ActiveIndex)` verbatim (status and PR bookkeeping intact), keep the pointer, and recompute only the recomputable tail (active slice → `BUILD`, rest → `PENDING`), recording the superseded lock. Refuse — before the transactional promote, so nothing half-applies — any amendment that drops, reorders, renames, or changes a *published* slice, or touches a fully-published (immutable) delivery, directing the operator to a corrective child delivery. "Published" is keyed off the pointer and `Status`, never `pr_state`, which real projects leave empty even on merged PRs | Keying delivery identity to the lock hash makes every plan edit a candidate reset, so the reconcile must be the only re-derivation path; treating `pr_state` as the published marker would mis-classify shipped slices as amendable; refusing legitimate tail amendments would push routine edits into unnecessary child deliveries | ## Lessons encoded from the benchmark campaign diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 9e36769..99b7fb6 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -276,6 +276,17 @@ One implicit `delivery` slice preserves the ordinary one-feature/one-PR flow. An explicit multi-slice plan starts only its first slice in `BUILD`; later slices remain `PENDING`. +Because delivery state is keyed to the lock hash, re-activating an amended plan (a +widened tail slice, a new phase, any edit that changes the lock) **reconciles** rather +than resets: the already-published prefix is preserved verbatim — its status, PR, and +branch bookkeeping intact — the active pointer holds, and only the recomputable tail is +re-derived (the active slice restarts at `BUILD`, the rest `PENDING`), recording the +superseded lock. An amendment that would drop, reorder, rename, or change an +already-published slice, or any edit to a fully-published (immutable) delivery, is +refused before the transactional promote — nothing half-applies — and routes to a +corrective child delivery (see "A published delivery cannot be reset"). Published status +is read from the pointer and slice status, never from `pr_state`. + Missing required human approval, unresolved `blocking_questions`, or any change to the source plan, spec, complete `plan.md`, or displayed product baseline blocks activation and returns the feature to `PLAN_GATE`. Existing schema-v1 approval receipts remain valid only with a clean product baseline. A failed or partial compilation never creates a valid lock. Existing schema-v1 human locks remain valid; policy activation always writes schema v2. After `auto-plan` successfully saves a feature plan, managed authority is latched before activation. Reads and bounded Markdown planning transitions remain available, but native edits, mutation-capable MCP tools, and shell commands not proven read-only are denied until activation creates a current lock. Approval itself does not authorize product edits. Ambiguous, stale, malformed, or unverifiable phase state fails closed with one recovery operation; repositories with no saved managed plan retain ordinary unmanaged behavior. diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 9c9e1d9..5d991fd 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 **18368 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 **19038 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 [`76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75`](https://github.com/operatorstack/intelligence-flow/tree/76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75/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 [`666b0631c31ee9509a4b3cb0f1242fe865a97c24`](https://github.com/operatorstack/intelligence-flow/tree/666b0631c31ee9509a4b3cb0f1242fe865a97c24/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 9f19bd2..58120f8 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75", + "source_commit": "666b0631c31ee9509a4b3cb0f1242fe865a97c24", "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" }, { "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:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" + "last_verified_version": "source:666b0631c31ee9509a4b3cb0f1242fe865a97c24" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 38786e2..63047da 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": "76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75", + "source_commit": "666b0631c31ee9509a4b3cb0f1242fe865a97c24", "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-reactivation-preserves-published-progress.md b/release-notes/2026-07-24-reactivation-preserves-published-progress.md new file mode 100644 index 0000000..701629f --- /dev/null +++ b/release-notes/2026-07-24-reactivation-preserves-published-progress.md @@ -0,0 +1,9 @@ +### Re-activating an amended plan reconciles delivery progress instead of erasing it + +Boatstack binds a feature's delivery state to its plan lock hash, so any re-activation that changes the lock — widening a not-yet-built slice, adding a phase, even an incidental edit — re-derives the slice list from the amended plan. Until now that re-derivation reset the active pointer to `0` and dropped every slice back to `BUILD`, guarded only against the *fully*-published case. A **partially** delivered feature — slice one merged, slice two mid-build — silently lost the record of what had already shipped. The consequence was a hard deadlock: `undo` correctly refuses to reverse the reset because a live delivery gate would be stranded, and every forward verb (`run-preflight`, `record-delivery-gate`, the next `activate-plan`) re-derives the same wrong state, so the agent loops with no legal move. This is the delivery-state cousin of the transactional-mutation and publication-nonblocking boundaries: a supervisor that re-derives state must never erase the terminal record of what it already shipped. + +Re-activation now **reconciles** the amended plan against the existing delivery state. The already-published prefix `[0, ActiveIndex)` is preserved verbatim — each slice keeps its `PUBLISHED` status and its PR and branch bookkeeping — the active pointer holds where it was, and only the recomputable tail is re-derived from the amended definitions (the active slice restarts at `BUILD`, the rest at `PENDING`). The superseded lock is appended to the delivery's lock history. Re-activating the identical plan stays a no-op, and amending before anything is published still recomputes cleanly at the head. + +An amendment that would **drop, reorder, rename, or change an already-published slice**, or any edit to a **fully-published (immutable)** delivery, is refused — and refused *before* the transactional promote, so a rejected amendment leaves the plan lock and delivery state byte-for-byte unchanged, never half-applied. The refusal names the offending slice and directs the operator to a corrective child delivery, the standing forward-correction path for terminal work. Throughout, "published" is determined from the active pointer and slice status, never from `pr_state` — real projects were observed to leave `pr_state` empty even on merged PRs, so keying the published prefix off it would misclassify shipped work as freely amendable. + +The boundary ships with a conformance suite covering the preserved published/merged prefix through `activate-plan`, the no-half-apply refusal, drop/reorder/rename/change rejection, fully-published immutability, the identical-plan no-op, and the benign pre-publication amendment.