From d3fe686e7b9214ea88190b6c0b55489992689471 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Tue, 28 Jul 2026 17:08:20 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ c550ef95f440 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 25 ++- boatstack/delivery.go | 23 ++ boatstack/flow_control.go | 10 +- boatstack/flow_frontier.go | 9 +- boatstack/flow_watch.go | 1 + boatstack/goal_escape.go | 100 +++++++++ boatstack/goal_escape_conformance_test.go | 199 ++++++++++++++++++ .../internal/deliverycontrol/registry.go | 2 +- boatstack/next.go | 23 +- docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 +-- labs/diagram-json/plan.lock.json | 2 +- .../2026-07-28-bounded-merge-pursuit.md | 5 + 14 files changed, 395 insertions(+), 32 deletions(-) create mode 100644 boatstack/goal_escape.go create mode 100644 boatstack/goal_escape_conformance_test.go create mode 100644 release-notes/2026-07-28-bounded-merge-pursuit.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d70eede..6250f19 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/cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56/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/c550ef95f440e8b463ae0d436953e10ad91e1ab6/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 ea80b3b..55ca006 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "be449f84d39fec8cd745cf5613ede48abcc853ca2ec20fff9951ef0a0a707e90", + "CONTRIBUTING.md": "370fe1191864a3a3b4acd5ca4bc22807f70da794743f376f992a140c534e0619", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -54,7 +54,7 @@ "boatstack/context.go": "02510af176d2d040c0080086f06d1235e76a1d47fef5176f96f740ad18d27660", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "a9a266d0413b25276d9bcfeb8328ac3ca4b3020ada1f97912251f68b4a9b5019", + "boatstack/delivery.go": "f26db7386b94b551229fb90c17dfb1cd6967d3693155495387aaed299cb8ff88", "boatstack/delivery_boundary_conformance_test.go": "53dde765046420b9119e82034d137742e600019938ed908c608f725d8a0c84c6", "boatstack/delivery_migrate.go": "7566e49f9c1838d4d563866e941c7aacd61ac918c9e886222282398d287ca780", "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", @@ -77,11 +77,11 @@ "boatstack/export_test.go": "dce5aa3ab5499c82d05859cf86b46dfcee308482491366d83e10ca3fb8605bb6", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", - "boatstack/flow_control.go": "105556b619c83f5f68fe4d98cd4763745fa0dafadf4ba6569a6bab31896e58a4", + "boatstack/flow_control.go": "3105375ee3ca0e100aa61c5ccd74b7f49ae4aa73385e32c7f17b15e6dcc7f3fe", "boatstack/flow_control_test.go": "02d788c83be55ebd79ffc73875bfd019de45325151eb1f70f506980eb8e77f29", "boatstack/flow_drive.go": "90f57e178884aff017195a126954ac0aeb85f27707b9d42844323341dc1fefd9", "boatstack/flow_drive_conformance_test.go": "23edea926c271a1f5718fb9dae1da11e4bf03cceb1357290cd61cd8ffb73beda", - "boatstack/flow_frontier.go": "64304ff8aa7f662d166ac3ae9d54cfd32caf35458101c3019cedd8b8326ca5b4", + "boatstack/flow_frontier.go": "57e860c1613f3d5cca2fde56fb6e7dad527a71ac148ee3617ceb4bccd9cf805a", "boatstack/flow_frontier_conformance_test.go": "771838f06d6157547c1277eca1f7c1df609bb9f3ae630b2fa509f641b49aba86", "boatstack/flow_guard.go": "dd18524d95f4a220cfd3d11b11003dacc52120785ee0ccdbeceb2307fab55872", "boatstack/flow_guard_test.go": "8ba75f11ddd080427c15bd7e25f7d03c1b746a2f587c212cea0e710337d1c0e1", @@ -94,10 +94,12 @@ "boatstack/flow_tasks_conformance_test.go": "fbc4d672536051f8e20a2e6c07c5b10f78cd84fc04765eee11cbed7a459b8e4b", "boatstack/flow_trace.go": "1a69f9dd53db313235c74f7ae4f821a6aed023f780aea57210c721ea8f11f2fc", "boatstack/flow_trace_test.go": "99f89a831e904f6a8ef710b6977ed3a808ce1c7ddfaba457b292d84f2ddca51b", - "boatstack/flow_watch.go": "3baca52f0e2a0e4e4ad90f5f6f30369fe28f382efbdf0e7f2153dafba2538051", + "boatstack/flow_watch.go": "3bf029c8a700636256f698dabd79c9ea20f89891d940e4698ea43e51522b8c41", "boatstack/flow_watch_conformance_test.go": "aba71d94844a23bbd0be4bede038e006ae5f2eaaf5df662b8eb96fcc0572461b", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", + "boatstack/goal_escape.go": "394b9496f01d91c8158a9bdad235081363023b92ee90c1ca0e1f683a108e0ed3", + "boatstack/goal_escape_conformance_test.go": "ec340660251c892fc6b7b7f813e6b3fe321b0429f094ba9110a26a205cd1d554", "boatstack/hooks.go": "a3881c69dd88025c914ebaaa43362a2b1c47565f0ec16074e078d16cb6cfb853", "boatstack/hooks_hydrate_test.go": "7beeb26b2b1398741e8a28963a9686e974047016cc736f233024004add1afc32", "boatstack/hooks_test.go": "fb75e3aabf2204871b3e6d16de98d26fb33b0ec19e41aae761cf1f34397c31f4", @@ -119,7 +121,7 @@ "boatstack/internal/deliverycontrol/liveness_test.go": "7a148075d9d2c5df468fecb710bdd38226c4cd4b37584a810b6848909a0f3292", "boatstack/internal/deliverycontrol/oracle.go": "80765b1946d6c863f0e635a99b68d3ccafa7ff235360fba774811b5b0de791da", "boatstack/internal/deliverycontrol/oracle_test.go": "ce320a71f0c9440c5a7bc1b742d0f74c6a46759845e919ab36bde5ff8fabb311", - "boatstack/internal/deliverycontrol/registry.go": "d77c9ceea1feb576b857c3d1f4fc517afbf3f38cb43525a8a139206a6802980d", + "boatstack/internal/deliverycontrol/registry.go": "0c02a146b5590dad0e81a069717363a0d7ee9b4627d350fbe4656d191cb27219", "boatstack/internal/deliverycontrol/registry_test.go": "473ab5e5d33f84d34c29a219db867abfc6eb3ad4489f3d5d0c7dc09b06d193f3", "boatstack/internal/deliverycontrol/state.go": "2551624bbcbd8f9dd897a1e2240cef2cc1895d117a4030525d88f1d62f6e395e", "boatstack/internal/deliverycontrol/trajectory.go": "4469a0c35b40f8e2a37060e9b26fcbda7d34d020088c31de367dd5cf6e7721dc", @@ -135,7 +137,7 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "e45b5e573616e6ac7616503d5b9b50ad6f9f9ee51b57f6bc90436b3375c5f338", + "boatstack/next.go": "d66e9303c52cac43a1ecc928b64eeedb5a596de271f0f214084b92a270b53dcc", "boatstack/next_actor_conformance_test.go": "23c055bcc99d889344c3f86c7940eb9ef2ef6f4ddab34e91cf215c9d078f5d42", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", "boatstack/next_response.go": "11decf2e3b236cbaa183980946ec17ffbbbb1af9c08bd11a466a8487bf229d5f", @@ -213,10 +215,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "221f979506a3a9de357e5277f1329c345bf175346ec8dfc8fdd1212fb100dea1", - "docs/evidence-engineered-coding.md": "1abb6b48dfa4bd0f8f4d736b3af9a757c20af6890bbc9b308510bb426f9af98a", + "docs/evidence-engineered-coding.md": "42c0efc8f5947b4b21f7f99ea8b86a303025bf3760b8fe17caf07ed47acfe609", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "b1a5d44604a96fa5bc5e1d465c2091261e1aa2d7f32156b33df3c4a58f60effc", + "docs/public-claims.json": "b47e678c1a830a0bd6ceea4fdc3fae7b55794099770df61f6819b9802212f561", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -230,7 +232,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": "01abc3857b88e21028c0ca68d4769369a0141830f5377cbf87041dee6bf8cb22", + "labs/diagram-json/plan.lock.json": "77ff19d1baf7a9872a2e0e052a1b02fdc804607eff4106bc677cf70c995e970e", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -359,6 +361,7 @@ "release-notes/2026-07-27-repeated-denials-escalate.md": "ee54b597e593ce74d8d9acbc67c74d2d8cb972ff206f618e08a047d4d962d7af", "release-notes/2026-07-27-sandboxed-migration-grading.md": "03cebc372bbdfed37cc70d18f3b6374d1aa5e585bafefa073dbcced58bd0336a", "release-notes/2026-07-27-state-ownership-map.md": "d032547aafc1a4acbeb520f6cbb59d7757de4f33fe824701d7b5ea8cd8c8b9e7", + "release-notes/2026-07-28-bounded-merge-pursuit.md": "a1f124b7cd5b4c495786d5bc1734f4477fa7ae5d9f992bb4ebc9e7e087e8b4f3", "release-notes/2026-07-28-configurable-delivery-terminal.md": "54f42d8ee04f16429f7f6db484c12247a75dd365d9028ec6c24b2efb92f9e700", "release-notes/2026-07-28-flow-frontier-dashboard.md": "9a768e0fd67f122bc0aef99899314f0a8530189164fb560b536ee03f20e29081", "release-notes/2026-07-28-flow-watch-loop.md": "54833887e733c65626c3bb7f0e6430eca8028f0fe19aa2cd31cdbdcc6ff4d8ba", @@ -372,7 +375,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56", + "commit": "c550ef95f440e8b463ae0d436953e10ad91e1ab6", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/delivery.go b/boatstack/delivery.go index dde618d..7c2ca6d 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -51,6 +51,14 @@ type DeliverySlice struct { // 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"` + // PostPublishFixAttempts counts the post-publish correction cycles + // recorded against this published slice, and GoalEscape caches a fired + // merged-goal demotion (sticky offline until the next recorded correction + // clears it). Both are written only under delivery.terminal "merged"; a + // default-terminal state file never carries them. + // control-law: goal-escape-demotes-to-operator-and-stops + PostPublishFixAttempts int `json:"post_publish_fix_attempts,omitempty"` + GoalEscape string `json:"goal_escape,omitempty"` } type DeliveryState struct { @@ -654,7 +662,19 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio if err := appendChangeObservation(repo, observation); err != nil { return ChangeObservation{}, DeliveryState{}, err } + // Under the merged terminal, a recorded post-publish correction advances + // the targeted published slice's fix-cycle bookkeeping (and, after an + // escape, is the operator's explicit reset for a fresh cycle). The + // published default records nothing — its state files stay byte-stable. + // control-law: goal-escape-demotes-to-operator-and-stops + trackPostPublishCycle := resolveDeliveryTerminal(repo, options.Feature) == TerminalMerged if published { + if trackPostPublishCycle && len(state.Slices) > 0 { + bumpPostPublishFixCycle(&state.Slices[len(state.Slices)-1]) + if err := saveDeliveryState(repo, state); err != nil { + return ChangeObservation{}, DeliveryState{}, err + } + } return observation, state, nil } if publishedOpen { @@ -683,6 +703,9 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio } else { slice.Status = StatusBuild } + if trackPostPublishCycle { + bumpPostPublishFixCycle(slice) + } if err := saveDeliveryState(repo, state); err != nil { return ChangeObservation{}, DeliveryState{}, err } diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 0f1093a..254d908 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -166,7 +166,10 @@ func classifyNextActor(status NextStatus, next FlowNext) NextActor { // unknown position — stays the operator's. Fail-closed: the zero // Terminal behaves as published. // control-law: turn-ends-only-at-the-operator-frontier - if next.Terminal == TerminalMerged { + // A fired goal escape demotes unconditionally: the pursuit contract + // ended, so no phase can hand the step back to the agent. + // control-law: goal-escape-demotes-to-operator-and-stops + if next.Terminal == TerminalMerged && status.GoalEscape == "" { switch PRPhase(status.PRPhase) { case PRPhaseChecksPending, PRPhaseChecksFailing, PRPhaseMergeEligible: return NextActorAgent @@ -457,6 +460,11 @@ func prescribePostPublish(repo string, status NextStatus, terminal DeliveryTermi if terminal != TerminalMerged || status.ObservedStage != "PUBLISHED" || status.Lifecycle == "PUBLISHED_MERGED" { return nil, "" } + // A fired escape prescribes nothing: demote-and-stop, never + // demote-and-suggest. control-law: goal-escape-demotes-to-operator-and-stops + if status.GoalEscape != "" { + return nil, "" + } var repoArgs []string if repo != "" && repo != "." { repoArgs = []string{"--repo", repo} diff --git a/boatstack/flow_frontier.go b/boatstack/flow_frontier.go index 6acbe7c..84f9b87 100644 --- a/boatstack/flow_frontier.go +++ b/boatstack/flow_frontier.go @@ -24,6 +24,7 @@ type FrontierRow struct { TotalSlices int `json:"total_slices,omitempty"` Stage string `json:"stage"` Lifecycle string `json:"lifecycle,omitempty"` + GoalEscape string `json:"goal_escape,omitempty"` PRPhase string `json:"pr_phase,omitempty"` PRFailingChecks []string `json:"pr_failing_checks,omitempty"` PRURL string `json:"pr_url,omitempty"` @@ -85,7 +86,7 @@ func ResolveFrontier(repoPath string) (FlowFrontier, error) { continue } branch, _, prURL := deliveryBranchAndSlice(state) - status := publishedNextStatus(state, observePRTarget(repo, prURL, branch)) + status := publishedNextStatus(state, observePRTarget(repo, prURL, branch), resolveDeliveryTerminal(repo, state.Feature)) frontier.Rows = append(frontier.Rows, frontierRowFromStatus(repo, status)) } for _, row := range frontier.Rows { @@ -139,6 +140,9 @@ func activeDeliveryRows(repo string, state DeliveryState) []FrontierRow { PRMergeState: observation.MergeState, PRFailingChecks: observation.FailingChecks, Reason: fmt.Sprintf("Slice %q is published with an open pull request while a later slice is active.", slice.ID), } + if resolveDeliveryTerminal(repo, state.Feature) == TerminalMerged && observation.Lifecycle != "PUBLISHED_MERGED" { + sliceStatus.GoalEscape = evaluateGoalEscape(slice, observation) + } rows = append(rows, frontierRowFromStatus(repo, sliceStatus)) } return rows @@ -153,7 +157,8 @@ func frontierRowFromStatus(repo string, status NextStatus) FrontierRow { Feature: status.Feature, Slice: status.ActiveSlice, SliceIndex: status.SliceIndex, TotalSlices: status.TotalSlices, Stage: status.ObservedStage, Lifecycle: status.Lifecycle, - PRPhase: status.PRPhase, PRFailingChecks: status.PRFailingChecks, + GoalEscape: status.GoalEscape, + PRPhase: status.PRPhase, PRFailingChecks: status.PRFailingChecks, PRURL: status.PRURL, NextOperation: status.NextOperation, Reason: status.Reason, Blocked: status.VerificationStatus == "BLOCKED", diff --git a/boatstack/flow_watch.go b/boatstack/flow_watch.go index df86bca..2312c1c 100644 --- a/boatstack/flow_watch.go +++ b/boatstack/flow_watch.go @@ -130,6 +130,7 @@ func frontierSignatures(frontier FlowFrontier) map[string]string { key := row.Feature + "/" + row.Slice signatures[key] = strings.Join([]string{ row.Stage, row.Lifecycle, row.PRPhase, row.Actor, row.NextOperation, + row.GoalEscape, fmt.Sprintf("blocked=%t", row.Blocked), strings.Join(row.PRFailingChecks, "|"), }, "·") diff --git a/boatstack/goal_escape.go b/boatstack/goal_escape.go new file mode 100644 index 0000000..eb8e338 --- /dev/null +++ b/boatstack/goal_escape.go @@ -0,0 +1,100 @@ +package boatstack + +import "strings" + +// Goal escapes bound the merged-terminal pursuit: pursuing a merge +// autonomously is trustworthy only while the world stays inside the contract +// the goal was granted under. When a disturbance ends that contract — the fix +// budget is spent, a reviewer requested changes, the base conflicts — the +// pursuit DEMOTES to the operator and stops: the actor becomes operator, +// nothing further is prescribed, and the demotion is persisted best-effort so +// it holds offline in a fresh session. An escape is cleared only by the next +// explicit correction cycle (record-change), which is an operator-authorized +// act in the protocol. Escapes exist only under the merged terminal; the +// published default never evaluates or records them. +// control-law: goal-escape-demotes-to-operator-and-stops +const ( + EscapeFixAttemptsExhausted = "fix_attempts_exhausted" + EscapeChangesRequested = "changes_requested" + EscapeBaseConflicts = "base_conflicts" +) + +// postPublishFixBudget bounds how many post-publish correction cycles a +// published slice may consume before the pursuit hands back to the operator. +// It mirrors the active-slice repair budget (RepairAttempt < 3). +const postPublishFixBudget = 3 + +// evaluateGoalEscape derives the escape for one published slice from its +// persisted counters and one live observation. Pure and offline-safe: a +// previously persisted escape is sticky regardless of what gh says now (a +// re-approval without a recorded correction does not silently re-arm the +// pursuit), and the attempts bound needs no network at all. +func evaluateGoalEscape(slice DeliverySlice, pr publishedPRObservation) string { + if persisted := strings.TrimSpace(slice.GoalEscape); persisted != "" { + return persisted + } + if slice.PostPublishFixAttempts >= postPublishFixBudget { + return EscapeFixAttemptsExhausted + } + if strings.EqualFold(strings.TrimSpace(pr.ReviewDecision), "CHANGES_REQUESTED") { + return EscapeChangesRequested + } + // DIRTY is GitHub's "the branch conflicts with the base". BEHIND is + // deliberately not an escape: it is often auto-resolvable and already + // classifies to an operator-owned Unknown phase without stickiness. + if strings.EqualFold(strings.TrimSpace(pr.MergeState), "DIRTY") { + return EscapeBaseConflicts + } + return "" +} + +// goalEscapeReason renders one escape as the operator-facing explanation. +func goalEscapeReason(escape string) string { + switch escape { + case EscapeFixAttemptsExhausted: + return "the post-publish fix budget is spent" + case EscapeChangesRequested: + return "a reviewer requested changes" + case EscapeBaseConflicts: + return "the branch conflicts with its base" + default: + return "the pursuit contract ended" + } +} + +// persistGoalEscape caches a fired escape on the slice it belongs to, exactly +// like persistObservedTerminalPRState caches a terminal lifecycle: a bounded, +// best-effort write of an already-derived fact, so the demotion is sticky in +// a fresh offline session. Failures are swallowed — the demotion holds for +// this resolution regardless. +func persistGoalEscape(repo string, state DeliveryState, escape string) { + if strings.TrimSpace(escape) == "" { + return + } + for i := len(state.Slices) - 1; i >= 0; i-- { + slice := state.Slices[i] + if slice.Status != "PUBLISHED" { + continue + } + if strings.TrimSpace(slice.GoalEscape) == escape { + return + } + state.Slices[i].GoalEscape = escape + _ = saveDeliveryState(repo, state) + return + } +} + +// bumpPostPublishFixCycle advances the per-slice correction-cycle bookkeeping +// when a post-publish correction is explicitly recorded. Recording a +// correction after an escape is the operator's reset: the escape clears and +// the new cycle starts at one. Without an escape, the cycle count advances +// toward the budget. +func bumpPostPublishFixCycle(slice *DeliverySlice) { + if strings.TrimSpace(slice.GoalEscape) != "" { + slice.GoalEscape = "" + slice.PostPublishFixAttempts = 1 + return + } + slice.PostPublishFixAttempts++ +} diff --git a/boatstack/goal_escape_conformance_test.go b/boatstack/goal_escape_conformance_test.go new file mode 100644 index 0000000..f23926b --- /dev/null +++ b/boatstack/goal_escape_conformance_test.go @@ -0,0 +1,199 @@ +package boatstack + +// control-law: goal-escape-demotes-to-operator-and-stops +// +// The merged-terminal pursuit is bounded by an explicit contract: it runs +// only while the fix budget holds, no reviewer has requested changes, and the +// branch merges cleanly. Any of those disturbances fires a goal escape, and a +// fired escape demotes unconditionally — the actor becomes operator, nothing +// further is prescribed (demote-and-stop, never demote-and-suggest), and the +// demotion persists best-effort so it holds OFFLINE in a fresh session. The +// only reset is the next explicitly recorded correction cycle. Under the +// published default no escape is ever evaluated or written. +// +// Test classes: positive (each escape condition → operator + no +// prescription + explanatory reason), relation (a persisted escape demotes +// with gh unavailable — sticky offline; recording a correction clears it and +// restarts the cycle at one), negative (a budget not yet spent does not +// escape; the default terminal records nothing), bypass (an escaped delivery +// never gets the merge prescribed again until reset, even under a live +// merge-eligible observation). + +import ( + "errors" + "strings" + "testing" +) + +func recordCIObservation(t *testing.T, repo, feature string) { + t.Helper() + if _, _, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "check failed", SourceStage: "ci", + Classification: "implementation_repair", + }); err != nil { + t.Fatal(err) + } +} + +// Positive: each live disturbance fires its escape — operator actor, no +// prescription, and a reason that explains the pause. +func TestLiveDisturbancesFireEscapes(t *testing.T) { + for _, test := range []struct { + name string + payload func(string, ...string) (string, error) + wantEscape string + }{ + {"changes_requested", phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunPass), EscapeChangesRequested}, + {"base_conflicts", phaseObservationPayload("OPEN", "APPROVED", "DIRTY", rollupCheckRunPass), EscapeBaseConflicts}, + } { + t.Run(test.name, func(t *testing.T) { + repo := mergedTerminalRepo(t) + withRecoveryGh(t, test.payload) + status, err := ResolveNext(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if status.GoalEscape != test.wantEscape { + t.Fatalf("escape = %q, want %q", status.GoalEscape, test.wantEscape) + } + if !strings.Contains(status.Reason, "paused") { + t.Fatalf("reason does not explain the pause: %q", status.Reason) + } + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator || next.Prescribed != nil { + t.Fatalf("escape did not demote-and-stop: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } + }) + } +} + +// Positive + relation: the fix budget is offline — three recorded cycles +// exhaust it with no live signal at all, the demotion is sticky with gh +// unavailable, and the next recorded correction is the reset that starts a +// fresh cycle at one. +func TestFixBudgetExhaustsDemotesOfflineAndResets(t *testing.T) { + repo := mergedTerminalRepo(t) + for i := 0; i < postPublishFixBudget; i++ { + recordCIObservation(t, repo, "shipped") + } + state, err := LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + last := state.Slices[len(state.Slices)-1] + if last.PostPublishFixAttempts != postPublishFixBudget { + t.Fatalf("attempts = %d, want %d", last.PostPublishFixAttempts, postPublishFixBudget) + } + + // gh is unavailable: the escape must fire from the persisted counter alone. + withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("offline") }) + status, err := ResolveNext(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if status.GoalEscape != EscapeFixAttemptsExhausted { + t.Fatalf("offline escape = %q", status.GoalEscape) + } + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator || next.Prescribed != nil { + t.Fatalf("offline demotion failed: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } + // The fired escape was cached; a fresh load shows it without any observation. + state, err = LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if state.Slices[len(state.Slices)-1].GoalEscape != EscapeFixAttemptsExhausted { + t.Fatalf("escape not persisted: %#v", state.Slices) + } + + // The reset: recording the next correction clears the escape and starts a + // new cycle at one. + recordCIObservation(t, repo, "shipped") + state, err = LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + last = state.Slices[len(state.Slices)-1] + if last.GoalEscape != "" || last.PostPublishFixAttempts != 1 { + t.Fatalf("reset failed: %#v", last) + } +} + +// Negative: a budget not yet spent does not escape, and the pursuit still +// prescribes the fix. +func TestUnspentBudgetKeepsPrescribing(t *testing.T) { + repo := mergedTerminalRepo(t) + for i := 0; i < postPublishFixBudget-1; i++ { + recordCIObservation(t, repo, "shipped") + } + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorAgent || next.Prescribed == nil || next.Prescribed.Verb != "record-change" { + t.Fatalf("unspent budget stopped prescribing: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } +} + +// Negative: the published default never evaluates, surfaces, or writes an +// escape — its state files stay byte-stable through a correction cycle. +func TestPublishedDefaultRecordsNoEscapeState(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "DIRTY", rollupCheckRunFail)) + + status, err := ResolveNext(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if status.GoalEscape != "" { + t.Fatalf("default terminal surfaced an escape: %q", status.GoalEscape) + } + recordCIObservation(t, repo, "shipped") + state, err := LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + last := state.Slices[len(state.Slices)-1] + if last.PostPublishFixAttempts != 0 || last.GoalEscape != "" { + t.Fatalf("default terminal wrote pursuit bookkeeping: %#v", last) + } +} + +// Bypass: once escaped, even a live merge-eligible observation cannot get the +// merge prescribed again — the contract stays ended until the recorded reset. +func TestEscapedDeliveryNeverGetsMergePrescribed(t *testing.T) { + repo := mergedTerminalRepo(t) + // Fire and persist an escape. + withRecoveryGh(t, phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunPass)) + if _, err := ResolveNext(repo, "shipped"); err != nil { + t.Fatal(err) + } + // The world now looks perfect — but the escape is sticky. + withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunPass)) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator || next.Prescribed != nil { + t.Fatalf("sticky escape bypassed: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } + // After the recorded reset, the pursuit re-arms from a fresh observation. + recordCIObservation(t, repo, "shipped") + next, err = NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Prescribed == nil || next.Prescribed.Program != "gh" { + t.Fatalf("reset did not re-arm the pursuit: %#v", next.Prescribed) + } +} diff --git a/boatstack/internal/deliverycontrol/registry.go b/boatstack/internal/deliverycontrol/registry.go index 039d7bd..3e5133f 100644 --- a/boatstack/internal/deliverycontrol/registry.go +++ b/boatstack/internal/deliverycontrol/registry.go @@ -80,7 +80,7 @@ var registry = []TransitionDescriptor{ ID: "delivery.next", From: nil, To: "", Kind: KindObserve, CostClass: CostObserve, Reversible: false, HandlerRef: "ResolveNext", CLIVerb: "next-status", - Note: "Derives the recommended next move. Read-only, except that the published branch caches an observed terminal PRState as a best-effort side effect (a known bypass, modeled not fixed).", + Note: "Derives the recommended next move. Read-only, except that the published branch caches an observed terminal PRState — and, under the merged terminal, a fired goal-escape demotion — as a best-effort side effect (a known bypass, modeled not fixed).", }, { ID: "delivery.recovery_status", From: []StateID{StateBuild, StateTestPassed, StateReviewPassed, StatePublished}, To: "", diff --git a/boatstack/next.go b/boatstack/next.go index 53a16c7..b9e4065 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -27,6 +27,7 @@ type NextStatus struct { Reason string `json:"reason"` BlockingAmbiguity []string `json:"blocking_ambiguity,omitempty"` Lifecycle string `json:"lifecycle,omitempty"` + GoalEscape string `json:"goal_escape,omitempty"` PRPhase string `json:"pr_phase,omitempty"` PRReviewDecision string `json:"pr_review_decision,omitempty"` PRMergeState string `json:"pr_merge_state,omitempty"` @@ -144,14 +145,22 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { func nextForPublished(repo string, state DeliveryState) NextStatus { pr := observePublishedPR(repo, state) persistObservedTerminalPRState(repo, state, pr) - return publishedNextStatus(state, pr) + terminal := resolveDeliveryTerminal(repo, state.Feature) + status := publishedNextStatus(state, pr, terminal) + // A fired escape is cached best-effort so the demotion holds offline in a + // fresh session — the same bounded bypass as the terminal PRState cache. + // control-law: goal-escape-demotes-to-operator-and-stops + if terminal == TerminalMerged && status.GoalEscape != "" && status.Lifecycle != "PUBLISHED_MERGED" { + persistGoalEscape(repo, state, status.GoalEscape) + } + return status } // publishedNextStatus is the pure mapping from one live PR observation to the // published NextStatus. Split from nextForPublished so the frontier report can // present the same projection without nextForPublished's best-effort terminal // cache write. control-law: frontier-reports-never-mutates -func publishedNextStatus(state DeliveryState, pr publishedPRObservation) NextStatus { +func publishedNextStatus(state DeliveryState, pr publishedPRObservation, terminal DeliveryTerminal) NextStatus { _, sliceID, _ := deliveryBranchAndSlice(state) status := NextStatus{ SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", @@ -162,6 +171,13 @@ func publishedNextStatus(state DeliveryState, pr publishedPRObservation) NextSta PRPhase: string(pr.Phase), PRReviewDecision: pr.ReviewDecision, PRMergeState: pr.MergeState, PRFailingChecks: pr.FailingChecks, } + if terminal == TerminalMerged && pr.Lifecycle != "PUBLISHED_MERGED" && len(state.Slices) > 0 { + index := state.ActiveIndex + if index >= len(state.Slices) { + index = len(state.Slices) - 1 + } + status.GoalEscape = evaluateGoalEscape(state.Slices[index], pr) + } switch pr.Lifecycle { case "PUBLISHED_MERGED": status.ObservedStage = "FEATURE_COMPLETE" @@ -189,6 +205,9 @@ func publishedNextStatus(state DeliveryState, pr publishedPRObservation) NextSta default: status.Reason = fmt.Sprintf("Feature %q is published, but its PR state could not be verified.", state.Feature) } + if status.GoalEscape != "" { + status.Reason = fmt.Sprintf("Feature %q is published; the merged-goal pursuit is paused because %s. Record the correction to start a new cycle, or handle the pull request yourself.", state.Feature, goalEscapeReason(status.GoalEscape)) + } return status } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 37041dc..80c9c38 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -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 [`cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56`](https://github.com/operatorstack/intelligence-flow/tree/cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56/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 [`c550ef95f440e8b463ae0d436953e10ad91e1ab6`](https://github.com/operatorstack/intelligence-flow/tree/c550ef95f440e8b463ae0d436953e10ad91e1ab6/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 a40b614..a52036e 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56", + "source_commit": "c550ef95f440e8b463ae0d436953e10ad91e1ab6", "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" }, { "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:cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56" + "last_verified_version": "source:c550ef95f440e8b463ae0d436953e10ad91e1ab6" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 78921a5..6111b81 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": "cd4c03277c5812f8792cf6009e1a9bb8b0ec5f56", + "source_commit": "c550ef95f440e8b463ae0d436953e10ad91e1ab6", "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-28-bounded-merge-pursuit.md b/release-notes/2026-07-28-bounded-merge-pursuit.md new file mode 100644 index 0000000..3381389 --- /dev/null +++ b/release-notes/2026-07-28-bounded-merge-pursuit.md @@ -0,0 +1,5 @@ +### The merged-goal pursuit now has explicit limits, and stops when it hits one + +With `delivery.terminal: merged`, the flow pursues your pull request only inside a clear contract: at most three recorded post-publish fix cycles, no reviewer asking for changes, and a branch that merges cleanly. When any of those ends — the budget is spent, changes are requested, the base conflicts — the pursuit pauses: the step comes back to you, nothing further is prescribed, and the pause is remembered so it still holds tomorrow in a fresh session, even offline. + +Recording the next correction is the explicit reset that starts a fresh cycle. The status reason always tells you why the pursuit paused. With the default `published` goal, none of this bookkeeping is evaluated or written.