From 8969c0ab72485e3ec68874cd8b19c7585371ee00 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Mon, 27 Jul 2026 21:26:52 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 6f60e1b420c8 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 21 +- boatstack/flow_control.go | 113 +++++-- boatstack/flow_prescribe_conformance_test.go | 27 +- boatstack/flow_solutions.go | 300 ++++++++++++++++++ boatstack/next_response.go | 13 + boatstack/safety.go | 130 ++++---- .../solution_closure_conformance_test.go | 227 +++++++++++++ docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 +- labs/diagram-json/plan.lock.json | 2 +- release-notes/2026-07-27-flow-solution-set.md | 7 + 12 files changed, 763 insertions(+), 105 deletions(-) create mode 100644 boatstack/flow_solutions.go create mode 100644 boatstack/solution_closure_conformance_test.go create mode 100644 release-notes/2026-07-27-flow-solution-set.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 835315d..7514ff3 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/7f71d4816931f34405b6c9a6bccca0eb4e29021f/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/6f60e1b420c8236479d1ed76d755372de52620fc/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 3cff492..969440e 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "c84e0cbd421edb15d688e7a0c5a75e00a96da9e0fd7ff736c864a26ede7408b8", + "CONTRIBUTING.md": "581c585e4246b2ef56f1d875a306446d09dc5dedc507b3aee3ee7f22697e52e2", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -71,16 +71,17 @@ "boatstack/export_test.go": "dce5aa3ab5499c82d05859cf86b46dfcee308482491366d83e10ca3fb8605bb6", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", - "boatstack/flow_control.go": "da5759a2588acc8a67b46b480572fe2f00c1a68769bdf9127a4aef7351dcd44a", + "boatstack/flow_control.go": "d1cc9268fafd37a322222b26e041d89d8f95e7ca8c7ea0726679801c2836c536", "boatstack/flow_control_test.go": "02d788c83be55ebd79ffc73875bfd019de45325151eb1f70f506980eb8e77f29", "boatstack/flow_drive.go": "a501ceda390dfd3605e22cf7ecfa15f9d50240b3fac6ebb2bb2d80c615d0a9fc", "boatstack/flow_drive_conformance_test.go": "23edea926c271a1f5718fb9dae1da11e4bf03cceb1357290cd61cd8ffb73beda", "boatstack/flow_guard.go": "dd18524d95f4a220cfd3d11b11003dacc52120785ee0ccdbeceb2307fab55872", "boatstack/flow_guard_test.go": "8ba75f11ddd080427c15bd7e25f7d03c1b746a2f587c212cea0e710337d1c0e1", "boatstack/flow_planning_prescribe_conformance_test.go": "c2fa2566b0676f34a777764ade87a0670d41413d052a9a339e01bdb6a9a8699b", - "boatstack/flow_prescribe_conformance_test.go": "e2287aa079b7aafaf822e1f752a1bb5017acda811363e576eeff793347d0d5c8", + "boatstack/flow_prescribe_conformance_test.go": "a307e26c03df2ac530f6a120fa021bd971921df5fb3aec7228578489eee74611", "boatstack/flow_report.go": "9e58cec51c6c903847f3ebc79cd6bf25e2f91d14b81811e82e5f4e026a7b71a3", "boatstack/flow_report_test.go": "eae00f2b8ead4f1ec20e1f1bc47db4c53a36048bb84eca9bea4f1a2105bdcdde", + "boatstack/flow_solutions.go": "78d639ebd1012326f3e7e67cb5a4068de24898148a29db9176a1237af932f6aa", "boatstack/flow_tasks.go": "690db05d345dabdb24965015c94198aa3f93d2d9691599ae8e6a2ac3aafb9d44", "boatstack/flow_tasks_conformance_test.go": "fbc4d672536051f8e20a2e6c07c5b10f78cd84fc04765eee11cbed7a459b8e4b", "boatstack/flow_trace.go": "1a69f9dd53db313235c74f7ae4f821a6aed023f780aea57210c721ea8f11f2fc", @@ -126,7 +127,7 @@ "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", "boatstack/next.go": "c133dbf907dc86ca5aacec154f6e4a63e7aa9f5f0ebdd76e1803375b5700675a", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", - "boatstack/next_response.go": "62777e52556098ca8a40197a5d7d42fddd49d5e89f7b9e1884c23bf2cdf07599", + "boatstack/next_response.go": "f63f9593cf4adb1217cc65337fe737c4e5ef07b9c6f311641a77269b2c264b6b", "boatstack/next_response_conformance_test.go": "be4f3bc7507abfb0ae9f86310eb29e34b166dcc40b6fa103e05babb81f2bd928", "boatstack/next_test.go": "6b5ec46ecf1a197d7644846cecbb6d99873a06b7c4e5562772b5016fa0a4cb11", "boatstack/operation.go": "073113e1e7b6349417e70b704bd1a342b460604cbd97a7fab06b1a6494604112", @@ -167,12 +168,13 @@ "boatstack/runtime_cache.go": "e026ffc1906f7e1e98b768bae63e6658164d2826c07169c9121ce0f23c73faf8", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "405782eba91a1718a061faa65ff52c10d345cbf3bccf5ecd2cb8ed45d5df9c00", + "boatstack/safety.go": "0d04805e834c9cf10ba59b823fa1dd194c226fdfcd0d67889953cb38b6305653", "boatstack/safety_corpus_test.go": "e7d8c493d8cee957e4590f2c9034d4aa4af08bdcbe02c9889d0d98a0168bbcf9", "boatstack/safety_test.go": "500ad53cd5e3a700553eb781d9eaf4028ae27478796759a76bfd57321fff5a7c", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", + "boatstack/solution_closure_conformance_test.go": "f73e6748dac373e2a10bc9269c2f2e060bd220bb4113bd0d5ff66ca4c8e91a54", "boatstack/statemap.go": "39ff3a7a7254ec5fde8340551fa92a82aac3f00a48bea34c94192823dcb41337", "boatstack/statemap_conformance_test.go": "38950377d10b97f4223b79cdb16b17c29a6334f8f2a9a4d826ce12cbd292aa57", "boatstack/supervisory_control_test.go": "c7ea4bcd678e8ec211dac772c834981c4e21762914be2770a5e181bc24605e06", @@ -197,10 +199,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "8722b287bb91f5fd0209d61f0594672ab026ebe9f8f70b65f8df5214c4451d95", + "docs/evidence-engineered-coding.md": "d8fce9c9a21e9ddb382811bfe397edc526360c1ebe4e8ec4f5e603436e1082b8", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "6e804e45d843599b44b7f7733064ebf1a7799afc5debe7b694b5a0d4b1298edd", + "docs/public-claims.json": "4932a0b1c1a59b3d75fa3facc206b8ba94720079b7de8f1c01ab10c0458d3df4", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -214,7 +216,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": "2c2fbf7ea885339dad90e362725a2ffe8b0edec513d90f4bb5de5c7feede47ea", + "labs/diagram-json/plan.lock.json": "76555eec4fcf58509beb849db669a1a924d0c9f123a784b51876ed1f9a92fd34", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -333,6 +335,7 @@ "release-notes/2026-07-27-discoverable-planning-errors.md": "d8099d1a6cd1805c3fcd446d9fa95739dec93e57ff421ecf4f99562b143c9966", "release-notes/2026-07-27-document-content-is-data.md": "c6a35c222bf53ba465fadf21e7e95e1764b52e12e41852e51584ce9cb6513f4a", "release-notes/2026-07-27-first-planning-write-owned-channel.md": "7a37e7abf7fd5f8612aca4323d55af1748c9e668bb294518619a1c39e195309f", + "release-notes/2026-07-27-flow-solution-set.md": "e138c333d515f8d2b7003334353ade2203b752cf726f2ec3158c657310b00014", "release-notes/2026-07-27-guard-dual-reward-corpus.md": "6bec0385c6c553f00517259821e502796ca1b1907aeab718a287560e3e0fa0d6", "release-notes/2026-07-27-helper-rendered-next-response.md": "08f53d8ade77c0ed6ffe7b63330c67ec0b88a5c71a466ed103f3b66d0952e4e2", "release-notes/2026-07-27-invalid-delivery-block-actionable.md": "8fac8e3921e2285291703efa46e624b72cb5bac1b8492beca4c4b633abb5ba16", @@ -344,7 +347,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "7f71d4816931f34405b6c9a6bccca0eb4e29021f", + "commit": "6f60e1b420c8236479d1ed76d755372de52620fc", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 4a6e9e5..d4953cf 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -92,6 +92,8 @@ const ( MarkerPlanningCheckPlan = deliverycontrol.TransitionID("planning.check_plan") MarkerPlanningActivate = deliverycontrol.TransitionID("planning.activate") MarkerPlanningWorkspace = deliverycontrol.TransitionID("planning.workspace_cut") + MarkerPlanningWrite = deliverycontrol.TransitionID("planning.planning_write") + MarkerPlanningApproval = deliverycontrol.TransitionID("planning.record_approval") MarkerRecoveryDoctor = deliverycontrol.TransitionID("recovery.doctor") MarkerRecoveryDiscard = deliverycontrol.TransitionID("recovery.discard_delivery") MarkerRecoveryRepair = deliverycontrol.TransitionID("recovery.repair_state") @@ -130,6 +132,12 @@ type FlowNext struct { // completion state and prescribes no command — coding work is never a modeled // transition, only an ordered pointer. SubAction *FlowTask `json:"sub_action,omitempty"` + // Alternatives are the other admissible next commands from this position — + // the computed solution set minus the single Prescribed primary. They let a + // caller PICK a legal move instead of deriving one from the law's prose. + // Advisory, never a second primary: the rendering keeps exactly one Run line. + // control-law: solution-set-derives-from-guard-declarations + Alternatives []PrescribedCommand `json:"alternatives,omitempty"` } // PrescribedCommand is the exact next command that makes the oracle's lowest-cost @@ -188,9 +196,30 @@ func prescribeCommand(repo, feature string, status NextStatus, transition delive preview := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "features", feature, "pr.md") cmd.Args = append(repoArgs, "--preview", preview, "--action", "open") cmd.RequiresHumanInput = []string{"--preview-fingerprint"} + case deliverycontrol.TransitionID("delivery.record_change"): + if feature == "" { + return nil, false + } + // Rework: the correction facts (what changed, where it was observed, and + // its classification) are human knowledge; owe them, never fabricate them. + cmd.Args = append(repoArgs, "--feature", feature) + if status.ActiveSlice != "" { + cmd.Args = append(cmd.Args, "--slice", status.ActiveSlice) + } + cmd.RequiresHumanInput = []string{"--message", "--source-stage", "--classification"} + case deliverycontrol.TransitionID("delivery.undo"): + // The mutation id names WHICH receipt to reverse — a human decision. + cmd.Args = repoArgs + cmd.RequiresHumanInput = []string{"--mutation"} + case deliverycontrol.TransitionID("delivery.discard_delivery"): + if feature == "" { + return nil, false + } + cmd.Args = append(repoArgs, "--feature", feature) default: - // Recovery/observe/rework transitions are not prescribed as a forward move; - // emit nothing rather than a command whose arguments we cannot derive. + // Recovery/observe transitions outside the set above are not prescribed as + // a forward move; emit nothing rather than a command whose arguments we + // cannot derive. return nil, false } cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0 @@ -209,12 +238,18 @@ func prescribeCommand(repo, feature string, status NextStatus, transition delive // state"), not an execution grant: markers are off the auto-drive allowlist // and have no executor, so the driver always prescribes-and-stops on them. // control-law: prescriptive-closure-every-stage-names-a-runnable-command +// planningFeatureDir is the single joined form of a feature's planning +// directory used by the prescription layer and the solution-set enumerator. +func planningFeatureDir(repo, feature string) string { + return filepath.Join(repo, ".product-loop", "features", feature) +} + func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, string) { var repoArgs []string if repo != "" && repo != "." { repoArgs = []string{"--repo", repo} } - featureDir := filepath.Join(repo, ".product-loop", "features", status.Feature) + featureDir := planningFeatureDir(repo, status.Feature) finish := func(cmd *PrescribedCommand, followUp string) (*PrescribedCommand, string) { cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0 return cmd, followUp @@ -233,8 +268,8 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri }, "Then run auto-plan with the validated SOURCE_PLAN path; author every feature artifact through `boatstack-helper planning-write` (document on stdin).") case "DRAFT_PLAN": return finish(&PrescribedCommand{ - Verb: "check-plan", - Args: []string{"--plan", filepath.Join(featureDir, "plan.md")}, + Verb: "check-plan", + Args: []string{"--plan", filepath.Join(featureDir, "plan.md")}, Transition: MarkerPlanningCheckPlan, }, "After the check passes, present the plan for approval and record it with `record-approval` using the exact PLAN_FINGERPRINT it printed.") case "APPROVED", "POLICY_READY": @@ -242,23 +277,10 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri // needed, otherwise activation. "build" is an operation name, not a verb; // activate-plan is the build operation's first concrete command. if status.NextOperation == "workspace-cut" { - return finish(&PrescribedCommand{ - Verb: "workspace-cut", - Args: append(repoArgs, "--feature", status.Feature), - Transition: MarkerPlanningWorkspace, - }, "Then activate the plan from the fresh workspace with `activate-plan`.") + return finish(buildWorkspaceCut(repoArgs, status.Feature), + "Then activate the plan from the fresh workspace with `activate-plan`.") } - args := []string{ - "--plan", filepath.Join(featureDir, "plan.md"), - "--out-dir", filepath.Join(featureDir, "compiled"), - "--output", filepath.Join(featureDir, "plan.lock.json"), - } - if status.ObservedStage == "APPROVED" { - args = append(args, "--approval", filepath.Join(featureDir, "approval.md")) - } - return finish(&PrescribedCommand{ - Verb: "activate-plan", Args: args, Transition: MarkerPlanningActivate, - }, "") + return finish(buildActivatePlan(featureDir, status.ObservedStage), "") case "INVALID_STATE": switch status.NextOperation { case "doctor": @@ -296,6 +318,30 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri } } +// buildWorkspaceCut and buildActivatePlan are the single assembly points for +// their commands, shared by prescribePlanning (the primary) and the solution-set +// enumerator (the alternatives) so the two can never drift apart. +// control-law: solution-set-derives-from-guard-declarations +func buildWorkspaceCut(repoArgs []string, feature string) *PrescribedCommand { + return &PrescribedCommand{ + Verb: "workspace-cut", + Args: append(append([]string{}, repoArgs...), "--feature", feature), + Transition: MarkerPlanningWorkspace, + } +} + +func buildActivatePlan(featureDir, stage string) *PrescribedCommand { + args := []string{ + "--plan", filepath.Join(featureDir, "plan.md"), + "--out-dir", filepath.Join(featureDir, "compiled"), + "--output", filepath.Join(featureDir, "plan.lock.json"), + } + if stage == "APPROVED" { + args = append(args, "--approval", filepath.Join(featureDir, "approval.md")) + } + return &PrescribedCommand{Verb: "activate-plan", Args: args, Transition: MarkerPlanningActivate} +} + // NextControl composes the authoritative read-only recommendation (ResolveNext) // with the deterministic oracle to advise the lowest-cost next move toward a // published delivery. It performs no mutation and is safe to call at any time. @@ -330,6 +376,7 @@ func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) { out.Prescribed = cmd out.FollowUp = followUp } + out.Alternatives = alternativesFor(repo, status, out) return out, nil } out.State = state @@ -354,6 +401,7 @@ func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) { } } } + out.Alternatives = alternativesFor(repo, status, out) return out, nil } @@ -389,9 +437,32 @@ func FormatFlowNext(next FlowNext) string { } else { fmt.Fprintf(&b, "Flow state: unresolved (no oracle advisory; follow the recommended operation above)\n") } + writeAlternatives(&b, next.Alternatives) return b.String() } +// writeAlternatives renders the solution set's other legal moves as ONE line of +// verbs with a short purpose gloss — never a second Run line, so the response +// contract's single primary action holds. +// control-law: solution-set-derives-from-guard-declarations +func writeAlternatives(b *strings.Builder, alternatives []PrescribedCommand) { + if len(alternatives) == 0 { + return + } + shown := alternatives + if len(shown) > solutionSetTextCap { + shown = shown[:solutionSetTextCap] + } + labels := make([]string, 0, len(shown)) + for _, alt := range shown { + labels = append(labels, alt.Verb+" ("+solutionGloss(alt.Transition)+")") + } + fmt.Fprintf(b, "Also legal from here: %s\n", strings.Join(labels, ", ")) + if len(alternatives) > len(shown) { + fmt.Fprintf(b, " (%d more in `flow next --json` under alternatives)\n", len(alternatives)-len(shown)) + } +} + // writePrescribed renders the Run line and its owed-input annotation for a // prescribed command, shared by the oracle and pre-activation branches. func writePrescribed(b *strings.Builder, p *PrescribedCommand) { diff --git a/boatstack/flow_prescribe_conformance_test.go b/boatstack/flow_prescribe_conformance_test.go index 039c5a5..2605a0f 100644 --- a/boatstack/flow_prescribe_conformance_test.go +++ b/boatstack/flow_prescribe_conformance_test.go @@ -124,15 +124,38 @@ func TestPrescribeNeverFabricatesHumanInput(t *testing.T) { } } -// Failure-state: a transition that is not a faithfully-assemblable forward move +// Failure-state: a transition that is not a faithfully-assemblable move // prescribes nothing — the caller emits no command rather than a guess. +// Observation rows are assembled by prescribeObserve, never here; unknown +// transitions are never assembled anywhere. (delivery.undo, record_change, and +// discard_delivery ARE assemblable since the solution set enumerates every +// legal edge; their fidelity is held by the closure conformance sweeps.) func TestPrescribeEmitsNothingForUnassemblableTransition(t *testing.T) { for _, id := range []deliverycontrol.TransitionID{ - "delivery.undo", "delivery.record_change", "delivery.discard_delivery", "delivery.status", "delivery.recovery_status", "not.a.transition", } { if cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), id); ok { t.Errorf("transition %s should not be prescribed as a forward move; got %+v", id, cmd) } } + // The rework/recovery edges owe exactly their human facts, never fabricated. + for id, owed := range map[deliverycontrol.TransitionID][]string{ + "delivery.record_change": {"--message", "--source-stage", "--classification"}, + "delivery.undo": {"--mutation"}, + "delivery.discard_delivery": nil, + } { + cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), id) + if !ok { + t.Fatalf("edge %s must be assemblable for the solution set", id) + } + if len(cmd.RequiresHumanInput) != len(owed) { + t.Errorf("%s owes %v, got %v", id, owed, cmd.RequiresHumanInput) + continue + } + for i, flag := range owed { + if cmd.RequiresHumanInput[i] != flag { + t.Errorf("%s owes %v, got %v", id, owed, cmd.RequiresHumanInput) + } + } + } } diff --git a/boatstack/flow_solutions.go b/boatstack/flow_solutions.go new file mode 100644 index 0000000..0e926c1 --- /dev/null +++ b/boatstack/flow_solutions.go @@ -0,0 +1,300 @@ +package boatstack + +import ( + "path/filepath" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// A law states what is admissible; a weak model cannot always derive a +// compliant action from that statement. The solution set is the law compiled +// into its concrete admissible actions, so a caller PICKS a legal move instead +// of deriving one. It is computed from the same declarations the guard +// enforces — the transition registry, the guard's stage-verb tables, the +// planning prescription layer — never from a hand-written list, so it cannot +// drift from the law (the oracle-as-advisor rule: the checker is exposed as a +// constructive advisor at the authoring boundary, not only as a terminal gate). +// control-law: solution-set-derives-from-guard-declarations + +// solutionSetCap bounds the structured payload; solutionSetTextCap bounds the +// plain-text rendering so the response contract stays scannable. +const ( + solutionSetCap = 8 + solutionSetTextCap = 3 +) + +// SolutionSet is the computed enumeration of admissible next commands from a +// flow position. Options are ordered most-productive first: mutations by total +// remaining flow cost through the move, then observations. +type SolutionSet struct { + Basis string `json:"basis"` + Stage string `json:"stage,omitempty"` + State deliverycontrol.StateID `json:"state,omitempty"` + Options []PrescribedCommand `json:"options"` + Truncated bool `json:"truncated,omitempty"` +} + +// enumerateFlowSolutions computes the solution set for a flow position: the +// delivery graph's out-edges when the oracle resolves the state, the guard's +// pre-activation stage tables when it does not. +func enumerateFlowSolutions(repo string, status NextStatus, next FlowNext) SolutionSet { + if next.Resolved { + return enumerateDeliverySolutions(repo, status, next.State) + } + return enumeratePlanningSolutions(repo, status, next) +} + +// alternativesFor is the FlowNext carrier hook: the full solution set minus the +// single Prescribed primary. Advisory only — it adds no verb the registry or +// the guard tables do not already admit. +func alternativesFor(repo string, status NextStatus, next FlowNext) []PrescribedCommand { + set := enumerateFlowSolutions(repo, status, next) + if next.Prescribed == nil { + return set.Options + } + primary := prescriptionKey(*next.Prescribed) + options := make([]PrescribedCommand, 0, len(set.Options)) + for _, option := range set.Options { + if prescriptionKey(option) == primary { + continue + } + options = append(options, option) + } + return options +} + +// enumerateDeliverySolutions enumerates from a resolved delivery state: every +// registry out-edge that can be assembled faithfully, ordered by the total +// remaining cost through the edge (edge cost + shortest path from its target to +// the goal, unreachable targets last, ties by transition ID), then the observe +// rows admissible from this state. ignore-delivery is deliberately absent: it +// is a policy filter over ResolveNext, not a move on the delivery walk. +func enumerateDeliverySolutions(repo string, status NextStatus, state deliverycontrol.StateID) SolutionSet { + set := SolutionSet{Basis: "flow-position", State: state} + graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights()) + + type scoredEdge struct { + edge deliverycontrol.Edge + total int + reachable bool + } + edges := make([]scoredEdge, 0) + for _, edge := range graph.Out(state) { + scored := scoredEdge{edge: edge} + if path := graph.ShortestFlow(edge.To, flowGoal); path.Resolution == deliverycontrol.Resolved { + scored.reachable = true + scored.total = edge.Cost + path.Cost + } + edges = append(edges, scored) + } + sort.SliceStable(edges, func(i, j int) bool { + if edges[i].reachable != edges[j].reachable { + return edges[i].reachable + } + if edges[i].total != edges[j].total { + return edges[i].total < edges[j].total + } + return edges[i].edge.Transition < edges[j].edge.Transition + }) + for _, scored := range edges { + if cmd, ok := prescribeCommand(repo, status.Feature, status, scored.edge.Transition); ok { + appendSolution(&set, *cmd) + } + } + + for _, descriptor := range deliverycontrol.Transitions() { + if descriptor.To != "" { + continue + } + if descriptor.CostClass != deliverycontrol.CostObserve && descriptor.CostClass != deliverycontrol.CostQuery { + continue + } + if descriptor.From != nil && !transitionAccepts(descriptor, state) { + continue + } + if cmd, ok := prescribeObserve(repo, status.Feature, descriptor); ok { + appendSolution(&set, *cmd) + } + } + return set +} + +// enumeratePlanningSolutions enumerates from a pre-activation or blocked stage: +// the stage's prescribed primary, the other mutation verbs the guard's +// stageMutationVerbs table admits there, and the planning-relevant read-only +// helpers. Every mutation verb comes from the SAME table the guard checks, so +// this list is closed under guard admission by construction. +func enumeratePlanningSolutions(repo string, status NextStatus, next FlowNext) SolutionSet { + set := SolutionSet{Basis: "flow-position", Stage: status.ObservedStage} + if next.Prescribed != nil { + appendSolution(&set, *next.Prescribed) + } + for _, verb := range stageMutationVerbs[status.ObservedStage] { + if cmd, ok := prescribePlanningVerb(repo, status, verb); ok { + appendSolution(&set, *cmd) + } + } + // The stage-independent recovery verbs relevant to a blocked plan, then the + // position observers. Both are admitted at every stage by the guard tables. + if status.ObservedStage == "INVALID_STATE" { + if cmd, ok := prescribePlanningVerb(repo, status, "repair-state"); ok { + appendSolution(&set, *cmd) + } + } + if descriptor, ok := deliverycontrol.Transition(deliverycontrol.TransitionID("delivery.next")); ok { + if cmd, ok := prescribeObserve(repo, "", descriptor); ok { + appendSolution(&set, *cmd) + } + } + appendSolution(&set, PrescribedCommand{ + Verb: "doctor", Args: repoFlagArgs(repo), AutoDerivable: true, + Transition: MarkerRecoveryDoctor, + }) + return set +} + +// prescribePlanningVerb assembles the faithful command for one admissible +// pre-activation verb. Shapes shared with prescribePlanning go through the +// same builders; anything it cannot assemble faithfully is omitted, never +// guessed. +func prescribePlanningVerb(repo string, status NextStatus, verb string) (*PrescribedCommand, bool) { + repoArgs := repoFlagArgs(repo) + featureDir := planningFeatureDir(repo, status.Feature) + var cmd *PrescribedCommand + switch verb { + case "planning-write": + cmd = &PrescribedCommand{Verb: verb, Args: repoArgs, Transition: MarkerPlanningWrite} + if status.Feature != "" { + cmd.Args = append(cmd.Args, "--feature", status.Feature) + } else { + cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, "--feature") + } + // The artifact name and its Markdown (stdin) are authored content — owed. + cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, "--artifact") + case "record-approval": + if status.Feature == "" { + return nil, false + } + cmd = &PrescribedCommand{ + Verb: verb, + Args: []string{"--plan", filepath.Join(featureDir, "plan.md")}, + // Approval facts are a human act; never fabricated. + RequiresHumanInput: []string{"--approved-by", "--approved-at", "--fingerprint"}, + Transition: MarkerPlanningApproval, + } + case "activate-plan": + if status.Feature == "" { + return nil, false + } + cmd = buildActivatePlan(featureDir, status.ObservedStage) + case "workspace-cut": + if status.Feature == "" { + return nil, false + } + cmd = buildWorkspaceCut(repoArgs, status.Feature) + case "repair-state": + cmd = &PrescribedCommand{Verb: verb, Args: repoArgs, Transition: MarkerRecoveryRepair} + if status.Feature != "" { + cmd.Args = append(cmd.Args, "--feature", status.Feature) + } + default: + return nil, false + } + cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0 + return cmd, true +} + +// prescribeObserve assembles a read-only observation command for a registry row +// with no target state. Observations never outrank a mutation in the pick list, +// but they must be present — inspecting is the legal move that costs nothing +// when the next mutation is not yet clear. +func prescribeObserve(repo, feature string, descriptor deliverycontrol.TransitionDescriptor) (*PrescribedCommand, bool) { + if descriptor.CLIVerb == "" { + return nil, false + } + cmd := &PrescribedCommand{Verb: descriptor.CLIVerb, Transition: descriptor.ID, AutoDerivable: true} + cmd.Args = repoFlagArgs(repo) + switch descriptor.ID { + case deliverycontrol.TransitionID("delivery.status"), deliverycontrol.TransitionID("delivery.check_ship"): + if feature == "" { + return nil, false + } + cmd.Args = append(cmd.Args, "--feature", feature) + case deliverycontrol.TransitionID("delivery.next"), deliverycontrol.TransitionID("delivery.recovery_status"): + if feature != "" { + cmd.Args = append(cmd.Args, "--feature", feature) + } + default: + return nil, false + } + return cmd, true +} + +// appendSolution adds an option, deduplicating on the rendered verb+args +// identity and enforcing the structured cap. +func appendSolution(set *SolutionSet, option PrescribedCommand) { + key := prescriptionKey(option) + for _, existing := range set.Options { + if prescriptionKey(existing) == key { + return + } + } + if len(set.Options) >= solutionSetCap { + set.Truncated = true + return + } + set.Options = append(set.Options, option) +} + +// prescriptionKey is the dedup identity of a prescribed command. +func prescriptionKey(p PrescribedCommand) string { + return p.Verb + "\x00" + strings.Join(p.Args, "\x00") +} + +// repoFlagArgs mirrors the prescription layer's convention: --repo appears only +// when it differs from the default working directory. +func repoFlagArgs(repo string) []string { + if repo != "" && repo != "." { + return []string{"--repo", repo} + } + return nil +} + +// transitionAccepts reports whether a registry row's From set contains a state. +func transitionAccepts(descriptor deliverycontrol.TransitionDescriptor, state deliverycontrol.StateID) bool { + for _, from := range descriptor.From { + if from == state { + return true + } + } + return false +} + +// solutionGloss names a move's purpose in one STE word or two, keyed by the +// transition family — used only in the compact text rendering. +func solutionGloss(transition deliverycontrol.TransitionID) string { + switch transition { + case deliverycontrol.TransitionID("delivery.record_gate_test"): + return "record test gate" + case deliverycontrol.TransitionID("delivery.record_gate_review"): + return "record review gate" + case deliverycontrol.TransitionID("delivery.record_change"): + return "rework" + case deliverycontrol.TransitionID("delivery.publish"): + return "publish" + case deliverycontrol.TransitionID("delivery.undo"): + return "reverse" + case deliverycontrol.TransitionID("delivery.discard_delivery"), MarkerRecoveryDiscard: + return "abandon" + case deliverycontrol.TransitionID("delivery.repair_state"), MarkerRecoveryRepair: + return "repair" + case MarkerRecoveryDoctor: + return "diagnose" + } + if strings.HasPrefix(string(transition), "planning.") { + return "plan" + } + return "inspect" +} diff --git a/boatstack/next_response.go b/boatstack/next_response.go index f19703a..607a92a 100644 --- a/boatstack/next_response.go +++ b/boatstack/next_response.go @@ -44,6 +44,19 @@ func RenderNextStatusResponse(repo string, status NextStatus) (string, error) { } fmt.Fprintf(&b, "Next sub-action: %s%s (from the plan task DAG; see `flow tasks`)\n", next.SubAction.ID, title) } + // The solution set as one short secondary sentence — the contract allows + // exactly one, and the verbs appear only in command position. + // control-law: solution-set-derives-from-guard-declarations + if len(next.Alternatives) > 0 { + verbs := make([]string, 0, solutionSetTextCap) + for _, alt := range next.Alternatives { + if len(verbs) == solutionSetTextCap { + break + } + verbs = append(verbs, "`"+alt.Verb+"`") + } + fmt.Fprintf(&b, "Other legal moves: %s.\n", strings.Join(verbs, ", ")) + } case status.ObservedStage == "FEATURE_COMPLETE", status.ObservedStage == "PUBLISHED" && status.Lifecycle == "PUBLISHED_MERGED": b.WriteString("No action required.\n") diff --git a/boatstack/safety.go b/boatstack/safety.go index 4962930..f176863 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -142,6 +142,71 @@ var externalReadOnlyToolPattern = regexp.MustCompile(`(?i)(?:^|[_-])(?:get|list| // control-law: first-planning-write-uses-the-owned-channel var featuresCommandPathPattern = regexp.MustCompile(`(?i)(?:^|[\s"'=(])((?:\./)?\.product-loop[/\\]features[/\\][^\s"';&|)]+)`) +// The guard's admissible-verb knowledge for the pre-activation interlock lives +// in the three tables below so that the guard decision (controlledPhaseTransition) +// and the solution-set enumerator (flow_solutions.go) read ONE declaration. A +// verb admitted here is exactly a verb the enumerator may present as a legal +// pick — the tables ARE the interlock law's computable solution set, and the +// closure conformance sweep holds the two consumers to the same rows. +// control-law: solution-set-derives-from-guard-declarations + +// readOnlyHelperVerbs never mutate workflow state and are admitted at every +// stage. +var readOnlyHelperVerbs = map[string]bool{ + "check-plan": true, "check-source-plan": true, "next-status": true, "delivery-status": true, + "recovery-status": true, "repair-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, + "doctor": true, "version": true, "mutation-status": true, +} + +// stageIndependentRecoveryVerbs mutate but self-guard, and are admitted at +// every stage — the Coreachability invariant: the states that prescribe a +// recovery verb must be a subset of the states that verb accepts, and the verb +// must be reachable in-tool. +// - repair-state is the guard-prescribed recovery for a workflow stuck at +// INVALID_STATE because of an unregistered malformed draft. Those findings +// carry an empty stage, so it is stage-independent. It quarantines the +// draft; RepairState self-guards, refusing any registered, published, or +// tracked directory. +// - undo is the bounded actuator that reverses a Boatstack-generated managed +// artifact by re-applying its receipt's inverse through the transactional +// mutation boundary. It self-guards (UndoManagedMutation refuses to strand +// delivery state; the boundary's stale-base precondition refuses to clobber +// later work). +// - workspace-reap and workspace-cleanup reclaim finished managed worktrees +// and branches. They mutate only Boatstack-owned workspace bookkeeping and +// self-guard (refusing the base branch, the current worktree, and unmerged +// or dirty work without an explicit force). Without them the pre-activation +// interlock would deny post-merge cleanup and force raw, denied Git. +// - discard-delivery clears stuck or unverifiable managed delivery state (and +// orphaned feature artifacts). It is the verb the resolver prescribes for +// those causes, so it must be admitted wherever it is prescribed. It +// self-guards (DiscardDelivery archives rather than deletes and refuses +// published state without --force). Without this admission the resolver +// could name discard-delivery while the guard denied it — a fail-closed +// state with no reachable exit. +var stageIndependentRecoveryVerbs = map[string]bool{ + "repair-state": true, "undo": true, "workspace-reap": true, "workspace-cleanup": true, "discard-delivery": true, +} + +// stageMutationVerbs maps each pre-activation stage to the mutation verbs the +// interlock admits there. NOT_STARTED deliberately omits record-approval — +// there is no plan to approve yet; the first-write latch denies raw writes into +// .product-loop/features/ before any candidate exists and prescribes +// planning-write, and Coreachability requires the guard to admit that verb at +// the very stage that names it. +var stageMutationVerbs = map[string][]string{ + // No pre-activation finding carries NOT_INITIALIZED today (the interlock has + // nothing to protect before init), but the prescription layer names init + // there — declaring the row keeps the admission tables total over every + // stage the solution set can emit (guard-never-prescribes-what-it-would-deny). + "NOT_INITIALIZED": {"init"}, + "DRAFT_PLAN": {"planning-write", "record-approval"}, + "INVALID_STATE": {"planning-write", "record-approval"}, + "APPROVED": {"activate-plan", "workspace-cut"}, + "POLICY_READY": {"activate-plan", "workspace-cut"}, + "NOT_STARTED": {"planning-write"}, +} + func controlledPhaseTransition(command, stage string) bool { if strings.ContainsAny(command, "\n`><;&|") || strings.Contains(command, "$(") { return false @@ -154,69 +219,18 @@ func controlledPhaseTransition(command, stage string) bool { if executable != "boatstack-helper" { return false } - readOnlyHelpers := map[string]bool{ - "check-plan": true, "check-source-plan": true, "next-status": true, "delivery-status": true, - "recovery-status": true, "repair-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, - "doctor": true, "version": true, "mutation-status": true, - } - if readOnlyHelpers[fields[1]] { - return true - } - // repair-state is the guard-prescribed recovery for a workflow stuck at - // INVALID_STATE because of an unregistered malformed draft. Those findings - // carry an empty stage, so allow it independent of stage. It mutates (it - // quarantines the draft), so it is not a read-only helper; RepairState - // self-guards, refusing any registered, published, or tracked directory. - if fields[1] == "repair-state" { + if readOnlyHelperVerbs[fields[1]] { return true } - // undo is the bounded actuator that reverses a Boatstack-generated managed - // artifact by re-applying its receipt's inverse through the same transactional - // mutation boundary. Like repair-state it is a stage-independent recovery verb; - // it mutates but self-guards (UndoManagedMutation refuses to strand delivery - // state and the boundary's stale-base precondition refuses to clobber later - // work), so it is not a read-only helper. - if fields[1] == "undo" { + if stageIndependentRecoveryVerbs[fields[1]] { return true } - // workspace-reap and workspace-cleanup are the sanctioned actuators that - // reclaim finished managed worktrees and branches. They mutate only - // Boatstack-owned workspace bookkeeping — never product source or delivery - // state — and self-guard (refusing the base branch, the current worktree, and - // unmerged or dirty work without an explicit force). They are stage-independent - // like undo: without this the pre-activation interlock would deny post-merge - // cleanup and force the operator to reclaim worktrees with raw, denied Git. - if fields[1] == "workspace-reap" || fields[1] == "workspace-cleanup" { - return true - } - // discard-delivery is the bounded recovery that clears stuck or unverifiable - // managed delivery state (and orphaned feature artifacts). It is the verb the - // resolver prescribes for those causes, so it must be admitted wherever it is - // prescribed — the Coreachability invariant: the states that prescribe a - // recovery verb must be a subset of the states that verb accepts, and the verb - // must be reachable in-tool. It mutates but self-guards (DiscardDelivery archives - // rather than deletes and refuses published state without --force). Without this - // admission the resolver could name discard-delivery while the guard denied it — - // a fail-closed state with no reachable exit. - if fields[1] == "discard-delivery" { - return true - } - switch stage { - case "DRAFT_PLAN": - return fields[1] == "planning-write" || fields[1] == "record-approval" - case "INVALID_STATE": - return fields[1] == "planning-write" || fields[1] == "record-approval" - case "APPROVED", "POLICY_READY": - return fields[1] == "activate-plan" || fields[1] == "workspace-cut" - case "NOT_STARTED": - // The first-write latch denies raw writes into .product-loop/features/ - // before any candidate exists and prescribes planning-write; Coreachability - // requires the guard to admit that verb at the very stage that names it. - // record-approval is NOT admitted here — there is no plan to approve yet. - return fields[1] == "planning-write" - default: - return false + for _, verb := range stageMutationVerbs[stage] { + if fields[1] == verb { + return true + } } + return false } func controlledWorkspaceSync(repo, command string) bool { diff --git a/boatstack/solution_closure_conformance_test.go b/boatstack/solution_closure_conformance_test.go new file mode 100644 index 0000000..864d581 --- /dev/null +++ b/boatstack/solution_closure_conformance_test.go @@ -0,0 +1,227 @@ +package boatstack + +import ( + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// control-law: guard-never-prescribes-what-it-would-deny +// control-law: solution-set-derives-from-guard-declarations +// +// The sibling harness's v2.9 campaign lost trials to exactly this defect +// class: the system compiled an artifact its own laws then rejected. The +// solution set makes that class structurally testable here — every command the +// enumerator presents as a legal pick must be admitted by the guard at the +// exact position that emitted it, and must be a legal or observation move on +// the declared delivery model. These sweeps hold both consumers of the guard +// tables to the same rows. + +// substituteOwedFlags replaces placeholders with a dummy value so a +// rendered command can be fed back to the guard (which rejects angle brackets +// as shell metacharacters). The closure property is defined over substituted +// lines: what the user runs after filling the owed input. +func substituteOwedFlags(line string) string { + return strings.ReplaceAll(line, "", "test-value") +} + +// solutionOptions is the full pick list of a position: the primary plus the +// alternatives. +func solutionOptions(next FlowNext) []PrescribedCommand { + var options []PrescribedCommand + if next.Prescribed != nil { + options = append(options, *next.Prescribed) + } + return append(options, next.Alternatives...) +} + +// deliveryStages maps each oracle-resolved stage to a synthetic VERIFIED +// status, mirroring planningStages for the delivery side. +var deliveryStages = []NextStatus{ + {VerificationStatus: "VERIFIED", ObservedStage: "BUILD", NextOperation: "build", Feature: "demo", ActiveSlice: "s1"}, + {VerificationStatus: "VERIFIED", ObservedStage: "TEST_PASSED", NextOperation: "review-gate", Feature: "demo", ActiveSlice: "s1"}, + {VerificationStatus: "VERIFIED", ObservedStage: "REVIEW_PASSED", NextOperation: "ship-gate", Feature: "demo", ActiveSlice: "s1"}, + {VerificationStatus: "VERIFIED", ObservedStage: "PUBLISHED", NextOperation: "none", Feature: "demo", ActiveSlice: "s1"}, +} + +// Positive/Relation: every pre-activation option is admitted by +// controlledPhaseTransition at the emitting stage — the guard admits its own +// solution set, verb for verb, from the same tables. +func TestPlanningSolutionSetIsClosedUnderGuardAdmission(t *testing.T) { + for _, status := range planningStages { + next, err := nextControlFromStatus(".", status) + if err != nil { + t.Fatal(err) + } + options := solutionOptions(next) + if len(options) == 0 { + t.Errorf("%s/%s: the solution set must never be empty at a pre-activation stage", status.ObservedStage, status.NextOperation) + } + for _, option := range options { + line := substituteOwedFlags(option.CommandLine()) + if !controlledPhaseTransition(line, status.ObservedStage) { + t.Errorf("%s/%s: enumerated %q but the guard denies it at that stage", status.ObservedStage, status.NextOperation, line) + } + } + } + // AMBIGUOUS keeps its documented no-primary exception, but the solution set + // still names the legal observations — a weak model always has a pick. + next, err := nextControlFromStatus(".", NextStatus{ObservedStage: "AMBIGUOUS"}) + if err != nil { + t.Fatal(err) + } + if next.Prescribed != nil { + t.Fatalf("AMBIGUOUS must not gain a fabricated primary: %+v", next.Prescribed) + } + if len(next.Alternatives) == 0 { + t.Fatal("AMBIGUOUS must still enumerate read-only picks (next-status, doctor)") + } + for _, option := range next.Alternatives { + if !controlledPhaseTransition(substituteOwedFlags(option.CommandLine()), "AMBIGUOUS") { + t.Errorf("AMBIGUOUS pick %q must be guard-admitted", option.CommandLine()) + } + } +} + +// Positive/Relation: every delivery-state option is either a legal move on the +// registry graph from that exact state, or an observation row that accepts the +// state. Nothing outside the declared model is ever offered. +func TestDeliverySolutionSetIsLegalOnTheDeclaredModel(t *testing.T) { + graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights()) + for _, status := range deliveryStages { + next, err := nextControlFromStatus(".", status) + if err != nil { + t.Fatal(err) + } + if !next.Resolved { + t.Fatalf("%s must resolve a flow state", status.ObservedStage) + } + options := solutionOptions(next) + if len(options) == 0 { + t.Errorf("%s: the solution set must never be empty at a resolved state", status.ObservedStage) + } + for _, option := range options { + descriptor, isRegistry := deliverycontrol.Transition(option.Transition) + if !isRegistry { + t.Errorf("%s: delivery option %s carries a non-registry transition %s", status.ObservedStage, option.Verb, option.Transition) + continue + } + if descriptor.To != "" { + if !graph.IsLegalMove(next.State, option.Transition) { + t.Errorf("%s: %s is not a legal move from %s", status.ObservedStage, option.Transition, next.State) + } + continue + } + if descriptor.From != nil && !transitionAccepts(descriptor, next.State) { + t.Errorf("%s: observation %s does not accept state %s", status.ObservedStage, option.Transition, next.State) + } + } + } +} + +// Bypass: no rendered option, after owed-input substitution, may trip the +// text-level guard laws — the managed-state path law and the destruction +// classifier. This closes the publish-update-pr regression class end to end: +// the guard denying its own prescribed command line. +func TestSolutionSetCommandsPassTheTextGuards(t *testing.T) { + statuses := append(append([]NextStatus{}, planningStages...), deliveryStages...) + for _, status := range statuses { + next, err := nextControlFromStatus(".", status) + if err != nil { + t.Fatal(err) + } + for _, option := range solutionOptions(next) { + line := substituteOwedFlags(option.CommandLine()) + if deliveryStatePathPattern.MatchString(line) && !isPureReadOnlyCommand(line) && !approvedUpdatePublisherPattern.MatchString(line) { + t.Errorf("%s: prescribed %q names managed state the guard would deny", status.ObservedStage, line) + } + if findings := classifySafetyText(line, "command", commandExecutesLiveSQL(line)); len(findings) > 0 { + t.Errorf("%s: prescribed %q trips the text guard: %+v", status.ObservedStage, line, findings) + } + } + } +} + +// End to end: in a real DRAFT_PLAN repository, every enumerated option passes +// the full command classifier — zero findings, not just the phase interlock. +func TestDraftPlanSolutionSetPassesFullClassifier(t *testing.T) { + repo := nextTestRepo(t) + writeSavedFeaturePlan(t, repo, "demo") + next, err := NextControl(repo, "") + if err != nil { + t.Fatal(err) + } + options := solutionOptions(next) + if len(options) < 3 { + t.Fatalf("DRAFT_PLAN should enumerate the primary plus alternatives, got %d: %+v", len(options), options) + } + for _, option := range options { + line := substituteOwedFlags(option.CommandLine()) + if findings := ClassifyCommand(repo, line); len(findings) > 0 { + t.Errorf("guard denies its own prescription %q: %+v", line, findings) + } + } +} + +// Failure-state/Invariants: dedup identity holds, the cap holds, the primary +// never reappears in Alternatives, and owed flags never leak into Args. +func TestSolutionSetInvariants(t *testing.T) { + statuses := append(append([]NextStatus{}, planningStages...), deliveryStages...) + for _, status := range statuses { + next, err := nextControlFromStatus(".", status) + if err != nil { + t.Fatal(err) + } + if len(next.Alternatives) > solutionSetCap { + t.Errorf("%s: alternatives exceed the cap: %d", status.ObservedStage, len(next.Alternatives)) + } + seen := map[string]bool{} + if next.Prescribed != nil { + seen[prescriptionKey(*next.Prescribed)] = true + } + for _, option := range next.Alternatives { + key := prescriptionKey(option) + if seen[key] { + t.Errorf("%s: duplicate or primary-shadowing option %q", status.ObservedStage, option.CommandLine()) + } + seen[key] = true + if option.AutoDerivable != (len(option.RequiresHumanInput) == 0) { + t.Errorf("%s: AutoDerivable must equal owed-input emptiness: %+v", status.ObservedStage, option) + } + for _, owed := range option.RequiresHumanInput { + for _, arg := range option.Args { + if arg == owed { + t.Errorf("%s: owed flag %s fabricated into Args: %+v", status.ObservedStage, owed, option) + } + } + } + } + } +} + +// Rendering: the text carriers keep exactly one primary Run line; alternatives +// are one line (flow) or one sentence (response), capped. +func TestSolutionSetRenderingKeepsOnePrimary(t *testing.T) { + status := deliveryStages[1] // TEST_PASSED: gate, rework, abandon all legal + next, err := nextControlFromStatus(".", status) + if err != nil { + t.Fatal(err) + } + out := FormatFlowNext(next) + if got := strings.Count(out, "Run: "); got != 1 { + t.Fatalf("flow rendering must keep exactly one Run line, got %d:\n%s", got, out) + } + if !strings.Contains(out, "Also legal from here: ") { + t.Fatalf("flow rendering must name the other legal moves:\n%s", out) + } + line := "" + for _, candidate := range strings.Split(out, "\n") { + if strings.HasPrefix(candidate, "Also legal from here: ") { + line = candidate + } + } + if got := strings.Count(line, ","); got > solutionSetTextCap-1 { + t.Fatalf("text rendering must cap at %d entries: %q", solutionSetTextCap, line) + } +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 066e420..5f47a0d 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 [`7f71d4816931f34405b6c9a6bccca0eb4e29021f`](https://github.com/operatorstack/intelligence-flow/tree/7f71d4816931f34405b6c9a6bccca0eb4e29021f/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 [`6f60e1b420c8236479d1ed76d755372de52620fc`](https://github.com/operatorstack/intelligence-flow/tree/6f60e1b420c8236479d1ed76d755372de52620fc/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 ef01ca3..e9e4f74 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "7f71d4816931f34405b6c9a6bccca0eb4e29021f", + "source_commit": "6f60e1b420c8236479d1ed76d755372de52620fc", "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" }, { "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:7f71d4816931f34405b6c9a6bccca0eb4e29021f" + "last_verified_version": "source:6f60e1b420c8236479d1ed76d755372de52620fc" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 7d63daf..57c8c09 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": "7f71d4816931f34405b6c9a6bccca0eb4e29021f", + "source_commit": "6f60e1b420c8236479d1ed76d755372de52620fc", "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-27-flow-solution-set.md b/release-notes/2026-07-27-flow-solution-set.md new file mode 100644 index 0000000..75342c2 --- /dev/null +++ b/release-notes/2026-07-27-flow-solution-set.md @@ -0,0 +1,7 @@ +### `flow next` lists every legal move, not just the best one + +A law states what is admissible; a smaller model cannot always derive a compliant command from that statement. Boatstack now compiles the position's law into its solution set: `flow next --json` and the status response carry the full list of admissible next commands — the prescribed primary plus `alternatives` — each an exact runnable line with its owed human inputs marked, ordered most-productive first. The agent picks a legal move instead of deriving one. + +The set is computed, never hand-written: it enumerates the delivery registry's legal out-edges, the guard's own stage-admission tables, and the planning prescription layer. Because the guard and the enumerator now read the same declarations, a new conformance sweep can hold the whole loop closed: every command the tool presents is one the guard admits at the exact position that presented it — the tool never prescribes what it would deny. + +Text renderings stay calm: one primary `Run:` line as before, plus a single `Also legal from here:` line naming up to three other moves. The full set rides in the structured output.