diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a668518..dde8e42 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/5850912e0d947f9fbee5048d9ed6a79a5d614ff1/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/4db21095ddbad477daa4a04cd4d0827d9ba2fb8b/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 a21eb22..bac2ea2 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "16a84cf6a740fe70a920537a534dfb11f00273204d83a9f8f23661dca7d5331d", + "CONTRIBUTING.md": "eac65d45c4f20dd219708eeee3f435d37663fbf23e6383cca97b1324e0d0d781", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -49,16 +49,18 @@ "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", "boatstack/compiled_artifact_resolution_test.go": "0748d67643263e698211eb04d46464e1dd3db15d94537f5fd5092b5aa689745b", - "boatstack/config_documentation_test.go": "aaecea04ee178ecf7872fff23a64014a3968dce1e7624e61a9453567e99969a9", + "boatstack/config_documentation_test.go": "cb0ab6f13162909a0a0b60bddc0dfb10b3492639b336a4d2eb36e819c25cc3be", "boatstack/content_effect_conformance_test.go": "ebf4f6d50af0a76722177c717c79d33921948b9c06bec2e9d062769dce32b8aa", "boatstack/context.go": "02510af176d2d040c0080086f06d1235e76a1d47fef5176f96f740ad18d27660", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "1cbc917eaa7df569f09c71352db157dff743827d5310dccb63acb7be72ccf9d5", + "boatstack/delivery.go": "a9a266d0413b25276d9bcfeb8328ac3ca4b3020ada1f97912251f68b4a9b5019", "boatstack/delivery_boundary_conformance_test.go": "53dde765046420b9119e82034d137742e600019938ed908c608f725d8a0c84c6", "boatstack/delivery_migrate.go": "7566e49f9c1838d4d563866e941c7aacd61ac918c9e886222282398d287ca780", "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", + "boatstack/delivery_terminal.go": "56fd8bec4d2c00ee3757bde5999417e5be763ab83cd7b68d4bccbbdea23af26a", + "boatstack/delivery_terminal_conformance_test.go": "50b88bdfb9bdfc9dc50c11f46276e1eb13eed61618e75b4c951dfa2ac6075df0", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/deliverycontrol_parity_test.go": "f8662cfc35043395a0e1eef8a87051c2120752f38b09c56b78f82896008f1b65", "boatstack/denial.go": "ce77240edafb2589565ff3821b4e2cf8be3cf5884b1467d606265ca877188d3e", @@ -71,11 +73,11 @@ "boatstack/detached_test.go": "6cc70d15baa9a69afacf66ea29ce112efeb166836acb0a52bf9c4bb4c898cee5", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "ca5c284fc658b112f58145e80b2bbfdd33a72b153a97922e2cf8c3f6e98fefb3", + "boatstack/export.go": "12c4e8c3b0692736ead73c613e0410b0ed5f7cb4c6b6a9fcc96e59547f2fe566", "boatstack/export_test.go": "dce5aa3ab5499c82d05859cf86b46dfcee308482491366d83e10ca3fb8605bb6", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", - "boatstack/flow_control.go": "43204356017b837148bd1505779611fbe781097839c2944f41a437a4ec755a82", + "boatstack/flow_control.go": "193dcae29e0611001b33ea23011beeb921d626562a5c733658c585ebca62b7fe", "boatstack/flow_control_test.go": "02d788c83be55ebd79ffc73875bfd019de45325151eb1f70f506980eb8e77f29", "boatstack/flow_drive.go": "a501ceda390dfd3605e22cf7ecfa15f9d50240b3fac6ebb2bb2d80c615d0a9fc", "boatstack/flow_drive_conformance_test.go": "23edea926c271a1f5718fb9dae1da11e4bf03cceb1357290cd61cd8ffb73beda", @@ -164,7 +166,7 @@ "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", "boatstack/reexec_windows.go": "f5335c8c28cb4e89048b058b1c4d12f78644f99acb4f6167ff60e622dfb9e742", "boatstack/references/artifacts.md": "4f27170227ffa2715d632eab4cbdca229726c5fc03c4b0f2af980fa50bb24725", - "boatstack/references/config-schema.md": "eff8586850eca9941df1cea29ac7edb779277ed9bd6d938a1980db5028d28cf4", + "boatstack/references/config-schema.md": "a6860ec7e1cd155e7e36dc465b8cae79de896714751e1c04dfa68d3f5c4a45e0", "boatstack/references/failure-moves.md": "b65ef72035afa6ad0dce589a0b38f84bc40cde3864c9ecf973f08fc687f001c3", "boatstack/references/host-hook-contracts.md": "2a89d44d0e418a53f2e3b6300fed957cdf878f45ea97ce24b55b66065f0eaa1d", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", @@ -175,7 +177,7 @@ "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", "boatstack/run.go": "3127e8c054b80e41413011e423ba9463fe06a7e6c9e1e71750ab98587871d89d", "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", - "boatstack/runtime.go": "7dc5d033ec11bbcf9a4561da66d4d6692a071bc79ac2fe4eb66feaced358d3c3", + "boatstack/runtime.go": "368bb43a0e3042bde2d4bab1b62df560a93f5928384c7c8f5e27e8a836628af4", "boatstack/runtime_cache.go": "e026ffc1906f7e1e98b768bae63e6658164d2826c07169c9121ce0f23c73faf8", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", @@ -209,11 +211,11 @@ "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "f0567d8bb1fdf42d78ca7dac28603042a78e2290487a6fce8518ff16b8307fd2", + "docs/configuration.md": "221f979506a3a9de357e5277f1329c345bf175346ec8dfc8fdd1212fb100dea1", + "docs/evidence-engineered-coding.md": "223d5b3a75c66eccbaa8d4a0b755bfb873fe0684106004b841e8319653e702ef", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "ff6bba756adaa499998016d2e4986c7fdfe7bc6f5d3ae7531bbc4471a6371a8b", + "docs/public-claims.json": "9f640af54667625048eddfbb78dd63a0c055c556affc4c105d186ca164467e2f", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -227,7 +229,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": "73fe60031dd4c5b25b28fbbb29654416359a4040b1d7bf124618aede0e482633", + "labs/diagram-json/plan.lock.json": "030278e2649c63a717a744ef91926fef25fc1648cedef91b4d2edafaf9e8d4ce", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -356,6 +358,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-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", "release-notes/2026-07-28-minimum-app-permissions.md": "9ef97e32e5591966ef34ba23f4e0a7aa14d061a4ac5f72f5f4dcecc9bfb62a89", @@ -367,7 +370,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "5850912e0d947f9fbee5048d9ed6a79a5d614ff1", + "commit": "4db21095ddbad477daa4a04cd4d0827d9ba2fb8b", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/config_documentation_test.go b/boatstack/config_documentation_test.go index 3ea7db1..322b185 100644 --- a/boatstack/config_documentation_test.go +++ b/boatstack/config_documentation_test.go @@ -113,6 +113,7 @@ func TestSerializedConfigurationSurfaceIsDocumentedInternally(t *testing.T) { func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) { want := []string{ "adapters", + "delivery.terminal", "project.commands", "project.context", "project.default_branch", diff --git a/boatstack/delivery.go b/boatstack/delivery.go index c802778..dde618d 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -66,6 +66,13 @@ type DeliveryState struct { RepairAttempt int `json:"repair_attempt,omitempty"` SupersededReceipts []string `json:"superseded_receipts,omitempty"` ParentDelivery string `json:"parent_delivery,omitempty"` + // Goal snapshots the non-default delivery terminal ("merged") this + // delivery was activated under, so a mid-flight config change never + // silently changes an in-progress delivery's goal. Empty means: resolve + // from config at read time (and keeps a default-config state file + // byte-identical to the pre-field format). + // control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config + Goal string `json:"goal,omitempty"` } type DeliveryGateReceipt struct { @@ -377,6 +384,7 @@ func initializeDeliveryState(repo, feature, planPath, lockPath string) error { SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, ActiveIndex: 0, Slices: slices, Mode: "NORMAL", ParentDelivery: strings.TrimSpace(stringValue(plan["parent_delivery"])), + Goal: deliveryGoalSnapshot(repo), }) } @@ -485,6 +493,7 @@ func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliveryS Slices: merged, Mode: "NORMAL", ParentDelivery: existing.ParentDelivery, + Goal: existing.Goal, } } diff --git a/boatstack/delivery_terminal.go b/boatstack/delivery_terminal.go new file mode 100644 index 0000000..7fb2ac3 --- /dev/null +++ b/boatstack/delivery_terminal.go @@ -0,0 +1,71 @@ +package boatstack + +import "strings" + +// The delivery terminal is the standing goal of the flow — the state past +// which nothing more is owed. It resolves in a fixed order: the goal the +// delivery was ACTIVATED under (state.Goal — hysteresis, so a mid-flight +// config change never silently changes an in-progress delivery's goal), then +// the repository config (delivery.terminal), then the published default. +// Every unreadable or invalid input resolves to the narrower published goal: +// a goal is widened only by an explicit, verifiable operator choice. +// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config +type DeliveryTerminal string + +const ( + // TerminalPublished — the flow is done when the slice's PR is open. + TerminalPublished DeliveryTerminal = "published" + // TerminalMerged — the flow keeps naming read-only post-publish steps + // until the PR is observed merged. + TerminalMerged DeliveryTerminal = "merged" +) + +func normalizeDeliveryTerminal(value string) (DeliveryTerminal, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case string(TerminalPublished): + return TerminalPublished, true + case string(TerminalMerged): + return TerminalMerged, true + default: + return "", false + } +} + +// configuredDeliveryTerminal reads the repository's standing terminal from +// the project config. Absent, invalid, or unreadable configuration resolves +// to published — never an error, because the terminal is consulted from +// read-only paths that must not gain a new failure mode. +func configuredDeliveryTerminal(repo string) DeliveryTerminal { + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + if err != nil || config.Delivery == nil { + return TerminalPublished + } + if terminal, ok := normalizeDeliveryTerminal(config.Delivery.Terminal); ok { + return terminal + } + return TerminalPublished +} + +// resolveDeliveryTerminal resolves the terminal for one feature: the +// activation snapshot first, then config, then the default. +func resolveDeliveryTerminal(repo, feature string) DeliveryTerminal { + if strings.TrimSpace(feature) != "" { + if state, err := LoadDeliveryState(repo, feature); err == nil { + if terminal, ok := normalizeDeliveryTerminal(state.Goal); ok { + return terminal + } + } + } + return configuredDeliveryTerminal(repo) +} + +// deliveryGoalSnapshot is what activation records on the new delivery state. +// Only the non-default goal is snapshotted: a default-config delivery keeps +// an empty Goal, so its persisted state is byte-identical to before this +// field existed. +func deliveryGoalSnapshot(repo string) string { + if configuredDeliveryTerminal(repo) == TerminalMerged { + return string(TerminalMerged) + } + return "" +} diff --git a/boatstack/delivery_terminal_conformance_test.go b/boatstack/delivery_terminal_conformance_test.go new file mode 100644 index 0000000..49a7f0c --- /dev/null +++ b/boatstack/delivery_terminal_conformance_test.go @@ -0,0 +1,215 @@ +package boatstack + +// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config +// +// The delivery terminal — the standing goal of the flow — resolves in a fixed +// order: the goal the delivery was ACTIVATED under (state.Goal), then the +// repository config (delivery.terminal), then the published default. The +// default is a hard no-op: with no delivery block (or an explicit +// "published"), every advisory output is identical to the pre-field +// behavior, because a goal this standing is widened only by an explicit +// operator choice, never by an upgrade. Invalid and unreadable inputs +// resolve to the NARROWER published goal (fail-closed direction: the wider +// goal implies more agent-owned steps). +// +// Test classes: positive (config merged → Terminal merged; activation +// snapshots the non-default goal), relation (state.Goal overrides config both +// ways — hysteresis), negative (invalid config value fails validation; +// invalid state.Goal is ignored), bypass (default vs explicit published → +// byte-identical rendering and JSON across the slice lifecycle), failure-state +// (a pre-field state file without goal loads clean and resolves from config). + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTerminalConfig(t *testing.T, repo, terminal string) { + t.Helper() + config := testConfig() + if terminal != "" { + config.Delivery = &DeliveryPolicy{Terminal: terminal} + } + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil { + t.Fatal(err) + } +} + +// Positive: the configured terminal surfaces on the advisory, and only the +// widened goal earns a rendered line. +func TestConfiguredTerminalSurfacesOnAdvisory(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "feature", "BUILD", 0) + writeTerminalConfig(t, repo, "merged") + + next, err := NextControl(repo, "feature") + if err != nil { + t.Fatal(err) + } + if next.Terminal != TerminalMerged { + t.Fatalf("terminal = %q, want merged", next.Terminal) + } + if !strings.Contains(FormatFlowNext(next), "Terminal goal: merged") { + t.Fatal("widened goal must be visible in the rendering") + } +} + +// Relation: the activation snapshot outranks config in BOTH directions — a +// delivery keeps the goal it was started under when config flips mid-flight. +func TestActivationSnapshotOverridesConfig(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "feature", "BUILD", 0) + + // Delivery activated under merged; config later narrowed to published. + state, err := LoadDeliveryState(repo, "feature") + if err != nil { + t.Fatal(err) + } + state.Goal = string(TerminalMerged) + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + writeTerminalConfig(t, repo, "published") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged { + t.Fatalf("mid-flight narrowing changed the goal: %q", got) + } + + // Delivery activated under the default; config later widened to merged. + // The empty snapshot means "resolve from config", so the widening applies. + state.Goal = "" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + writeTerminalConfig(t, repo, "merged") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged { + t.Fatalf("config terminal not hydrated: %q", got) + } + + // An invalid snapshot value is ignored, never trusted. + state.Goal = "deployed" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + writeTerminalConfig(t, repo, "") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalPublished { + t.Fatalf("invalid snapshot must resolve to published: %q", got) + } +} + +// Positive: first activation snapshots the non-default goal onto the new +// delivery state; the default snapshots nothing (byte-stable state files). +func TestActivationSnapshotsNonDefaultGoalOnly(t *testing.T) { + for _, test := range []struct { + terminal string + wantGoal string + }{ + {"merged", "merged"}, + {"published", ""}, + {"", ""}, + } { + repo := nextTestRepo(t) + writeTerminalConfig(t, repo, test.terminal) + if got := deliveryGoalSnapshot(repo); got != test.wantGoal { + t.Fatalf("terminal %q: snapshot = %q, want %q", test.terminal, got, test.wantGoal) + } + } +} + +// Negative: an explicit invalid enum fails config validation fail-closed. +func TestInvalidTerminalRejectedByValidation(t *testing.T) { + config := testConfig() + config.Delivery = &DeliveryPolicy{Terminal: "deployed"} + if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "delivery.terminal") { + t.Fatalf("invalid terminal accepted: %v", err) + } + config.Delivery = &DeliveryPolicy{} + if err := ValidateConfig(config); err != nil { + t.Fatalf("empty terminal must stay legal: %v", err) + } +} + +// Bypass: the default is a hard no-op — for every slice-lifecycle stage, the +// advisory under an absent delivery block is byte-identical (JSON and +// rendering) to an explicit published terminal, and carries no merged +// wording anywhere. +func TestDefaultTerminalIsByteIdenticalToExplicitPublished(t *testing.T) { + for _, stage := range []string{"BUILD", "TEST_PASSED", "REVIEW_PASSED", "PUBLISHED"} { + capture := func(terminal string) (string, string) { + repo := nextTestRepo(t) + activeIndex := 0 + if stage == "PUBLISHED" { + activeIndex = 1 + } + writeNextDelivery(t, repo, "feature", stage, activeIndex) + writeTerminalConfig(t, repo, terminal) + if stage == "PUBLISHED" { + updateRecoveryDelivery(t, repo, "feature", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPass)) + } + next, err := NextControl(repo, "feature") + if err != nil { + t.Fatal(err) + } + // The repo path differs per fixture; blank it out of the compared + // values so only behavior is compared. JSON escapes Windows path + // separators, so the escaped form must be blanked too. + value, err := MarshalJSON(next) + if err != nil { + t.Fatal(err) + } + escaped, err := json.Marshal(repo) + if err != nil { + t.Fatal(err) + } + blank := func(s string) string { + s = strings.ReplaceAll(s, strings.Trim(string(escaped), `"`), "") + return strings.ReplaceAll(s, repo, "") + } + return blank(string(value)), blank(FormatFlowNext(next)) + } + defaultJSON, defaultText := capture("") + publishedJSON, publishedText := capture("published") + if defaultJSON != publishedJSON { + t.Fatalf("stage %s: default and explicit published diverge:\n%s\n---\n%s", stage, defaultJSON, publishedJSON) + } + if defaultText != publishedText { + t.Fatalf("stage %s: rendering diverges:\n%s\n---\n%s", stage, defaultText, publishedText) + } + if strings.Contains(defaultText, "merged (delivery.terminal)") { + t.Fatalf("stage %s: default rendering mentions the widened goal:\n%s", stage, defaultText) + } + } +} + +// Failure-state: a pre-field state file (no goal key) loads clean and +// resolves from config — the migration law is untouched by the additive +// field. +func TestPreFieldStateResolvesFromConfig(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "feature", "BUILD", 0) + statePath, err := deliveryStatePath(repo, "feature") + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "\"goal\"") { + t.Fatal("fixture unexpectedly contains a goal key") + } + writeTerminalConfig(t, repo, "merged") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged { + t.Fatalf("pre-field state did not hydrate from config: %q", got) + } + if _, err := LoadDeliveryState(repo, "feature"); err != nil { + t.Fatalf("pre-field state failed to load: %v", err) + } +} diff --git a/boatstack/export.go b/boatstack/export.go index e9ebdfa..d3a7baa 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -128,6 +128,9 @@ func ValidateConfig(config ProjectConfig) error { if err := validateWorkspaceConfig(config.Workspace); err != nil { return err } + if err := validateDeliveryConfig(config.Delivery); err != nil { + return err + } if policy := strings.TrimSpace(config.Workflow.PRVisualEvidence); policy != "" && policy != "off" && policy != "suggest" && policy != "require" { return fmt.Errorf("workflow.pr_visual_evidence must be \"off\", \"suggest\", or \"require\"") } @@ -137,6 +140,18 @@ func ValidateConfig(config ProjectConfig) error { return nil } +// validateDeliveryConfig rejects only explicit invalid enum values. A nil +// block or empty terminal resolves to the published default at use. +func validateDeliveryConfig(delivery *DeliveryPolicy) error { + if delivery == nil { + return nil + } + if terminal := delivery.Terminal; terminal != "" && terminal != "published" && terminal != "merged" { + return fmt.Errorf("delivery.terminal must be \"published\" or \"merged\"") + } + return nil +} + // validateVisualEvidencePublish rejects only explicit invalid enum values. A nil // block or empty fields are legal and resolve to defaults at use, so configs written // before this block existed remain valid. diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 6d39eaf..887e63b 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -173,9 +173,17 @@ func classifyNextActor(status NextStatus, next FlowNext) NextActor { // authority. Resolved is false when the oracle cannot place the flow, in which // case only the real recommendation is meaningful. type FlowNext struct { - Resolved bool `json:"resolved"` - State deliverycontrol.StateID `json:"state,omitempty"` - Goal deliverycontrol.StateID `json:"goal"` + Resolved bool `json:"resolved"` + State deliverycontrol.StateID `json:"state,omitempty"` + Goal deliverycontrol.StateID `json:"goal"` + // Terminal is the standing goal of this delivery ("published" or + // "merged"), resolved state-then-config-then-default. Goal above remains + // the ORACLE's sink (always StatePublished — the delivery machine has no + // modeled transition past it); Terminal is the operator-facing setpoint + // that decides whether anything is still owed after publish. In this + // slice it is surfaced only; post-publish prescriptions follow. + // control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config + Terminal DeliveryTerminal `json:"terminal"` RecommendedOp string `json:"recommended_operation"` OracleNext deliverycontrol.TransitionID `json:"oracle_next_transition,omitempty"` RemainingCost int `json:"remaining_flow_cost"` @@ -432,6 +440,7 @@ func NextControl(repo, feature string) (FlowNext, error) { func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) { out := FlowNext{ Goal: flowGoal, + Terminal: resolveDeliveryTerminal(repo, status.Feature), RecommendedOp: status.NextOperation, Reason: status.Reason, } @@ -493,6 +502,11 @@ func FormatFlowNext(next FlowNext) string { if next.Actor != "" { fmt.Fprintf(&b, "Next actor: %s\n", next.Actor) } + // The published default renders exactly as before; only the widened goal + // earns a line, so opting in is visible and not opting in changes nothing. + if next.Terminal == TerminalMerged { + fmt.Fprintf(&b, "Terminal goal: merged (delivery.terminal)\n") + } if next.Resolved { fmt.Fprintf(&b, "Flow state: %s -> goal %s\n", next.State, next.Goal) fmt.Fprintf(&b, "Advisory (flow oracle): next %s, remaining cost %d\n", next.OracleNext, next.RemainingCost) diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index 0b4d214..2d29ce7 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -23,6 +23,8 @@ boatstack-config-field:workflow.visual_evidence_publish.mode boatstack-config-field:workflow.visual_evidence_publish.host boatstack-config-field:workflow.visual_evidence_publish.expiry boatstack-config-field:workflow.ignored_deliveries +boatstack-config-field:delivery +boatstack-config-field:delivery.terminal boatstack-config-field:workspace boatstack-config-field:workspace.enabled boatstack-config-field:workspace.mode @@ -53,6 +55,7 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `project` (object, required): General project definition. - `workflow` (object, required): Flags controlling state machine transitions and safety gates. - `workspace` (object, optional): Opt-in per-feature branch or worktree management. +- `delivery` (object, optional): The standing goal of the delivery flow. - `adapters` (array of strings, optional): Enabled host environment adapters. If empty, defaults to enabling all. - `integrations` (object, optional): Installer-owned state for third-party integrations. @@ -91,6 +94,10 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `cleanup_after` (string, optional): `merge` or `ship`. Defaults to `merge`. - `reap` (string, optional): `confirm`, `auto`, or `off`. Defaults to `confirm`. Governs the post-merge sweep that reclaims all terminal (merged or abandoned) Boatstack workspaces at once. `confirm` prompts the operator once when reclaimable workspaces exist; `auto` reclaims them without prompting; `off` disables the sweep and its prompt. +### delivery Fields + +- `terminal` (string, optional): `published` or `merged`. Defaults to `published`. Deterministic goal control: the state a delivery pursues before the flow reports nothing left to do. `published` ends the flow when the slice's pull request is open (the prior behavior, unchanged). `merged` keeps the read-only flow advisors naming post-publish steps until the pull request is observed merged. The goal a delivery is activated under is snapshotted on its state, so changing this value mid-flight never changes an in-progress delivery's goal; every invalid or unreadable value resolves to `published`. + ### adapters Values Supported values are `cursor`, `claude`, `codex`, `gemini`, and `github`. An empty or omitted array enables all supported adapters. diff --git a/boatstack/runtime.go b/boatstack/runtime.go index da16dde..f3a839d 100644 --- a/boatstack/runtime.go +++ b/boatstack/runtime.go @@ -34,10 +34,23 @@ type ProjectConfig struct { Project Project `json:"project"` Workflow Workflow `json:"workflow"` Workspace Workspace `json:"workspace,omitempty"` + Delivery *DeliveryPolicy `json:"delivery,omitempty"` Adapters []string `json:"adapters"` Integrations map[string]IntegrationState `json:"integrations,omitempty"` } +// DeliveryPolicy declares the standing goal of the delivery flow. Terminal +// names the state a delivery pursues before the flow reports "nothing left to +// do": "published" (default — the flow ends when the slice's PR is open) or +// "merged" (the flow keeps naming read-only post-publish steps until the PR +// is observed merged). The nil zero value preserves the prior behavior +// exactly: a goal this standing is widened only by an explicit operator +// choice, never by an upgrade. +// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config +type DeliveryPolicy struct { + Terminal string `json:"terminal,omitempty"` // "" | "published" | "merged" +} + type Project struct { Name string `json:"name"` DefaultBranch string `json:"default_branch,omitempty"` diff --git a/docs/configuration.md b/docs/configuration.md index dd4f4d1..1b12b04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,7 @@ boatstack-user-config-field:workflow.visual_evidence_publish.mode boatstack-user-config-field:workflow.visual_evidence_publish.host boatstack-user-config-field:workflow.visual_evidence_publish.expiry boatstack-user-config-field:workflow.ignored_deliveries +boatstack-user-config-field:delivery.terminal boatstack-user-config-field:workspace.enabled boatstack-user-config-field:workspace.mode boatstack-user-config-field:workspace.cleanup @@ -41,6 +42,7 @@ Boatstack's installer owns the complete `.boatstack-project.json` shape. Edit on | Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. | | Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. | | Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. | +| Pursue the PR to merge, not just to open | `delivery.terminal` | `merged` keeps the read-only flow advisors naming post-publish steps (watch checks, route corrections) until the PR is observed merged; the default `published` ends the flow when the PR is open, exactly as before. | | Use fresh feature workspaces | `workspace.*` | Boatstack creates and cleans branches or linked worktrees under the selected policy. | | Limit generated host surfaces | `adapters` | Export generates only the selected supported adapters. | @@ -143,6 +145,18 @@ List feature slugs here to drop past deliveries from the ambiguity check so hist Workspace `mode` is `worktree` or `branch`; cleanup is `confirm`, `auto`, or `off`; and cleanup eligibility begins after `merge` or `ship`. `reap` is `confirm`, `auto`, or `off`: when a delivery's PR is confirmed merged, Boatstack sweeps every terminal (merged or abandoned) Boatstack workspace at once — `confirm` asks the operator once before reclaiming them, `auto` reclaims without asking, and `off` disables the sweep. Supported adapters are `cursor`, `claude`, `codex`, `gemini`, and `github`. Empty or omitted adapters enable all supported surfaces. +## Delivery goal + +```json +{ + "delivery": { + "terminal": "merged" + } +} +``` + +`delivery.terminal` names the state a delivery pursues before the flow reports nothing left to do. The default `published` ends the flow when the slice's pull request is open, exactly as before. `merged` keeps the read-only flow advisors (`next-status`, `flow next`, `flow frontier`, `flow watch`) naming post-publish steps — watch the checks, route a correction, surface merge eligibility — until the pull request is observed merged. The goal a delivery starts under is snapshotted with the delivery, so changing this value never changes an in-progress delivery's goal. Boatstack itself never merges a pull request under any setting. + ## Installer-owned fields The installer maintains `schema_version`, `project.name`, and integration records. Select gstack or Spec Kit through installation and update flows. Their `requested`, `status`, `version`, and `detail` values are receipts and provenance, not hand-edited workflow switches. diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 964e22e..9727366 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 [`5850912e0d947f9fbee5048d9ed6a79a5d614ff1`](https://github.com/operatorstack/intelligence-flow/tree/5850912e0d947f9fbee5048d9ed6a79a5d614ff1/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 [`4db21095ddbad477daa4a04cd4d0827d9ba2fb8b`](https://github.com/operatorstack/intelligence-flow/tree/4db21095ddbad477daa4a04cd4d0827d9ba2fb8b/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 25100e3..d386e40 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "5850912e0d947f9fbee5048d9ed6a79a5d614ff1", + "source_commit": "4db21095ddbad477daa4a04cd4d0827d9ba2fb8b", "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" }, { "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:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" + "last_verified_version": "source:4db21095ddbad477daa4a04cd4d0827d9ba2fb8b" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 92f2cd4..ea240b6 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": "5850912e0d947f9fbee5048d9ed6a79a5d614ff1", + "source_commit": "4db21095ddbad477daa4a04cd4d0827d9ba2fb8b", "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-configurable-delivery-terminal.md b/release-notes/2026-07-28-configurable-delivery-terminal.md new file mode 100644 index 0000000..6970713 --- /dev/null +++ b/release-notes/2026-07-28-configurable-delivery-terminal.md @@ -0,0 +1,5 @@ +### You can now tell Boatstack the goal is a merged PR, not just an open one + +A new `delivery.terminal` setting names the state a delivery pursues before the flow reports nothing left to do. The default, `published`, keeps today's behavior exactly: the flow ends when your pull request is open. Setting `merged` tells the read-only flow advisors to keep reporting the standing goal until the pull request is observed merged; the prescribed post-publish steps arrive in the next update. + +The goal a delivery starts under is saved with that delivery, so changing the setting mid-flight never silently changes an in-progress delivery's goal, and a fresh session hydrates the goal from your repository instead of you restating it. Invalid or unreadable values always resolve to the narrower `published` goal, and Boatstack itself never merges a pull request under any setting.