From 775851a0691cd3dc1c63c268bd95243149f10de1 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 28 Jul 2026 17:23:17 +0100 Subject: [PATCH] =?UTF-8?q?feat(boatstack):=20flow=20frontier=20=E2=80=94?= =?UTF-8?q?=20the=20cross-delivery=20dashboard=20of=20who=20owes=20what?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New read-only `flow frontier` verb: one row per managed delivery slice with its observed position (stage or live PR phase), the actor who owes the next step, and the prescribed command when one exists. Earlier published-but-open slices of an active delivery surface as their own rows. Rows classify through the same nextControlFromStatus path as flow next, so the dashboard and the advisor can never name different owners; one corrupt delivery becomes one blocked row instead of poisoning the view. Unlike next/recovery, the frontier performs ZERO writes — the terminal PR-state cache is deliberately not maintained here, pinned by conformance. Supporting refactors: observePRTarget split from observePublishedPR and publishedNextStatus split from nextForPublished (pure mapping, no cache write); both behavior-preserving for existing callers. control-law: frontier-reports-never-mutates Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .../2026-07-28-flow-frontier-dashboard.md | 5 + .../cmd/boatstack-helper/flow.go | 32 ++- .../product-engineering-loop/flow_frontier.go | 223 +++++++++++++++++ .../flow_frontier_conformance_test.go | 224 ++++++++++++++++++ .../product-engineering-loop/next.go | 8 + .../product-engineering-loop/recovery.go | 9 + 6 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-frontier-dashboard.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/flow_frontier_conformance_test.go diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-frontier-dashboard.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-frontier-dashboard.md new file mode 100644 index 000000000..9315f3767 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-frontier-dashboard.md @@ -0,0 +1,5 @@ +### One command now shows every feature, its position, and whose move it is + +`flow frontier` renders a read-only dashboard across all of your managed deliveries: each row names the feature, its observed position (building, awaiting review, PR checks failing, eligible to merge, complete), and the actor who owes the next step — you or the agent — with the exact prescribed command when one exists. Earlier slices that are published with a still-open pull request appear as their own rows, so a red check on an already-published slice is visible while a later slice builds. + +The dashboard is a pure report: it performs no writes at all, one unverifiable delivery becomes one blocked row instead of hiding your healthy work, and a row's owner always matches what `flow next` would say for the same feature. Before this, reconstructing "where is everything and what is waiting on me" required running status per feature and reading each answer. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go index a8464483f..7b5874cbf 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go @@ -14,7 +14,7 @@ import ( // gate, authority, or exit code. func flowCommand(arguments []string) int { if len(arguments) == 0 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") return 2 } switch arguments[0] { @@ -24,6 +24,8 @@ func flowCommand(arguments []string) int { return flowNextCommand(arguments[1:]) case "tasks": return flowTasksCommand(arguments[1:]) + case "frontier": + return flowFrontierCommand(arguments[1:]) case "report": return flowReportCommand(arguments[1:]) default: @@ -147,6 +149,34 @@ func executePrescribed(cmd *boatstack.PrescribedCommand) error { } } +// flowFrontierCommand renders the cross-delivery frontier dashboard: one row +// per managed delivery slice with its observed position and the actor who owes +// the next step. Strictly read-only — it performs zero writes, including the +// terminal PR-state cache that next/recovery maintain. +// control-law: frontier-reports-never-mutates +func flowFrontierCommand(arguments []string) int { + flags := flag.NewFlagSet("flow frontier", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose delivery frontier should be reported") + jsonOutput := flags.Bool("json", false, "print the structured frontier report") + if err := flags.Parse(arguments); err != nil { + return 2 + } + frontier, err := boatstack.ResolveFrontier(*repo) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(frontier) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Print(boatstack.FormatFlowFrontier(frontier)) + } + return 0 +} + // flowTasksCommand renders the active delivery slice's sub-actions from the // compiled plan task DAG, in dependency order, with the one to start pointed at. // It is read-only and never fails on flow position — an unresolved slice or an diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go new file mode 100644 index 000000000..6acbe7c30 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go @@ -0,0 +1,223 @@ +package boatstack + +import ( + "fmt" + "strings" +) + +// The frontier report answers "where is every ball, and whose is it" in one +// read-only view: every managed delivery — active, published, or invalid — +// becomes a row carrying its observed position and the actor who owes the +// next step. It is pure presentation over the same resolution the flow oracle +// uses, so a frontier row can never disagree with `flow next` for the same +// delivery; and unlike next/recovery it performs ZERO writes — not even the +// best-effort terminal PR-state cache — because a report that mutates is a +// report that can lie about what it found. +// control-law: frontier-reports-never-mutates +const flowFrontierSchemaVersion = 1 + +// FrontierRow is one delivery slice's position on the operator frontier. +type FrontierRow struct { + Feature string `json:"feature"` + Slice string `json:"slice,omitempty"` + SliceIndex int `json:"slice_index,omitempty"` + TotalSlices int `json:"total_slices,omitempty"` + Stage string `json:"stage"` + Lifecycle string `json:"lifecycle,omitempty"` + PRPhase string `json:"pr_phase,omitempty"` + PRFailingChecks []string `json:"pr_failing_checks,omitempty"` + PRURL string `json:"pr_url,omitempty"` + Actor string `json:"next_actor"` + NextOperation string `json:"next_operation"` + Prescribed string `json:"prescribed,omitempty"` + Reason string `json:"reason"` + Blocked bool `json:"blocked,omitempty"` +} + +// FlowFrontier is the full cross-delivery dashboard. +type FlowFrontier struct { + SchemaVersion int `json:"schema_version"` + Initialized bool `json:"initialized"` + Rows []FrontierRow `json:"rows"` + AgentSteps int `json:"agent_steps"` + OperatorSteps int `json:"operator_steps"` + TerminalRows int `json:"terminal_rows"` + BlockedRows int `json:"blocked_rows"` +} + +// ResolveFrontier builds the frontier report. Faults are partitioned, never +// propagated: one invalid delivery becomes one blocked row instead of +// poisoning the view of every healthy delivery (the same partition law the +// read-only recovery boundary uses). +// control-law: frontier-reports-never-mutates +// control-law: stale-delivery-cannot-block-unrelated-feature +func ResolveFrontier(repoPath string) (FlowFrontier, error) { + frontier := FlowFrontier{SchemaVersion: flowFrontierSchemaVersion} + repo, err := ResolveRepository(repoPath) + if err != nil { + return frontier, err + } + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { + return frontier, nil + } + frontier.Initialized = true + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + if configErr != nil { + return frontier, fmt.Errorf("boatstack project configuration is invalid; fix the config file (doctor diagnoses): %w", configErr) + } + states, invalid, err := allManagedDeliveryStates(repo) + if err != nil { + return frontier, err + } + states = withoutIgnoredDeliveryStates(states, config.Workflow.IgnoredDeliveries) + invalid = withoutIgnoredDeliveries(invalid, config.Workflow.IgnoredDeliveries) + + for _, slug := range invalid { + frontier.Rows = append(frontier.Rows, FrontierRow{ + Feature: slug, Stage: "INVALID_STATE", Actor: string(NextActorOperator), + NextOperation: "discard-delivery", Blocked: true, + Reason: "This managed delivery state cannot be verified; restore its evidence, ignore it, or discard it.", + }) + } + for _, state := range states { + if state.ActiveIndex < len(state.Slices) { + frontier.Rows = append(frontier.Rows, activeDeliveryRows(repo, state)...) + continue + } + branch, _, prURL := deliveryBranchAndSlice(state) + status := publishedNextStatus(state, observePRTarget(repo, prURL, branch)) + frontier.Rows = append(frontier.Rows, frontierRowFromStatus(repo, status)) + } + for _, row := range frontier.Rows { + switch { + case row.Blocked: + frontier.BlockedRows++ + case row.Actor == string(NextActorAgent): + frontier.AgentSteps++ + case row.Actor == string(NextActorNone): + frontier.TerminalRows++ + default: + frontier.OperatorSteps++ + } + } + return frontier, nil +} + +// activeDeliveryRows renders an active delivery: one row for the active slice +// via the authoritative resolution, plus one row for every earlier slice that +// is published with a still-open PR — those are live balls too (their checks +// can be failing while the active slice builds), and they are exactly the +// addressable set the actuators can still re-gate in place. +func activeDeliveryRows(repo string, state DeliveryState) []FrontierRow { + rows := []FrontierRow{} + status, err := nextForDelivery(repo, state.Feature) + if err != nil { + rows = append(rows, FrontierRow{ + Feature: state.Feature, Stage: "INVALID_STATE", Actor: string(NextActorOperator), + NextOperation: "discard-delivery", Blocked: true, + Reason: "The active managed delivery cannot be verified: " + err.Error(), + }) + } else { + rows = append(rows, frontierRowFromStatus(repo, status)) + } + limit := state.ActiveIndex + if limit > len(state.Slices) { + limit = len(state.Slices) + } + for i := 0; i < limit; i++ { + slice := state.Slices[i] + if slice.Status != "PUBLISHED" || strings.TrimSpace(slice.PRState) == "" || isTerminalPRState(slice.PRState) { + continue + } + observation := observePRTarget(repo, slice.PRURL, slice.HeadBranch) + sliceStatus := NextStatus{ + SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", + Feature: state.Feature, ActiveSlice: slice.ID, SliceIndex: i + 1, + TotalSlices: len(state.Slices), ObservedStage: "PUBLISHED", NextOperation: "none", + Lifecycle: observation.Lifecycle, PRURL: observation.URL, HeadBranch: observation.Branch, + PRPhase: string(observation.Phase), PRReviewDecision: observation.ReviewDecision, + 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), + } + rows = append(rows, frontierRowFromStatus(repo, sliceStatus)) + } + return rows +} + +// frontierRowFromStatus projects one resolved status through the SAME actor +// classification and prescription layer `flow next` uses — one resolution +// path, so the dashboard and the advisor can never name different owners for +// the same step. +func frontierRowFromStatus(repo string, status NextStatus) FrontierRow { + row := 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, + PRURL: status.PRURL, NextOperation: status.NextOperation, + Reason: status.Reason, + Blocked: status.VerificationStatus == "BLOCKED", + } + next, err := nextControlFromStatus(repo, status) + if err != nil { + row.Actor = string(NextActorOperator) + row.Blocked = true + return row + } + row.Actor = string(next.Actor) + if next.Prescribed != nil { + row.Prescribed = next.Prescribed.CommandLine() + } + return row +} + +// frontierPosition is the one-word position column: the observed PR phase when +// it is positively known, the stage otherwise. +func frontierPosition(row FrontierRow) string { + if row.PRPhase != "" && row.PRPhase != string(PRPhaseUnknown) { + return row.PRPhase + } + return row.Stage +} + +// FormatFlowFrontier renders the dashboard as fixed-width human-facing lines. +func FormatFlowFrontier(frontier FlowFrontier) string { + var b strings.Builder + if !frontier.Initialized { + b.WriteString("Boatstack is not tracking anything here yet.\n") + return b.String() + } + if len(frontier.Rows) == 0 { + b.WriteString("Frontier: no managed deliveries.\n") + return b.String() + } + fmt.Fprintf(&b, "Frontier: %d for you, %d for the agent, %d complete, %d blocked\n", + frontier.OperatorSteps, frontier.AgentSteps, frontier.TerminalRows, frontier.BlockedRows) + nameWidth, positionWidth := len("FEATURE"), len("POSITION") + for _, row := range frontier.Rows { + if len(frontierLabel(row)) > nameWidth { + nameWidth = len(frontierLabel(row)) + } + if len(frontierPosition(row)) > positionWidth { + positionWidth = len(frontierPosition(row)) + } + } + fmt.Fprintf(&b, "%-*s %-*s %-8s %s\n", nameWidth, "FEATURE", positionWidth, "POSITION", "ACTOR", "NEXT") + for _, row := range frontier.Rows { + next := row.NextOperation + if len(row.PRFailingChecks) > 0 { + next += " (failing: " + strings.Join(row.PRFailingChecks, ", ") + ")" + } + fmt.Fprintf(&b, "%-*s %-*s %-8s %s\n", nameWidth, frontierLabel(row), positionWidth, frontierPosition(row), row.Actor, next) + } + return b.String() +} + +// frontierLabel names a row: the feature, with the slice id appended when the +// delivery has more than one slice so two rows of one delivery stay distinct. +func frontierLabel(row FrontierRow) string { + if row.TotalSlices > 1 && row.Slice != "" { + return row.Feature + "/" + row.Slice + } + return row.Feature +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier_conformance_test.go new file mode 100644 index 000000000..851a2165e --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier_conformance_test.go @@ -0,0 +1,224 @@ +package boatstack + +// control-law: frontier-reports-never-mutates +// +// The frontier dashboard is a pure projection: it reports every managed +// delivery's position and owing actor while performing ZERO writes — not even +// the best-effort terminal PR-state cache the next/recovery resolvers +// maintain. A report that mutates is a report that can lie about what it +// found. Companion laws exercised here: +// stale-delivery-cannot-block-unrelated-feature (one corrupt delivery is one +// blocked row, never a poisoned view) and +// turn-ends-only-at-the-operator-frontier (a frontier row's actor equals the +// flow advisor's actor for the same delivery — one classification path). +// +// Test classes: positive (a multi-delivery store renders every slice with a +// typed actor and live PR position), negative (a corrupt delivery yields one +// blocked row while healthy rows survive), bypass (state files are +// byte-identical after a frontier run, even under a terminal MERGED +// observation), relation (frontier actor == flow next actor per delivery). + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func frontierStateBytes(t *testing.T, repo string, features ...string) map[string]string { + t.Helper() + snapshot := map[string]string{} + for _, feature := range features { + path, err := deliveryStatePath(repo, feature) + if err != nil { + t.Fatal(err) + } + value, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + snapshot[feature] = string(value) + } + return snapshot +} + +// Positive + relation: every delivery renders with a typed actor, the +// published delivery carries its live PR phase, and each row's actor matches +// the flow advisor's actor for the same feature. +func TestFrontierRendersEveryDeliveryWithTypedActor(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "building", "BUILD", 0) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)) + + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + rows := map[string]FrontierRow{} + for _, row := range frontier.Rows { + rows[row.Feature] = row + } + building, ok := rows["building"] + if !ok || building.Stage != "BUILD" || building.Actor != string(NextActorAgent) { + t.Fatalf("unexpected building row: %#v", building) + } + if building.Prescribed == "" { + t.Fatal("an agent-owned row must carry its prescribed command") + } + shipped, ok := rows["shipped"] + if !ok || shipped.Stage != "PUBLISHED" || shipped.PRPhase != string(PRPhaseChecksFailing) { + t.Fatalf("unexpected shipped row: %#v", shipped) + } + if shipped.Actor != string(NextActorOperator) { + t.Fatalf("published-open step belongs to the operator today: %#v", shipped) + } + if frontier.AgentSteps != 1 || frontier.OperatorSteps != 1 || frontier.BlockedRows != 0 { + t.Fatalf("unexpected summary: %#v", frontier) + } + + // Relation: the frontier's actor for each feature equals the advisor's. + for _, feature := range []string{"building", "shipped"} { + next, nextErr := NextControl(repo, feature) + if nextErr != nil { + t.Fatal(nextErr) + } + if string(next.Actor) != rows[feature].Actor { + t.Fatalf("frontier actor %q disagrees with flow next actor %q for %s", rows[feature].Actor, next.Actor, feature) + } + } + + rendered := FormatFlowFrontier(frontier) + if !strings.Contains(rendered, "PR_CHECKS_FAILING") || !strings.Contains(rendered, "failing: unit") { + t.Fatalf("rendered frontier hides the live PR position:\n%s", rendered) + } +} + +// Positive: an active delivery with an earlier published-but-open slice shows +// both balls — the building active slice and the open PR of the earlier slice. +func TestFrontierShowsEarlierPublishedOpenSlices(t *testing.T) { + repo := nextTestRepo(t) + directory := filepath.Join(repo, ".product-loop", "features", "layered") + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(directory, "plan.lock.json") + if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + t.Fatal(err) + } + hash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "layered", PlanLockHash: hash, + ActiveIndex: 1, Slices: []DeliverySlice{ + {ID: "first", Title: "First", Status: "PUBLISHED", PRURL: "https://example.invalid/pr/9", HeadBranch: "feat/phase", PRState: "OPEN"}, + {ID: "second", Title: "Second", Status: "BUILD"}, + }, + }); err != nil { + t.Fatal(err) + } + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)) + + frontier, frontierErr := ResolveFrontier(repo) + if frontierErr != nil { + t.Fatal(frontierErr) + } + if len(frontier.Rows) != 2 { + t.Fatalf("want active + earlier published rows, got %#v", frontier.Rows) + } + var earlier *FrontierRow + for i := range frontier.Rows { + if frontier.Rows[i].Slice == "first" { + earlier = &frontier.Rows[i] + } + } + if earlier == nil || earlier.PRPhase != string(PRPhaseChecksFailing) || earlier.Actor != string(NextActorOperator) { + t.Fatalf("earlier published-open slice not surfaced: %#v", frontier.Rows) + } +} + +// Negative: one corrupt delivery becomes one blocked row; the healthy +// delivery's row survives untouched. +func TestFrontierPartitionsCorruptDeliveries(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "healthy", "BUILD", 0) + writeNextDelivery(t, repo, "corrupt", "BUILD", 0) + statePath, err := deliveryStatePath(repo, "corrupt") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(statePath, []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + if len(frontier.Rows) != 2 || frontier.BlockedRows != 1 || frontier.AgentSteps != 1 { + t.Fatalf("unexpected partition: %#v", frontier) + } + for _, row := range frontier.Rows { + if row.Feature == "corrupt" && (!row.Blocked || row.NextOperation != "discard-delivery") { + t.Fatalf("corrupt delivery not routed to its remedy: %#v", row) + } + if row.Feature == "healthy" && (row.Blocked || row.Actor != string(NextActorAgent)) { + t.Fatalf("healthy delivery poisoned by corrupt neighbor: %#v", row) + } + } +} + +// Bypass: the frontier performs zero writes — the delivery ledger is +// byte-identical after a run, even when the live observation is terminal +// (MERGED), which next/recovery WOULD cache. The report never mutates. +func TestFrontierWritesNothingEvenOnTerminalObservation(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "building", "BUILD", 0) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", "")) + + before := frontierStateBytes(t, repo, "building", "shipped") + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + after := frontierStateBytes(t, repo, "building", "shipped") + for feature, value := range before { + if after[feature] != value { + t.Fatalf("frontier mutated delivery state for %q", feature) + } + } + var shipped FrontierRow + for _, row := range frontier.Rows { + if row.Feature == "shipped" { + shipped = row + } + } + if shipped.Actor != string(NextActorNone) || shipped.Stage != "FEATURE_COMPLETE" { + t.Fatalf("terminal observation misclassified: %#v", shipped) + } +} + +// Failure-state: an uninitialized repository reports an empty, unblocked +// frontier rather than an error. +func TestFrontierOnUninitializedRepository(t *testing.T) { + repo := t.TempDir() + if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + if frontier.Initialized || len(frontier.Rows) != 0 { + t.Fatalf("unexpected frontier: %#v", frontier) + } + if rendered := FormatFlowFrontier(frontier); !strings.Contains(rendered, "not tracking") { + t.Fatalf("unexpected rendering: %s", rendered) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index 7c5430002..53a16c73f 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -144,6 +144,14 @@ 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) +} + +// 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 { _, sliceID, _ := deliveryBranchAndSlice(state) status := NextStatus{ SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go index 56519790c..f2bed7490 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go @@ -197,6 +197,15 @@ func selectRecoveryDelivery(states []DeliveryState, explicitFeature, currentBran func observePublishedPR(repo string, state DeliveryState) publishedPRObservation { branch, _, prURL := deliveryBranchAndSlice(state) + return observePRTarget(repo, prURL, branch) +} + +// observePRTarget performs the single live, read-only PR observation for one +// explicit PR URL or head branch. Split from observePublishedPR so callers +// that must not write anything (the frontier report) and callers that need a +// non-active slice's PR (an earlier published-but-open slice) share the exact +// same observation. +func observePRTarget(repo, prURL, branch string) publishedPRObservation { observation := publishedPRObservation{Lifecycle: "PUBLISHED_UNKNOWN", URL: prURL, Branch: branch, Phase: PRPhaseUnknown} target := prURL if target == "" {