From 041787b862df5529592007d34e646bd5e7a29962 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Fri, 24 Jul 2026 15:23:14 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ df798dfd69a0 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 34 +-- boatstack/changelog.go | 20 +- boatstack/cmd/boatstack-helper/main.go | 4 +- boatstack/delivery.go | 131 ++++++++-- boatstack/delivery_test.go | 20 +- boatstack/next.go | 1 + boatstack/pr.go | 18 +- boatstack/recovery.go | 33 +++ boatstack/references/failure-moves.md | 1 + boatstack/references/workflow.md | 10 + boatstack/supervisory_control_test.go | 238 ++++++++++++++++++ docs/evidence-engineered-coding.md | 4 +- docs/public-claims.json | 24 +- labs/diagram-json/plan.lock.json | 2 +- ...6-07-24-publication-nonblocking-control.md | 12 + 16 files changed, 480 insertions(+), 74 deletions(-) create mode 100644 boatstack/supervisory_control_test.go create mode 100644 release-notes/2026-07-24-publication-nonblocking-control.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f362ad2..8604436 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/c7d80b2f99481f8065a5fc5f60ee4d41958f5efa/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/df798dfd69a002bb8b9970216adf4f8afbe2b6ca/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 57f8535..d702a3c 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 65626, - "estimated_tokens": 16407, + "characters": 67135, + "estimated_tokens": 16784, "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": "543fc4bc84309b116b5aee230ed129ee81567cc2ad968bb8326f1306f332a858", + "CONTRIBUTING.md": "0404effd19cc9816e5aa60b4924c1cae5be7ebeb0470f3315d0e2388bc44699e", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -38,17 +38,17 @@ "boatstack/capability_test.go": "e8322903a843970d7f0317d2629466532cb05c3ffcb44b9423adf4219faa8021", "boatstack/capture.go": "e32dd7096ccc4844d37c9ec0aea8284adee58bc84cd5b6264516b6661b348bfc", "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", - "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", + "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "c6e1982a63d3e637ef70b8442d22d4fa5308da30e618c205cc3524f1c3b23a85", + "boatstack/cmd/boatstack-helper/main.go": "3ffcaa8907113fa581c3c6500196b9c898427a07821f10fc4bee09393ad3eb6c", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", "boatstack/config_documentation_test.go": "0632366edc5e88145bb080083ea03c6515da07b0162ce404d63e51bb5bc0774e", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "ea53af0e702ec3668a563a5f786dcac2e095362285ca6093b7ed71ec495a0a48", - "boatstack/delivery_test.go": "564ad2029a8412de7953967b1acdf377e6c87b6f6e3465d1f8741a4813f2949f", + "boatstack/delivery.go": "d9c8fdaa8cdc94a885e7d08c872dc2c4c851fa1e29b0db7cb2e8e0893cd36380", + "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", "boatstack/export.go": "9d2b83b6075b3715599a3c19afb3a7ec8d2a006f8af624e3807f3b1fe7620065", "boatstack/export_test.go": "67eb890728d20630925d6e4e90d2a97ec025195ba5c54994c1b098ab72721dca", @@ -68,7 +68,7 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "ce2660e1d7649e0356ee4708aff8ce9ee5961165cf3026df4b8974528c703f71", + "boatstack/next.go": "29644ff0b03974fa9bce290c09695a46282c1fd3ca608c245b6027cf7588529e", "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", "boatstack/operation.go": "62f97bf2091f33eb2ca91915bf08bee73d53387611b673e849355bfd516ca467", "boatstack/operation_test.go": "2d624eaba342b2c81b45cdf50918a65a9c002b5376a02041b24180658ee6a25a", @@ -78,20 +78,20 @@ "boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa", "boatstack/planning.go": "ef4507a9fecc900f0691372c50883f328c9232c3dfd6986fbde26fb7ef436ae3", "boatstack/planning_test.go": "c105a9c78c342be06614bf54d0bc1b661b0f7af64d63b79e43bd1fcc2769edd5", - "boatstack/pr.go": "bdb066acb329b6b772cb880db2e590b381cae909f270085cfacb8ef2fbfda651", + "boatstack/pr.go": "981a59288a0a90534f51a2378656b78bfdc4899810bce5fdeefaa6cde63eeffe", "boatstack/pr_test.go": "7f82954d94c1ceae848a581dda25e58af92251d78a5a94ed2d672bedf5a0349e", "boatstack/provision.go": "4882d49681f99b11ba9d182ca13772131b7f9a11a6c2b560800654ca14f5111e", "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", - "boatstack/recovery.go": "c8b2064791eab066311dad3f12a9143d312a8daefd8cc3670ff4095899a8a195", + "boatstack/recovery.go": "6939747f3725a6dd2de933d0248571d7f21a9ee017568306fe11f08fdbba413e", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", - "boatstack/references/failure-moves.md": "59cb691e618cedd30e53a1e0371c1c7c66f6164e41571ae7e717c42987a1d4a5", + "boatstack/references/failure-moves.md": "999dfc67e9d51eb180f9f14736825ce44fc022ab710ffea0f56cfdf044b71cbc", "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "3cb4eef7c7c519f2c07c192b369e9c8af60ac86873be3da918a7f60a343834a4", + "boatstack/references/workflow.md": "df04b136351d85e2e77be8d28895c8012f65d8992d22f9d0db8011b8f90f2511", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", @@ -104,6 +104,7 @@ "boatstack/safety_test.go": "01f28bc3bfcb6bdd47b307e309e36bbc1921b6426ad0b777d81fe4131200c37e", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", + "boatstack/supervisory_control_test.go": "af64106118ab31061e3f7335a2e981a4c831db3483962b6bc54e7e37dc4b7b45", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", @@ -123,10 +124,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "a3272e12807c4dc62305171b6c6e29dbdbb3ff6bc142b3e123b6909485b7d79c", + "docs/evidence-engineered-coding.md": "55174d0bbfbe32a232cead4063e55ffe1a086e1143e2d449b72c54d628b653c7", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "f314270c5ed1a55bbef5f3ddbcb5596693dbee9374e5f0d3df8838cefbd68052", - "docs/public-claims.json": "e462355d1628110e97fc2ccd1747c16f83cf76536b01950afbb0d9ca7cd1f40b", + "docs/public-claims.json": "106fd639fd10e63bd13194c5668414eb6099c0bde3ce657aeb57462c56f85809", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -140,7 +141,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": "e6b695720de7ae1761bd489b63ead5fe5ea2204d66aec74ada991c67d9ac66c3", + "labs/diagram-json/plan.lock.json": "2da633c72d7c84eca9b715261565954f437763cb6902165e4e1aca7633af297a", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -214,13 +215,14 @@ "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-publication-nonblocking-control.md": "2b9d8ea817896783273a843ec183fbf00bb2f7ac420b4ce3409aeda7f59b7fb5", "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": "c7d80b2f99481f8065a5fc5f60ee4d41958f5efa", + "commit": "df798dfd69a002bb8b9970216adf4f8afbe2b6ca", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/changelog.go b/boatstack/changelog.go index cff16fa..b75378f 100644 --- a/boatstack/changelog.go +++ b/boatstack/changelog.go @@ -133,8 +133,11 @@ func validateChangelogChange(repo, baseCommit string, config ProjectConfig) erro // changelogComparisonBase makes each managed slice prove its own entry. Later // slices compare with the previous slice's reviewed head, even when both slices -// use the same Git base and earlier Unreleased entries are still present. -func changelogComparisonBase(repo, feature, mergeBase string) (string, error) { +// use the same Git base and earlier Unreleased entries are still present. The +// comparison is anchored to the slice actually being gated or shipped (the +// addressable slice), not the BUILD pointer — a published-open earlier slice +// corrected in place compares against ITS predecessor, not the active slice's. +func changelogComparisonBase(repo, feature, sliceID, mergeBase string) (string, error) { if strings.TrimSpace(feature) == "" { return mergeBase, nil } @@ -142,16 +145,17 @@ func changelogComparisonBase(repo, feature, mergeBase string) (string, error) { if err != nil { return "", err } - if state.ActiveIndex == 0 { - return mergeBase, nil + index, _, err := resolveAddressableSlice(state, sliceID) + if err != nil { + return "", err } - if state.ActiveIndex >= len(state.Slices) { - return "", fmt.Errorf("delivery %s has no active slice for changelog comparison", feature) + if index <= 0 { + return mergeBase, nil } - previous := state.Slices[state.ActiveIndex-1] + previous := state.Slices[index-1] receipt, err := readDeliveryReceipt(repo, feature, previous.ID, "review") if err != nil { - return "", fmt.Errorf("cannot establish changelog baseline for delivery slice %s: %w", state.Slices[state.ActiveIndex].ID, err) + return "", fmt.Errorf("cannot establish changelog baseline for delivery slice %s: %w", state.Slices[index].ID, err) } if strings.TrimSpace(receipt.HeadCommit) == "" { return "", fmt.Errorf("previous delivery slice %s has no reviewed head commit", previous.ID) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index dd2cbbe..1f77bb0 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -401,7 +401,7 @@ func recordDeliveryGateCommand(arguments []string) int { options := boatstack.DeliveryGateOptions{} flags.StringVar(&options.Repo, "repo", ".", "repository containing the managed delivery") flags.StringVar(&options.Feature, "feature", "", "managed Boatstack feature slug") - flags.StringVar(&options.SliceID, "slice", "", "active delivery slice id") + flags.StringVar(&options.SliceID, "slice", "", "delivery slice id; redirects to the named active or published-open slice (default: active slice)") flags.StringVar(&options.Gate, "gate", "", "test or review") flags.StringVar(&options.Status, "status", "", "PASS or PASS_WITH_GAPS") flags.StringVar(&options.BaseBranch, "base", "", "delivery base branch; defaults from the active slice or project") @@ -971,7 +971,7 @@ func prContextCommand(arguments []string) int { flags := flag.NewFlagSet("pr-context", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose branch should be projected") feature := flags.String("feature", "", "managed Boatstack feature slug; omit for evidence-limited ad-hoc mode") - slice := flags.String("slice", "", "active managed delivery slice") + slice := flags.String("slice", "", "managed delivery slice; redirects to the named active or published-open slice (default: active slice)") base := flags.String("base", "", "base branch; defaults to the Boatstack project configuration") format := flags.String("format", "json", "json or template") if err := flags.Parse(arguments); err != nil { diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 3213b14..283d94a 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -23,6 +23,13 @@ type DeliverySlice struct { BaseBranch string `json:"base_branch,omitempty"` HeadBranch string `json:"head_branch,omitempty"` PRURL string `json:"pr_url,omitempty"` + // PRState caches the last observed lifecycle of this slice's pull request + // ("OPEN", "MERGED", or "CLOSED"). It is set to OPEN when the slice is first + // published and advanced to a terminal value only when an external + // observation (gh) confirms it. A published slice remains re-gateable and + // updatable in place while non-terminal; once terminal, in-place correction + // is refused and a corrective child delivery is the bounded forward actuator. + PRState string `json:"pr_state,omitempty"` } type DeliveryState struct { @@ -512,6 +519,66 @@ func activeDeliverySlice(state DeliveryState) (DeliverySlice, error) { return state.Slices[state.ActiveIndex], nil } +// isTerminalPRState reports whether a published slice's pull request has reached +// a state (merged or closed) that a corrective child delivery must address, +// rather than an in-place re-gate of the original slice. +func isTerminalPRState(prState string) bool { + switch strings.ToUpper(strings.TrimSpace(prState)) { + case "MERGED", "CLOSED", "PUBLISHED_MERGED", "PUBLISHED_CLOSED": + return true + } + return false +} + +// resolveAddressableSlice selects the slice a gate or ship operation may act on. +// +// The delivery keeps a single BUILD pointer (ActiveIndex) that advances on +// publication so the next slice can start building. Addressability, however, is +// broader than that pointer: a slice that has been PUBLISHED but whose PR is not +// yet terminal must remain re-gateable and updatable *in place*, because its +// postcondition (CI/merge) has not been observed. Conflating "which slice builds +// next" with "which slices may still be corrected" is what stranded a just +// published slice — publication advanced the pointer and thereby revoked the +// bounded correction actuator before the slice's postcondition was terminal. +// +// The addressable set is therefore {active slice} ∪ {PUBLISHED slices whose PR +// is not terminal}. Resolution is network-free: terminal-ness is read from the +// persisted PRState cache, never a live gh call. +func resolveAddressableSlice(state DeliveryState, sliceID string) (int, DeliverySlice, error) { + sliceID = strings.TrimSpace(sliceID) + if sliceID == "" { + if state.ActiveIndex >= len(state.Slices) { + return -1, DeliverySlice{}, fmt.Errorf("all delivery slices are already published") + } + return state.ActiveIndex, state.Slices[state.ActiveIndex], nil + } + activeID := "n/a" + if state.ActiveIndex < len(state.Slices) { + activeID = state.Slices[state.ActiveIndex].ID + } + for i, s := range state.Slices { + if s.ID != sliceID { + continue + } + if i == state.ActiveIndex { + return i, s, nil + } + // An earlier slice remains addressable while it has been published (PRState + // set on publication) but its PR is not yet terminal. PRState — not Status — + // is the correctability marker, because an in-place re-gate transitions the + // slice's Status back through TEST_PASSED/REVIEW_PASSED while it is still the + // same open PR being corrected. + if i < state.ActiveIndex && strings.TrimSpace(s.PRState) != "" { + if isTerminalPRState(s.PRState) { + return -1, DeliverySlice{}, fmt.Errorf("delivery slice %s has a %s pull request; draft a corrective child delivery instead of re-gating it in place", sliceID, strings.ToLower(strings.TrimPrefix(strings.ToUpper(s.PRState), "PUBLISHED_"))) + } + return i, s, nil + } + return -1, DeliverySlice{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", sliceID, activeID) + } + return -1, DeliverySlice{}, fmt.Errorf("delivery slice %s does not exist", sliceID) +} + func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error { lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json") lockHash, err := SHA256File(lockPath) @@ -660,13 +727,10 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if state.Mode == "AMENDMENT_REQUIRED" || state.Mode == "PLAN_INVALID" { return DeliveryGateReceipt{}, fmt.Errorf("delivery requires an approved plan amendment before gates may continue") } - slice, err := activeDeliverySlice(state) + sliceIndex, slice, err := resolveAddressableSlice(state, options.SliceID) if err != nil { return DeliveryGateReceipt{}, err } - if options.SliceID != "" && options.SliceID != slice.ID { - return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", options.SliceID, slice.ID) - } base := strings.TrimSpace(options.BaseBranch) if base == "" { base = slice.BaseBranch @@ -707,7 +771,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if mergeErr != nil || mergeBase == "" { return DeliveryGateReceipt{}, fmt.Errorf("cannot determine changelog diff against %s", base) } - changelogBase, changelogBaseErr := changelogComparisonBase(repo, options.Feature, mergeBase) + changelogBase, changelogBaseErr := changelogComparisonBase(repo, options.Feature, options.SliceID, mergeBase) if changelogBaseErr != nil { return DeliveryGateReceipt{}, changelogBaseErr } @@ -760,15 +824,15 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if err := atomicWriteMode(path, value, 0o644); err != nil { return DeliveryGateReceipt{}, err } - state.Slices[state.ActiveIndex].BaseBranch = base - state.Slices[state.ActiveIndex].HeadBranch = head + state.Slices[sliceIndex].BaseBranch = base + state.Slices[sliceIndex].HeadBranch = head if gate == "test" { - state.Slices[state.ActiveIndex].Status = "TEST_PASSED" + state.Slices[sliceIndex].Status = "TEST_PASSED" if reviewPath, pathErr := deliveryReceiptPath(repo, options.Feature, slice.ID, "review"); pathErr == nil { _ = os.Remove(reviewPath) } } else { - state.Slices[state.ActiveIndex].Status = "REVIEW_PASSED" + state.Slices[sliceIndex].Status = "REVIEW_PASSED" state.Mode = "NORMAL" state.ResumeStage = "" state.ActiveObservationID = "" @@ -779,7 +843,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error return receipt, nil } -func CheckDeliveryReadyForShip(repo, feature, base, head, diffHash string, changed []string) (DeliveryState, DeliverySlice, []PRSource, error) { +func CheckDeliveryReadyForShip(repo, feature, sliceID, base, head, diffHash string, changed []string) (DeliveryState, DeliverySlice, []PRSource, error) { state, err := LoadDeliveryState(repo, feature) if err != nil { return DeliveryState{}, DeliverySlice{}, nil, err @@ -790,11 +854,14 @@ func CheckDeliveryReadyForShip(repo, feature, base, head, diffHash string, chang if state.Mode != "" && state.Mode != "NORMAL" { return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("delivery has unresolved repair state %s", state.Mode) } - slice, err := activeDeliverySlice(state) + _, slice, err := resolveAddressableSlice(state, sliceID) if err != nil { return DeliveryState{}, DeliverySlice{}, nil, err } - if slice.Status != "REVIEW_PASSED" { + // A published-open slice that has been re-gated in place is REVIEW_PASSED again; + // an already-PUBLISHED slice that has not been re-gated is still shippable as an + // idempotent --action update of its open PR. + if slice.Status != "REVIEW_PASSED" && slice.Status != "PUBLISHED" { return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("delivery slice %s has not passed test and review gates", slice.ID) } if err := validateDeliveryScope(feature, slice, changed); err != nil { @@ -831,22 +898,40 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { if err := checkDeliveryPlanLock(repo, feature, state); err != nil { return err } - slice, err := activeDeliverySlice(state) + sliceIndex, slice, err := resolveAddressableSlice(state, sliceID) if err != nil { return err } - if slice.ID != sliceID || slice.Status != "REVIEW_PASSED" { + if slice.ID != sliceID { return fmt.Errorf("delivery slice %s is not ready to publish", sliceID) } - state.Slices[state.ActiveIndex].Status = "PUBLISHED" - state.Slices[state.ActiveIndex].PRURL = url - state.ActiveIndex++ - if state.ActiveIndex < len(state.Slices) { - state.Slices[state.ActiveIndex].Status = "BUILD" - state.RepairAttempt = 0 - state.ActiveObservationID = "" - state.ResumeStage = "" - state.Mode = "NORMAL" + // Re-publishing an already-PUBLISHED, non-terminal slice is an idempotent + // --action update of its still-open PR: refresh the recorded PR URL and keep + // PRState OPEN, but do NOT advance the BUILD pointer a second time. + if slice.Status == "PUBLISHED" { + state.Slices[sliceIndex].PRURL = url + if strings.TrimSpace(state.Slices[sliceIndex].PRState) == "" { + state.Slices[sliceIndex].PRState = "OPEN" + } + return saveDeliveryState(repo, state) + } + if slice.Status != "REVIEW_PASSED" { + return fmt.Errorf("delivery slice %s is not ready to publish", sliceID) + } + state.Slices[sliceIndex].Status = "PUBLISHED" + state.Slices[sliceIndex].PRURL = url + state.Slices[sliceIndex].PRState = "OPEN" + // Only a first publication of the active slice advances the BUILD pointer to + // the next slice; a re-publication of an earlier published-open slice does not. + if sliceIndex == state.ActiveIndex { + state.ActiveIndex++ + if state.ActiveIndex < len(state.Slices) { + state.Slices[state.ActiveIndex].Status = "BUILD" + state.RepairAttempt = 0 + state.ActiveObservationID = "" + state.ResumeStage = "" + state.Mode = "NORMAL" + } } return saveDeliveryState(repo, state) } diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go index 6dac3a9..51692ca 100644 --- a/boatstack/delivery_test.go +++ b/boatstack/delivery_test.go @@ -223,7 +223,7 @@ func TestManagedReviewRequiresChangelogEntryAndBindsItToTestEvidence(t *testing. if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil { t.Fatal(err) } - base, err := changelogComparisonBase(repo, feature, runGit(t, repo, "merge-base", "main", "HEAD")) + base, err := changelogComparisonBase(repo, feature, "", runGit(t, repo, "merge-base", "main", "HEAD")) if err != nil { t.Fatal(err) } @@ -275,8 +275,22 @@ func TestDeliveryGateReceiptsBindTheActiveSliceAndAdvanceOnce(t *testing.T) { if err != nil || state.ActiveIndex != 1 { t.Fatalf("rerunning build reset delivery progress: state=%#v err=%v", state, err) } - if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "current slice is phase-two") { - t.Fatalf("prior slice receipt reused after publication: %v", err) + // A published slice whose PR is still open (PRState defaults to OPEN) remains + // re-gateable in place: publication advanced the BUILD pointer to phase-two but + // did not revoke phase-one's correction actuator. Re-gating must NOT double + // advance ActiveIndex. + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil { + t.Fatalf("published-open slice was not re-gateable in place: %v", err) + } + state, err = LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + if state.ActiveIndex != 1 { + t.Fatalf("re-gating a published-open slice advanced the BUILD pointer: %#v", state) + } + if state.Slices[0].Status != "TEST_PASSED" { + t.Fatalf("re-gate did not transition the published slice: %#v", state.Slices[0]) } } diff --git a/boatstack/next.go b/boatstack/next.go index 0d287ca..8d34ab3 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -138,6 +138,7 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { func nextForPublished(repo string, state DeliveryState) NextStatus { pr := observePublishedPR(repo, state) + persistObservedTerminalPRState(repo, state, pr) _, sliceID, _ := deliveryBranchAndSlice(state) status := NextStatus{ SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", diff --git a/boatstack/pr.go b/boatstack/pr.go index bb96c42..3a38c8f 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -561,7 +561,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if len(changed) == 0 { return PRContext{}, fmt.Errorf("branch has no committed product changes relative to %s", base) } - changelogBase, err := changelogComparisonBase(repo, options.Feature, mergeBaseCommit) + changelogBase, err := changelogComparisonBase(repo, options.Feature, options.SliceID, mergeBaseCommit) if err != nil { return PRContext{}, err } @@ -603,15 +603,21 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { } sources = append(sources, managedSources...) gateStatus = statuses - state, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, base, head, SHA256Bytes(diff), changed) + state, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, options.SliceID, base, head, SHA256Bytes(diff), changed) if deliveryErr != nil { return PRContext{}, deliveryErr } - if options.SliceID != "" && options.SliceID != slice.ID { - return PRContext{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", options.SliceID, slice.ID) - } + // The addressable slice may be the active slice or an earlier published-open + // slice being corrected in place; report its own 1-based position, not the + // active pointer's. sliceID = slice.ID - sliceIndex = state.ActiveIndex + 1 + sliceIndex = 1 + for i, s := range state.Slices { + if s.ID == slice.ID { + sliceIndex = i + 1 + break + } + } totalSlices = len(state.Slices) sources = append(sources, gateSources...) } diff --git a/boatstack/recovery.go b/boatstack/recovery.go index f8554e3..5922d23 100644 --- a/boatstack/recovery.go +++ b/boatstack/recovery.go @@ -195,6 +195,38 @@ func observePublishedPR(repo string, state DeliveryState) publishedPRObservation return observation } +// persistObservedTerminalPRState caches a terminal (MERGED/CLOSED) PR lifecycle +// on the specific published slice it belongs to. This is a bounded, best-effort +// write of an already-observed external fact — not a workflow mutation — so the +// network-free gate resolver (resolveAddressableSlice) can later refuse in-place +// correction of a slice whose PR has closed and route it to a corrective child +// instead. Non-terminal lifecycles are left as the OPEN default so the slice +// stays re-gateable in place. Failures are swallowed: the observation is +// authoritative regardless of whether the cache write succeeds. +func persistObservedTerminalPRState(repo string, state DeliveryState, observation publishedPRObservation) { + if !isTerminalPRState(observation.Lifecycle) { + return + } + url := strings.TrimSpace(observation.URL) + branch := strings.TrimSpace(observation.Branch) + for i, s := range state.Slices { + if s.Status != "PUBLISHED" { + continue + } + matches := (url != "" && strings.TrimSpace(s.PRURL) == url) || + (branch != "" && strings.TrimSpace(s.HeadBranch) == branch) + if !matches { + continue + } + if strings.EqualFold(strings.TrimSpace(s.PRState), observation.Lifecycle) { + return + } + state.Slices[i].PRState = observation.Lifecycle + _ = saveDeliveryState(repo, state) + return + } +} + func suggestedCorrectionFeature(states []DeliveryState, parent string) string { used := map[int]bool{} prefix := parent + "-correction-" @@ -329,6 +361,7 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { return status, nil } pr := observePublishedPR(repo, selected) + persistObservedTerminalPRState(repo, selected, pr) status.Lifecycle = pr.Lifecycle status.PRURL = pr.URL status.ObservedPRHeadSHA = pr.HeadSHA diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 1e1ce1d..57acd6e 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 | +| 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 | ## Lessons encoded from the benchmark campaign diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 580918a..0b53069 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -379,6 +379,16 @@ active delivery slice. Successful publication marks only that slice `PUBLISHED` activates the next slice as `BUILD`. No parent-plan approval, prior phase receipt, or context summary can skip these transitions. +Advancing the `BUILD` pointer does not revoke correctability of the slice just +published. While its PR is not terminal (not merged or closed), a `PUBLISHED` slice +**remains re-gateable and updatable in place**: `record-delivery-gate --slice ` +and `pr-context --slice ` redirect to that slice, and `publish-pr --action update` +re-targets its still-open PR without advancing the pointer a second time. Correctability +ends only when the PR reaches a terminal state, observed by the recovery/next resolver +and cached on the slice; from there correction routes to a corrective child delivery +(see "A published delivery cannot be reset"). This keeps multi-slice deliveries flowing +while never stranding a slice whose postcondition has not yet been observed. + Opening or updating a PR does not authorize merge or deployment. After successful publication only, the publisher may use the ignored 24-hour release cache to report an available stable Boatstack version. The primary response and next action remain **PR opened -> Review the PR**. Put the maintenance notice in collapsed details, state that no files changed, and direct the user to run `/boatstack-update` from the clean default branch after the feature PR merges. Suppress repeated notices for seven days unless a different release appears. Release lookup failure never changes the ship result. diff --git a/boatstack/supervisory_control_test.go b/boatstack/supervisory_control_test.go new file mode 100644 index 0000000..2d79d91 --- /dev/null +++ b/boatstack/supervisory_control_test.go @@ -0,0 +1,238 @@ +package boatstack + +import ( + "strings" + "testing" +) + +// TestSupervisoryControlNeverDeadlocks is the conformance model for the +// nonblocking-supervisory-control invariant: +// +// Every durable supervisory transition that removes an actuator must leave a +// bounded actuator reachable in the resulting state that reaches the next valid +// state (or reverses the transition). +// +// The canonical violation it guards against is a durable pointer/state that +// advances on request-success and thereby revokes the correction actuator for a +// target whose postcondition has not yet been observed — exactly the delivery +// publication bug that stranded a just-published slice. Each subtest names the +// property it asserts and drives the exported delivery/recovery functions +// directly, in the black-box style of TestMutationBoundaryPreservesALegalTrajectory. +func TestSupervisoryControlNeverDeadlocks(t *testing.T) { + // Property: publication advances the BUILD pointer to the next slice, but the + // published slice remains re-gateable in place because its PR postcondition is + // not yet terminal. Advancing "which slice builds next" must not revoke "which + // slices may still be corrected". + t.Run("published-open slice stays correctable after the pointer advances", func(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + gateSlice(t, repo, feature, "phase-one") + if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil { + t.Fatalf("publish phase-one: %v", err) + } + + state := loadDelivery(t, repo, feature) + if state.ActiveIndex != 1 || state.Slices[1].Status != "BUILD" { + t.Fatalf("publication did not advance the BUILD pointer: %#v", state) + } + if state.Slices[0].Status != "PUBLISHED" || state.Slices[0].PRState != "OPEN" { + t.Fatalf("published slice did not record an open PR: %#v", state.Slices[0]) + } + + // The bounded correction actuator is still reachable: re-gate phase-one in + // place through BOTH gates (its changelog baseline anchors to phase-one, not + // the active slice), and the BUILD pointer must not move. + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil { + t.Fatalf("published-open slice was not re-gateable (test) in place: %v", err) + } + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"}); err != nil { + t.Fatalf("published-open slice was not re-gateable (review) in place: %v", err) + } + got := loadDelivery(t, repo, feature) + if got.ActiveIndex != 1 { + t.Fatalf("re-gating a published-open slice advanced the pointer: %#v", got) + } + if got.Slices[0].Status != "REVIEW_PASSED" || got.Slices[0].PRState != "OPEN" { + t.Fatalf("in-place re-gate did not restore the corrected slice: %#v", got.Slices[0]) + } + }) + + // Property: re-publishing a published-open slice (an --action update of its PR) + // is idempotent on the BUILD pointer — the advance happens once, on the first + // REVIEW_PASSED -> PUBLISHED transition, never again. + t.Run("re-publication does not double-advance the pointer", func(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + gateSlice(t, repo, feature, "phase-one") + if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil { + t.Fatalf("publish phase-one: %v", err) + } + // Re-publishing the same already-PUBLISHED, open slice is the --action update + // path (e.g. after a corrective in-place change): it refreshes the recorded + // PR URL but must not advance the BUILD pointer a second time. + if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1?rev=2"); err != nil { + t.Fatalf("re-publish phase-one: %v", err) + } + state := loadDelivery(t, repo, feature) + if state.ActiveIndex != 1 { + t.Fatalf("re-publication double-advanced the pointer: ActiveIndex=%d", state.ActiveIndex) + } + if state.Slices[0].PRURL != "https://example.invalid/pr/1?rev=2" { + t.Fatalf("re-publication did not refresh the PR URL: %#v", state.Slices[0]) + } + if state.Slices[1].Status != "BUILD" { + t.Fatalf("re-publication disturbed the active slice: %#v", state.Slices[1]) + } + }) + + // Property: once a published slice's PR reaches a terminal state (merged/closed) + // the in-place actuator is correctly removed, but a bounded FORWARD actuator — + // the corrective child delivery — remains reachable. Removal of one actuator is + // only legal because it exposes another; a terminal PR is never a deadlock. + t.Run("terminal PR refuses in-place correction but offers a corrective child", func(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + gateSlice(t, repo, feature, "phase-one") + if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil { + t.Fatalf("publish phase-one: %v", err) + } + // Drive the delivery to fully published directly: phase-two's real gate flow + // needs per-slice committed diffs this conformance property does not exercise. + state := loadDelivery(t, repo, feature) + state.ActiveIndex = 2 + state.Slices[1].Status = "PUBLISHED" + state.Slices[1].PRState = "OPEN" + state.Slices[1].PRURL = "https://example.invalid/pr/2" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatalf("persist fully-published state: %v", err) + } + + // The whole delivery is published; simulate the merge observation the + // recovery/next resolver would make against GitHub. + restore := stubRecoveryGh(t, "MERGED", "https://example.invalid/pr/2", branchForFeature(feature)) + defer restore() + + status, err := ResolveRecovery(RecoveryStatusOptions{ + Repo: repo, Feature: feature, + Message: "required checks failed after merge", SourceStage: "ci", + }) + if err != nil { + t.Fatalf("resolve recovery: %v", err) + } + if status.NextOperation != "draft_corrective_child" { + t.Fatalf("terminal PR did not route to the bounded forward actuator: %#v", status) + } + + // The terminal observation is now cached on the slice, so the network-free + // gate resolver refuses an in-place re-gate and points at the corrective child. + reloaded := loadDelivery(t, repo, feature) + if _, _, err := resolveAddressableSlice(reloaded, "phase-two"); err == nil || !strings.Contains(err.Error(), "corrective child") { + t.Fatalf("terminal slice was still addressable in place: %v", err) + } + }) + + // Property: the addressable-slice resolver both redirects (pr-context/gate to a + // named published-open slice) and guards (refuses future, terminal, or unknown + // slices with a message that names the correct actuator). This is the shared + // lookup behind record-delivery-gate --slice and pr-context --slice. + t.Run("addressable resolver redirects to correctable slices and guards the rest", func(t *testing.T) { + state := DeliveryState{ + ActiveIndex: 1, + Slices: []DeliverySlice{ + {ID: "phase-one", Status: "PUBLISHED", PRState: "OPEN"}, + {ID: "phase-two", Status: "BUILD"}, + {ID: "phase-three", Status: "PENDING"}, + }, + } + cases := []struct { + name string + sliceID string + wantIndex int + wantErr string + }{ + {"empty resolves to the active slice", "", 1, ""}, + {"active slice by name", "phase-two", 1, ""}, + {"published-open slice redirects in place", "phase-one", 0, ""}, + {"future slice is refused", "phase-three", -1, "not active"}, + {"unknown slice is refused", "phase-nine", -1, "does not exist"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + index, slice, err := resolveAddressableSlice(state, tc.sliceID) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("want error containing %q, got %v", tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if index != tc.wantIndex || slice.ID != state.Slices[tc.wantIndex].ID { + t.Fatalf("resolved index=%d slice=%s, want index=%d", index, slice.ID, tc.wantIndex) + } + }) + } + + // A terminal published slice is refused in place and routed to a corrective child. + terminal := DeliveryState{ + ActiveIndex: 1, + Slices: []DeliverySlice{ + {ID: "phase-one", Status: "PUBLISHED", PRState: "MERGED"}, + {ID: "phase-two", Status: "BUILD"}, + }, + } + if _, _, err := resolveAddressableSlice(terminal, "phase-one"); err == nil || !strings.Contains(err.Error(), "corrective child") { + t.Fatalf("terminal published slice was addressable in place: %v", err) + } + }) + + // Property: the safety guard that removes ordinary command authority at + // exceptional stages still admits a bounded recovery verb at EVERY stage, so no + // stage transition is a deadlock. (Reinforces + // TestControlledPhaseTransitionAllowsBoundedRecoveryVerbs as an invariant, not + // an enumerated fixture.) + t.Run("a bounded recovery verb is admitted at every stage", func(t *testing.T) { + stages := []string{"BUILD", "DELIVERY", "SHIP_GATE", "PR_OPEN", "INVALID_STATE", "PUBLISHED", ""} + for _, stage := range stages { + admitted := controlledPhaseTransition("boatstack-helper repair-state --repo .", stage) || + controlledPhaseTransition("boatstack-helper undo --repo . --mutation abc", stage) + if !admitted { + t.Fatalf("stage %q admitted no bounded recovery verb — a deadlock", stage) + } + } + }) +} + +func gateSlice(t *testing.T, repo, feature, sliceID string) { + t.Helper() + for _, gate := range []string{"test", "review"} { + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: sliceID, Gate: gate, Status: "PASS"}); err != nil { + t.Fatalf("record %s gate for %s: %v", gate, sliceID, err) + } + } +} + +func loadDelivery(t *testing.T, repo, feature string) DeliveryState { + t.Helper() + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatalf("load delivery state: %v", err) + } + return state +} + +// stubRecoveryGh replaces the package gh shim with a fixed `gh pr view` +// observation so recovery/next resolution stays network-free and deterministic +// in tests. It returns a restore function. +func stubRecoveryGh(t *testing.T, state, url, branch string) func() { + t.Helper() + previous := recoveryGh + recoveryGh = func(repo string, arguments ...string) (string, error) { + payload, err := MarshalJSON(map[string]any{ + "state": state, "headRefName": branch, "headRefOid": "deadbeef", "url": url, + }) + if err != nil { + return "", err + } + return string(payload), nil + } + return func() { recoveryGh = previous } +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index a0119a6..78b549e 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 **16407 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 **16784 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 [`c7d80b2f99481f8065a5fc5f60ee4d41958f5efa`](https://github.com/operatorstack/intelligence-flow/tree/c7d80b2f99481f8065a5fc5f60ee4d41958f5efa/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 [`df798dfd69a002bb8b9970216adf4f8afbe2b6ca`](https://github.com/operatorstack/intelligence-flow/tree/df798dfd69a002bb8b9970216adf4f8afbe2b6ca/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 0a1cfb5..3f360c9 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "c7d80b2f99481f8065a5fc5f60ee4d41958f5efa", + "source_commit": "df798dfd69a002bb8b9970216adf4f8afbe2b6ca", "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" }, { "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:c7d80b2f99481f8065a5fc5f60ee4d41958f5efa" + "last_verified_version": "source:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 8036d5b..211e1c4 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": "c7d80b2f99481f8065a5fc5f60ee4d41958f5efa", + "source_commit": "df798dfd69a002bb8b9970216adf4f8afbe2b6ca", "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-publication-nonblocking-control.md b/release-notes/2026-07-24-publication-nonblocking-control.md new file mode 100644 index 0000000..3df1a0d --- /dev/null +++ b/release-notes/2026-07-24-publication-nonblocking-control.md @@ -0,0 +1,12 @@ +### Publishing a delivery slice no longer strands it — a published-open PR stays correctable in place + +Multi-slice managed deliveries build sequentially, so publishing one slice's PR intentionally advances the delivery's `BUILD` pointer to the next slice. But that same pointer also decided *which slices may still be corrected*, and it advanced the instant `gh pr create/edit` returned — before the published slice's CI, review, or merge was ever observed. Once it moved, the just-published slice became unreachable: `record-delivery-gate --slice ` refused it as "not active", `pr-context --slice ` only relabelled the active slice instead of redirecting, and a corrective push landed as `relation=unrelated`. A slice whose PR was still **open** and simply needed its CI fixed had no bounded way back — a supervisory deadlock. The only sanctioned recovery, a corrective child delivery, is right for a *merged or closed* PR but wrong for one still open. + +The fix separates *which slice builds next* (rightly advances) from *which slices may still be corrected* (must not be revoked before the postcondition is terminal). The addressable set is now **{active slice} ∪ {published slices whose PR is not terminal}**. Publication still advances the `BUILD` pointer, but a `PUBLISHED` slice remains re-gateable and re-publishable **in place** until its PR is terminal: + +- `record-delivery-gate --slice ` and `pr-context --slice ` now **redirect** the gate/ship lookup to the named active-or-published-open slice instead of rejecting it. Resolution is network-free — addressability is read from persisted delivery state, never a live `gh` call. +- Re-publishing a published-open slice is an idempotent `--action update` of its still-open PR: it refreshes the recorded PR URL without advancing the `BUILD` pointer a second time. Only the first `REVIEW_PASSED → PUBLISHED` transition advances. +- Each slice carries a `pr_state` marker. It is `OPEN` on first publication and is advanced to a terminal value (`MERGED`/`CLOSED`) only when the recovery/next resolver's existing `gh pr view` observation confirms it — a bounded, best-effort cache of an observed external fact. A terminal slice refuses in-place correction and routes to the corrective-child path, the bounded **forward** actuator. A published delivery still cannot be reset. +- The per-slice changelog baseline now anchors to the slice actually being gated or shipped, not the `BUILD` pointer, so an in-place re-gate of an earlier published-open slice compares against *its* predecessor. + +This is a standing instance of the recovery principle behind `repair-state` and the mutation boundary: a supervisor that removes an actuator must still expose a bounded actuator that reaches every valid next state — including reversing its own last move. A new `nonblocking-supervisory-control` conformance suite encodes that invariant as named-property tests so the failure class — advancing a durable supervisory pointer past an unobserved postcondition — cannot silently reappear, and the failure-move catalog records it as **Premature supervisory pointer advance**.