diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84c2a5e..9d1b440 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/bf68921a54fdb3139401ce4a91241c799887e5b6/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/60f76062f72185efddad1c22f6c1fc088aff0005/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 2b352f5..dd826c4 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 77946, - "estimated_tokens": 19487, + "characters": 78586, + "estimated_tokens": 19647, "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": "b5ed7bc801e78adc910b1467cd0be364fe39c44a4c6e4547200debc1d9f74517", + "CONTRIBUTING.md": "2e8bb6baa538f3ea22f5d31d03511eac4ffea83842733ec2f2de731f66baae76", "README.md": "6b7402c5cef5b3b9b739281d3d4d576cdc995796ff127fc6aefb97c5743e0bac", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,14 +40,14 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "6e9cb69ef962974f9893b1b4211df5e5fbf15f50207e958d6458b64dc042a683", + "boatstack/cmd/boatstack-helper/main.go": "ac174deb556f9a0b5e033154f2fc966715b3bff81d26ef97a23df2f901c56652", "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": "10098124cfb34d0c1d99885b7293ecaccb64c5b5cd80c08f1e2bd1ef43f8a756", + "boatstack/delivery.go": "4b4e870335b4b45c1fbfdc5a2daeb65b14c41671bc489f7e75189b9fc2ae6cb9", "boatstack/delivery_boundary_conformance_test.go": "800cd722d8d2a696a0529e8343d3523e453bb052f0917c8a2cad2990296ac1b3", "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", @@ -89,14 +89,15 @@ "boatstack/provision.go": "4882d49681f99b11ba9d182ca13772131b7f9a11a6c2b560800654ca14f5111e", "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", - "boatstack/recovery.go": "6939747f3725a6dd2de933d0248571d7f21a9ee017568306fe11f08fdbba413e", + "boatstack/published_slice_routing_test.go": "ea7e7351018bc13dcd31c4b96f50f8bc230e8a1dbf7806fba32a12ae58923e7e", + "boatstack/recovery.go": "7c06cdb52a31125cf3b944c304eca1df2273edc763242112527798bfb114874f", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/reexec.go": "fed55416479d7bd3e0c3637057ffe8eb58a032f93fc358f76df906ab7acc677b", "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", "boatstack/reexec_windows.go": "f5335c8c28cb4e89048b058b1c4d12f78644f99acb4f6167ff60e622dfb9e742", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", - "boatstack/references/failure-moves.md": "04cf53609c355f93df20ae7650bab49183d612b8b04ef425ea564b808039192b", + "boatstack/references/failure-moves.md": "35ac99fdf19eac823313b684b4a8a92990fb42392dc1a94447621d538c637fc7", "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", @@ -110,7 +111,7 @@ "boatstack/runtime_cache.go": "e40c8c43f410d781a7a4e7ebf68a1caa597ea9005190fd6cea02884f863e091e", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "9e1dd0a40524304b6eb3e0ad9f24ebe12bd1a72794b77bd74b4f79e81992765c", + "boatstack/safety.go": "4f8f6e2dc2596ab28e3cb7f15c5634c6580d4306f039e2bacb85c7a584b5d255", "boatstack/safety_test.go": "01f28bc3bfcb6bdd47b307e309e36bbc1921b6426ad0b777d81fe4131200c37e", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", @@ -134,10 +135,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "0eb1c59316939ebb951e28c9a508743be4d55a7ab49a15e80c237dbd1715232d", + "docs/evidence-engineered-coding.md": "8da580e5b910db5255944ba15aa23bea9356b9545e52430962abc265810f871c", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "8d9a5258882cdc22df696000a44fdb1d0185c9eae853a3b31f29d49d75fe9cb8", + "docs/public-claims.json": "8fd1c004d69856c2426263f96ce5c9c25cda37ac3d43f8f39bffec0f0913908c", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -151,7 +152,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": "54138d8f25086643c31032262bfe9b6d2f7fec4eabd3fcbfd41c92923c283987", + "labs/diagram-json/plan.lock.json": "617523321b82841a59652e680bd163416507c84ddb727fc226ff5727175cef16", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -233,12 +234,13 @@ "release-notes/2026-07-24-repair-state-recovery.md": "daaa12deb51a5f647178d6164ea5b4bcd29bf5482b77d90002429f69e0da5dd0", "release-notes/2026-07-24-transactional-mutation-boundary.md": "38819a4811edbc99a9d8a77983aedbd0589bdbbf849da4991d3e21a1b319a65c", "release-notes/2026-07-25-boatstack-banner.md": "28e83f294de606211cfdc91b2586aa834e004dee76d5c4bee08859986ae86b5b", + "release-notes/2026-07-25-published-slice-correction-routing.md": "129cdd62c80c8b93060726027d68ba3abdb0bca1a1ce9e64d6053271af3fd082", "release-notes/2026-07-25-root-cause-operation.md": "5bf1f082e9123c5a7bcc8bc01b12e97b24b5ae15958577b4ff2a358994fca891" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "bf68921a54fdb3139401ce4a91241c799887e5b6", + "commit": "60f76062f72185efddad1c22f6c1fc088aff0005", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 899dcc8..d3d2997 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -757,6 +757,7 @@ func recordChangeCommand(arguments []string) int { flags.StringVar(&options.Actual, "actual", "", "observed behavior") flags.StringVar(&options.Evidence, "evidence", "", "bounded evidence or reproduction reference") flags.StringVar(&options.Classification, "classification", "", "implementation_repair, verification_repair, review_repair, requirement_amendment, needs_clarification, or plan_invalid") + flags.StringVar(&options.SliceID, "slice", "", "delivery slice id the correction targets; redirects to the named active or published-open slice (default: the correction's branch, then the active slice)") if err := flags.Parse(arguments); err != nil { return 2 } diff --git a/boatstack/delivery.go b/boatstack/delivery.go index fa9b38a..44aaad5 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -113,6 +113,10 @@ type ChangeObservationOptions struct { Actual string Evidence string Classification string + // SliceID optionally targets a specific addressable slice — the active slice or + // a published-but-open earlier slice. Empty resolves to the correction's branch + // and then the active slice, preserving the ordinary repair path. + SliceID string } type ChangeObservation struct { @@ -546,14 +550,52 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio return ChangeObservation{}, DeliveryState{}, fmt.Errorf("change observation requires the user message and source stage") } published := state.ActiveIndex >= len(state.Slices) + + // Resolve which addressable slice this correction targets before mutating any + // state. An explicit --slice, or a correction pushed on a published-but-open + // earlier slice's branch, must bind to that slice — historically every non- + // published correction was bound to the active slice, which corrupted the wrong + // slice when the real target was an already-published slice inside the delivery. + targetIndex := -1 + var targetSlice DeliverySlice + publishedOpen := false if !published { + sliceHint := strings.TrimSpace(options.SliceID) + if sliceHint == "" { + if branch, _ := gitCommand(repo, "branch", "--show-current"); strings.TrimSpace(branch) != "" { + if idx, _, ok := resolveAddressableSliceByBranch(state, strings.TrimSpace(branch)); ok && idx < state.ActiveIndex { + sliceHint = state.Slices[idx].ID + } + } + } + idx, slice, resolveErr := resolveAddressableSlice(state, sliceHint) + if resolveErr != nil { + return ChangeObservation{}, DeliveryState{}, resolveErr + } + targetIndex, targetSlice = idx, slice + publishedOpen = idx < state.ActiveIndex + } + + // A published-but-open slice may only be corrected in place through a gate + // repair; a requirement or plan change to it is a new decision that requires an + // independently approved corrective child, not an in-place re-gate. + if publishedOpen { + switch classification { + case "implementation_repair", "verification_repair", "review_repair": + default: + return ChangeObservation{}, DeliveryState{}, fmt.Errorf("delivery slice %s is published with an open pull request; a %s change must be handled by a corrective child delivery, not an in-place re-gate", targetSlice.ID, classification) + } + } + + // The delivery-level repair budget governs only the active slice's build loop. + if !published && !publishedOpen { if state.RepairAttempt >= 3 { return ChangeObservation{}, DeliveryState{}, fmt.Errorf("persistent repair budget exhausted after %d attempts; preserve current state and require a reviewed recovery decision", state.RepairAttempt) } state.RepairAttempt++ } id := fmt.Sprintf("CHG-%03d", state.RepairAttempt) - if published { + if published || publishedOpen { id = nextChangeObservationID(repo, options.Feature, state.RepairAttempt+1) } observation := ChangeObservation{ @@ -562,10 +604,8 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio Message: strings.TrimSpace(options.Message), Classification: classification, ResumeStage: resume, RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), } - if !published { - observation.SliceID = state.Slices[state.ActiveIndex].ID - observation.Outcome = "RESUME_ACTIVE" - } else { + switch { + case published: if len(state.Slices) > 0 { observation.SliceID = state.Slices[len(state.Slices)-1].ID } @@ -576,6 +616,12 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio observation.Outcome = "CORRECTIVE_CHILD_REQUIRED" observation.ParentDelivery = state.Feature observation.SuggestedFeatureID = suggestedCorrectionFeature(states, state.Feature) + case publishedOpen: + observation.SliceID = targetSlice.ID + observation.Outcome = "RESUME_PUBLISHED_SLICE" + default: + observation.SliceID = targetSlice.ID + observation.Outcome = "RESUME_ACTIVE" } if err := appendChangeObservation(repo, observation); err != nil { return ChangeObservation{}, DeliveryState{}, err @@ -583,6 +629,37 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio if published { return observation, state, nil } + if publishedOpen { + // In-place re-gate of a published-but-open slice: archive its stale gate + // receipts and reset its Status so test/review re-run against the fix, while + // its PRState="OPEN" preserves the published identity that publish-pr --action + // update targets. The active slice's build loop (RepairAttempt, Mode, + // ResumeStage, ActiveObservationID) is deliberately left untouched — the + // correction belongs to the published slice, not the active one. + slice := &state.Slices[targetIndex] + gates := []string{"review"} + if resume == "BUILD" || resume == "TEST_GATE" { + gates = []string{"test", "review"} + } + for _, gate := range gates { + archived, archiveErr := archiveDeliveryReceipt(repo, options.Feature, slice.ID, gate, id) + if archiveErr != nil { + return ChangeObservation{}, DeliveryState{}, archiveErr + } + if archived != "" { + state.SupersededReceipts = append(state.SupersededReceipts, archived) + } + } + if resume == "REVIEW_GATE" { + slice.Status = "TEST_PASSED" + } else { + slice.Status = "BUILD" + } + if err := saveDeliveryState(repo, state); err != nil { + return ChangeObservation{}, DeliveryState{}, err + } + return observation, state, nil + } state.ActiveObservationID = id state.ResumeStage = resume if classification == "needs_clarification" || classification == "requirement_amendment" { @@ -688,6 +765,40 @@ func resolveAddressableSlice(state DeliveryState, sliceID string) (int, Delivery return -1, DeliverySlice{}, fmt.Errorf("delivery slice %s does not exist", sliceID) } +// resolveAddressableSliceByBranch selects the addressable slice a correction on +// the given branch may act on. It applies the same addressable set as +// resolveAddressableSlice — {active slice} ∪ {PUBLISHED slices whose PR is not +// terminal} — but keys off the slice's recorded head branch rather than its id. +// +// Advisors (recovery routing, safety findings, change recording) start from the +// current git branch, not a slice id, and historically resolved corrections +// against state.ActiveIndex alone. That left the advisor layer blind to a +// published-but-open earlier slice that the actuator layer (resolveAddressableSlice) +// could still correct in place, so a correction pushed on the published slice's +// branch was mis-routed to the active slice. Routing every advisor through this one +// resolver keeps "which slice this correction may act on" defined in a single place, +// so the advisor layer cannot drift from the actuator layer again. ok is false when +// no addressable slice owns the branch (including a slice whose PR is terminal — the +// caller then falls through to its corrective-child path unchanged). +func resolveAddressableSliceByBranch(state DeliveryState, branch string) (int, DeliverySlice, bool) { + branch = strings.TrimSpace(branch) + if branch == "" { + return -1, DeliverySlice{}, false + } + for i, s := range state.Slices { + if strings.TrimSpace(s.HeadBranch) != branch { + continue + } + if i == state.ActiveIndex { + return i, s, true + } + if i < state.ActiveIndex && strings.TrimSpace(s.PRState) != "" && !isTerminalPRState(s.PRState) { + return i, s, true + } + } + return -1, DeliverySlice{}, false +} + func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error { lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json") lockHash, err := SHA256File(lockPath) diff --git a/boatstack/published_slice_routing_test.go b/boatstack/published_slice_routing_test.go new file mode 100644 index 0000000..dcb4624 --- /dev/null +++ b/boatstack/published_slice_routing_test.go @@ -0,0 +1,175 @@ +package boatstack + +import "testing" + +// Regression for the published-slice correction trap: after a slice publishes and +// the BUILD pointer advances, the actuator layer (resolveAddressableSlice) keeps +// the published-but-open slice correctable in place, but the advisor/router/safety +// layer used to decide purely on the whole-delivery pointer (ActiveIndex < +// len(Slices)) and mis-routed a correction for the published slice to the active +// slice — repairing the wrong slice and reporting the fix branch as unrelated. +// +// These tests pin the advisor layer to the SAME addressable set the actuators use. + +// publishOpenFirstSlice activates a two-slice delivery, gates and publishes the +// first slice, and returns the repo/feature with the pointer advanced to the +// still-building second slice. The first slice is PUBLISHED with an open PR; the +// test repo's git branch (feat/reviewer-ready) is the first slice's head branch. +func publishOpenFirstSlice(t *testing.T) (string, string) { + t.Helper() + 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[0].Status != "PUBLISHED" || state.Slices[0].PRState != "OPEN" { + t.Fatalf("setup did not leave phase-one published-open with the pointer on phase-two: %#v", state) + } + return repo, feature +} + +func TestRecoveryRoutesPublishedOpenSliceInPlace(t *testing.T) { + repo, feature := publishOpenFirstSlice(t) + + // The correction is reported from phase-one's own branch (feat/reviewer-ready), + // which is the test repo's current branch — no explicit --feature is needed. + status, err := ResolveRecovery(RecoveryStatusOptions{ + Repo: repo, Feature: feature, + Message: "required checks failed on the published PR", SourceStage: "ci", + }) + if err != nil { + t.Fatalf("resolve recovery: %v", err) + } + if status.NextOperation != "repair_published_slice" { + t.Fatalf("correction for a published-open slice was not routed in place: %#v", status) + } + if status.Slice != "phase-one" { + t.Fatalf("router named the wrong slice: got %q, want phase-one", status.Slice) + } + if status.Lifecycle != "PUBLISHED_OPEN" { + t.Fatalf("router did not mark the published-open lifecycle: %#v", status) + } +} + +func TestRecordChangeBindsPublishedOpenSliceWithoutDisturbingActive(t *testing.T) { + assertInPlace := func(t *testing.T, options ChangeObservationOptions) { + t.Helper() + repo, feature := publishOpenFirstSlice(t) + options.Repo = repo + options.Feature = feature + options.Message = "required checks failed on the published PR" + options.SourceStage = "ci" + options.Classification = "verification_repair" + + observation, _, err := RecordChangeObservation(options) + if err != nil { + t.Fatalf("record change: %v", err) + } + if observation.Outcome != "RESUME_PUBLISHED_SLICE" { + t.Fatalf("change bound to the wrong path: %#v", observation) + } + if observation.SliceID != "phase-one" { + t.Fatalf("change bound to the wrong slice: got %q, want phase-one", observation.SliceID) + } + got := loadDelivery(t, repo, feature) + // The active slice's build loop must be untouched. + if got.ActiveIndex != 1 { + t.Fatalf("recording a published-slice correction moved the pointer: %#v", got) + } + if got.Slices[1].Status != "BUILD" { + t.Fatalf("active slice was disturbed: %#v", got.Slices[1]) + } + if got.RepairAttempt != 0 { + t.Fatalf("active repair budget was consumed by a published-slice correction: %d", got.RepairAttempt) + } + if got.Mode == "REWORK" { + t.Fatalf("published-slice correction hijacked the delivery mode: %q", got.Mode) + } + // The published slice is driven back to an in-place re-gate; its open PR + // identity is preserved so publish-pr --action update can target it. + if got.Slices[0].Status != "BUILD" { + t.Fatalf("published slice was not reset for an in-place re-gate: %#v", got.Slices[0]) + } + if got.Slices[0].PRState != "OPEN" { + t.Fatalf("in-place re-gate dropped the published PR identity: %#v", got.Slices[0]) + } + } + + t.Run("explicit --slice", func(t *testing.T) { + assertInPlace(t, ChangeObservationOptions{SliceID: "phase-one"}) + }) + t.Run("resolved from the correction branch", func(t *testing.T) { + assertInPlace(t, ChangeObservationOptions{}) + }) +} + +// TestActiveSliceCorrectionStillRoutesToRepairActive guards the ordinary path: a +// correction on the active slice's own branch, with no earlier published-open +// slice, must still route to repair_active unchanged. +func TestActiveSliceCorrectionStillRoutesToRepairActive(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + // Gate (but do not publish) phase-one so it becomes the branch-matched ACTIVE + // slice; the pointer stays at index 0. + gateSlice(t, repo, feature, "phase-one") + + status, err := ResolveRecovery(RecoveryStatusOptions{ + Repo: repo, Feature: feature, + Message: "a review note on the active slice", SourceStage: "review", + }) + if err != nil { + t.Fatalf("resolve recovery: %v", err) + } + if status.NextOperation != "repair_active" { + t.Fatalf("active-slice correction no longer routes to repair_active: %#v", status) + } +} + +// TestSafetyFindingNamesPublishedOpenSlice pins the publication-bypass finding to +// the addressable slice the branch owns: a denied push on the published slice's +// branch must name that slice and read as the current branch, not the active slice +// with relation=unrelated. +func TestSafetyFindingNamesPublishedOpenSlice(t *testing.T) { + repo, _ := publishOpenFirstSlice(t) + + finding, blocked := publicationBypassFinding(repo, "denied direct push", "tool-input") + if !blocked { + t.Fatalf("expected a publication-bypass finding for the active delivery") + } + if finding.BranchRelation != "current_branch" { + t.Fatalf("published-slice fix branch read as %q, want current_branch", finding.BranchRelation) + } + if finding.BlockingSlice != "phase-one" { + t.Fatalf("finding named the wrong slice: got %q, want phase-one", finding.BlockingSlice) + } +} + +// TestAdvisorAndActuatorResolveSameSlice is the anti-drift invariant: for a given +// branch, the advisor resolver (by branch) and the actuator resolver (by id) must +// select the same slice. This is the structural guarantee that the advisor layer +// cannot silently diverge from the actuator layer again. +func TestAdvisorAndActuatorResolveSameSlice(t *testing.T) { + state := DeliveryState{ + ActiveIndex: 1, + Slices: []DeliverySlice{ + {ID: "phase-one", Status: "PUBLISHED", PRState: "OPEN", HeadBranch: "feat/phase-one"}, + {ID: "phase-two", Status: "BUILD", HeadBranch: "feat/phase-two"}, + }, + } + for _, slice := range state.Slices { + byBranch, addressable, ok := resolveAddressableSliceByBranch(state, slice.HeadBranch) + if !ok { + t.Fatalf("advisor resolver could not resolve branch %q", slice.HeadBranch) + } + byID, _, err := resolveAddressableSlice(state, slice.ID) + if err != nil { + t.Fatalf("actuator resolver rejected slice %q: %v", slice.ID, err) + } + if byBranch != byID { + t.Fatalf("advisor/actuator drift for %q: branch->%d, id->%d", slice.ID, byBranch, byID) + } + if addressable.ID != slice.ID { + t.Fatalf("advisor resolver returned %q for branch %q", addressable.ID, slice.HeadBranch) + } + } +} diff --git a/boatstack/recovery.go b/boatstack/recovery.go index 5922d23..03c26c8 100644 --- a/boatstack/recovery.go +++ b/boatstack/recovery.go @@ -105,6 +105,13 @@ func stateMatchesBranch(state DeliveryState, branch string) bool { if strings.TrimSpace(branch) == "" { return false } + // A correction may be pushed on the branch of any addressable slice — the active + // slice or a published-but-open earlier slice — not only the active slice's head. + // Matching solely the active slice's head stranded corrections for a published + // slice inside a still-active delivery, so they never selected their own delivery. + if _, _, ok := resolveAddressableSliceByBranch(state, branch); ok { + return true + } head, _, _ := deliveryBranchAndSlice(state) if head != "" { return head == branch @@ -354,7 +361,25 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { Feature: selected.Feature, Slice: sliceID, ParentDelivery: selected.ParentDelivery, HeadBranch: head, PRURL: prURL, } + // A correction can target a published-but-open earlier slice whose PR has not + // yet merged or closed. The actuator layer (resolveAddressableSlice) can re-gate + // and re-publish such a slice in place, so the advisor must point the correction + // there rather than at the active slice. Resolve the target from the correction's + // branch through the same addressable set the actuators use; only an earlier + // (already published) slice needs this redirect — the active slice falls through + // to the ordinary repair boundary below. This redirect is scoped to a still-active + // delivery: once the whole delivery is published the corrective-child logic below + // owns the decision (and observes the live PR terminal-ness rather than the cache). if selected.ActiveIndex < len(selected.Slices) { + if idx, addressable, ok := resolveAddressableSliceByBranch(selected, strings.TrimSpace(branch)); ok && idx < selected.ActiveIndex { + status.Slice = addressable.ID + status.HeadBranch = addressable.HeadBranch + status.PRURL = addressable.PRURL + status.Lifecycle = "PUBLISHED_OPEN" + status.NextOperation = "repair_published_slice" + status.Reason = fmt.Sprintf("Delivery slice %q is published with an open pull request; re-gate it and republish with publish-pr --action update on its own branch, rather than repairing the active slice.", addressable.ID) + return status, nil + } status.Lifecycle = "ACTIVE" status.NextOperation = "repair_active" status.Reason = fmt.Sprintf("Managed delivery %q is active; route the exact correction through its current repair boundary.", selected.Feature) diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index d90b79d..3a6eb53 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -24,7 +24,7 @@ The `root-cause` operation operationalizes this taxonomy for a single bug: it cl | Irreversible recovery escalation | A failed external operation causes authority/target broadening or an invented reset | Immutable pre-execution deny; preserve state; read-only diagnosis; transactional retry or fix forward | False denial of legitimate isolated development operations | | Worktree bootstrap deadlock | A linked worktree inherits fail-closed hooks but not the ignored runtime required to evaluate them | Versioned Git-common runtime; atomic first-use hydration; provenance check | Cross-version execution or weakened failure behavior | | Cross-clone runtime-absence lockout | Only pointers (the guard's baked version path, the committed version pin) travel through Git; the version-keyed runtime bytes are gitignored and delivered out of band. So a teammate who pulls a merged version bump — or clones fresh — holds the new pointers but an empty slot, and the guard fail-closes ("shared runtime is missing") before any Go runs, stranding every teammate on every bump until each manually re-installs | The guard auto-hydrates an absent slot by running the tag-pinned, `.sha256`-verified installer in a branch-free, slot-only `hydrate-runtime` mode, serialized clone-wide by an atomic `mkdir` lock and bounded by a timeout, then falls through to the existing gates which stay authoritative and fail-closed; the deny message embeds the exact one-line self-heal, and `BOATSTACK_AUTO_HYDRATE=0` is the kill switch. Hydration refuses any running-vs-pin identity mismatch and touches no committed generated file | Running a fetched installer on cold start (bounded by tag pinning, HTTPS, sidecar verification, the guard's own checksum re-verify before `exec`, and the kill switch), or falling open — hydration is additive only, never a new authority for `exec` | -| 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 | +| Post-publication correction routing | CI, review, or a denied push targets work already marked published — including a published-but-open *earlier slice* inside a still-active delivery, which the pointer-based advisors mis-routed to the active slice | Resolve the target through the same addressable-slice set the actuators use ({active slice} ∪ {published slices whose PR is not terminal}), keyed off the correction's branch: route a non-terminal published slice to an in-place re-gate/`publish-pr --action update` of *that* slice (not the active one), and only a terminal PR to an independently approved corrective child. Run recovery routing, change recording, and the publication-bypass finding through that one resolver so the advisor layer cannot drift from the actuator layer | Treating PR creation as completion, asking the user to bypass the guard, or an advisor/actuator addressability split-brain that repairs the wrong slice | | 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 | diff --git a/boatstack/safety.go b/boatstack/safety.go index 2743536..3c60ef6 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -328,7 +328,14 @@ func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) } if !strings.Contains(selected, ",") { if state, loadErr := LoadDeliveryState(repo, selected); loadErr == nil { - _, finding.BlockingSlice, _ = deliveryBranchAndSlice(state) + // Report the addressable slice the current branch actually owns — the + // active slice or a published-but-open earlier slice — rather than always + // the active slice, which named the wrong slice for a published-slice fix. + if _, addressable, ok := resolveAddressableSliceByBranch(state, strings.TrimSpace(branch)); ok { + finding.BlockingSlice = addressable.ID + } else { + _, finding.BlockingSlice, _ = deliveryBranchAndSlice(state) + } finding.ParentDelivery = state.ParentDelivery } } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index ab7c5b7..8c83b67 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 **19487 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 **19647 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 [`bf68921a54fdb3139401ce4a91241c799887e5b6`](https://github.com/operatorstack/intelligence-flow/tree/bf68921a54fdb3139401ce4a91241c799887e5b6/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 [`60f76062f72185efddad1c22f6c1fc088aff0005`](https://github.com/operatorstack/intelligence-flow/tree/60f76062f72185efddad1c22f6c1fc088aff0005/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 4d9ed5a..6e0d1ee 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "bf68921a54fdb3139401ce4a91241c799887e5b6", + "source_commit": "60f76062f72185efddad1c22f6c1fc088aff0005", "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" }, { "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:bf68921a54fdb3139401ce4a91241c799887e5b6" + "last_verified_version": "source:60f76062f72185efddad1c22f6c1fc088aff0005" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index f4c54c0..f398e46 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": "bf68921a54fdb3139401ce4a91241c799887e5b6", + "source_commit": "60f76062f72185efddad1c22f6c1fc088aff0005", "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-25-published-slice-correction-routing.md b/release-notes/2026-07-25-published-slice-correction-routing.md new file mode 100644 index 0000000..04f5f94 --- /dev/null +++ b/release-notes/2026-07-25-published-slice-correction-routing.md @@ -0,0 +1,7 @@ +### Corrections for a published-but-open slice route to that slice in place, not the active one + +When a multi-slice delivery publishes an early slice and the build pointer advances, that slice is not finished: its pull request is still open and may need a CI fix or a review follow-up before it merges. Boatstack's actuators already knew this — `resolveAddressableSlice` treats the addressable set as `{the active slice} ∪ {published slices whose PR is not terminal}`, so `record-delivery-gate --slice` and `publish-pr` can re-gate and update a published-open slice in place. But the **advisor, router, and safety** layers were left deciding purely on the whole-delivery pointer (`ActiveIndex < len(Slices)`): whenever any later slice was still unbuilt they treated *the correction* as belonging to the active slice. A fix pushed on the published slice's own branch was mis-routed — `recovery-status` named the active slice and prescribed `repair_active`, `record-change` charged the active slice's repair budget and reset it to `BUILD`, and the publication-bypass finding reported the fix branch as `relation=unrelated` against the active slice. The published slice's open PR was left with no clean forward move, and the active slice's build loop was corrupted by a correction that was never about it. This is the slice-granularity instance of *advisor–actuator addressability drift*: one domain concept — "which slice may still be corrected" — with two implementations, only one of which had been migrated to per-slice addressability. + +The advisor layer now consumes the **same** addressability the actuators do. A single branch-keyed resolver, `resolveAddressableSliceByBranch`, returns the addressable slice (active **or** published-open with a non-terminal PR) that a correction branch owns, and the router, recorder, and safety finding all route through it. A correction reported from a published-open earlier slice's branch now resolves to that slice: `recovery-status` reports `PUBLISHED_OPEN` and a `repair_published_slice` next-operation that directs the operator to re-gate and `publish-pr --action update` the slice in place; `record-change` (now with an optional `--slice`, falling back to the correction branch) binds the observation to that slice and drives an in-place re-gate that preserves its open-PR identity, **without** touching the active slice's status, repair budget, or mode; and the publication-bypass finding names the published slice and reads the branch as `current_branch`. The active-slice path is unchanged for the ordinary case, and a fully-published (terminal-PR) delivery keeps its existing `draft_corrective_child` behavior. + +The regression suite pins the class shut: from a published-open slice's branch it asserts the router routes in place, `record-change` binds the published slice while leaving the active slice untouched (both via `--slice` and via branch resolution), the safety finding names the published slice, and — the anti-drift invariant — the advisor's by-branch resolver and the actuator's by-id resolver select the *same* slice, so the two layers cannot silently diverge again.