From e7264e24ee9ef0e58e4597e8afa6e9dcb50b14fd Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Mon, 27 Jul 2026 05:32:10 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ f740356bfc30 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 25 +++--- boatstack/delivery.go | 58 ++++++++++++++ boatstack/next.go | 26 ++++-- boatstack/next_test.go | 6 +- boatstack/recovery.go | 4 + boatstack/safety.go | 27 ++++++- boatstack/supervisory_control_test.go | 80 +++++++++++++++++++ docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 +++--- labs/diagram-json/plan.lock.json | 2 +- .../2026-07-27-coreachable-recovery.md | 19 +++++ 12 files changed, 236 insertions(+), 39 deletions(-) create mode 100644 release-notes/2026-07-27-coreachable-recovery.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0153e87..c6e2cb8 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/01dec93295af21787a594de9479acc16e0f85bba/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/f740356bfc30b59038162ed3c7ca849f77c76e7f/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 c4aae49..ba3f3fc 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "01ec94c6a5b13837bcfa11b05bb1a9be58bd643fb144c1d4f17e3b6287e88c92", + "CONTRIBUTING.md": "9d31155e08bf2ec29b66a839c0a320e1e30f2c1f69448b89622b74ade3b0c5ed", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -53,7 +53,7 @@ "boatstack/context.go": "02510af176d2d040c0080086f06d1235e76a1d47fef5176f96f740ad18d27660", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "8a4dd6b7dd553e9544879b0489ef51231f2c16ae49dfb98b526bb07fec2b6e96", + "boatstack/delivery.go": "1cbc917eaa7df569f09c71352db157dff743827d5310dccb63acb7be72ccf9d5", "boatstack/delivery_boundary_conformance_test.go": "c374eddf49b4597db78c0621f65f87199de9a26f1872ed88d9d790edf21fe3f2", "boatstack/delivery_migrate.go": "7566e49f9c1838d4d563866e941c7aacd61ac918c9e886222282398d287ca780", "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", @@ -120,9 +120,9 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "47e01558a04922a9743ac63ee934945906e17efde54ba3b163721d5f8fbc71e8", + "boatstack/next.go": "c133dbf907dc86ca5aacec154f6e4a63e7aa9f5f0ebdd76e1803375b5700675a", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", - "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", + "boatstack/next_test.go": "6b5ec46ecf1a197d7644846cecbb6d99873a06b7c4e5562772b5016fa0a4cb11", "boatstack/operation.go": "073113e1e7b6349417e70b704bd1a342b460604cbd97a7fab06b1a6494604112", "boatstack/operation_test.go": "59d3dc37319aa4d334c0cacbe886e2f757842e6a28dee8781448e528fecbde11", "boatstack/paths.go": "bc14901f9497bfa87f64eab80ff30cc7c3a31781e3fdb60826df2dc21eb2253b", @@ -139,7 +139,7 @@ "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", "boatstack/published_slice_routing_test.go": "ea7e7351018bc13dcd31c4b96f50f8bc230e8a1dbf7806fba32a12ae58923e7e", - "boatstack/recovery.go": "43a87bf4453f5533d1e3ed97f63bc3f8f1cbe95dee27e3480a63c1b6f72a8870", + "boatstack/recovery.go": "8e35cf7f0d73ec9708e00a5a9bfd5f30ec832537cb0bffc254581bb6b8ae33ea", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/reexec.go": "fed55416479d7bd3e0c3637057ffe8eb58a032f93fc358f76df906ab7acc677b", "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", @@ -160,12 +160,12 @@ "boatstack/runtime_cache.go": "e026ffc1906f7e1e98b768bae63e6658164d2826c07169c9121ce0f23c73faf8", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "3f02b6be7d0a209da5afb2d23a5e5f1cb1c2578f1c4b430cea1d5a30a96e06ad", + "boatstack/safety.go": "56055f93cd9fe0f308b244111f2282d191c3ed522731047a194b06f21df64247", "boatstack/safety_test.go": "02260654d0b93ad48585c40391b310810876a6abcafc3d6c074f1f4e4e633f76", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", - "boatstack/supervisory_control_test.go": "af64106118ab31061e3f7335a2e981a4c831db3483962b6bc54e7e37dc4b7b45", + "boatstack/supervisory_control_test.go": "c7ea4bcd678e8ec211dac772c834981c4e21762914be2770a5e181bc24605e06", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", @@ -187,10 +187,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "36de4b7c7f2bbff5e250a3613e36a7e756b350f4bf44cf9f55feb944df746b3b", + "docs/evidence-engineered-coding.md": "c3301e5ef81642029935970eb4270c3667eeeacba3d7994d98407f65409dcac4", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "05bce9b3fab43563ca89db5e69b685bb2ee75b510cc7cc4eabfcac7754035209", + "docs/public-claims.json": "6c631d91ce3579a4a4fcf09a0d5c902a102de62b9793136e18e94032f67f73aa", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -204,7 +204,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": "d537e3b15bfa7312f12892f2a1c59fd34078fec2dea37c28362785c3fd17d1c3", + "labs/diagram-json/plan.lock.json": "e059bbc4a5ab29c1dc16a3ae00a1486b57a245e767e2ebaef4cd572002e959a7", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -317,12 +317,13 @@ "release-notes/2026-07-26-guard-etxtbsy-retry.md": "4238591804be62f8b9a76dd5cda18923af60ef67932d40d70cab0d815124bcaf", "release-notes/2026-07-26-guard-hydrate-double-check.md": "83a5591aba6bf30c9f4008ba8d26bf1994ef3fd61145f46fcdd1678912b9990b", "release-notes/2026-07-26-hidden-jflow-design-note.md": "f60ed9dbbfb46a172ac9d33dd758a3166f820007b1673029f29f0fbefa0e5c0a", - "release-notes/2026-07-26-workspace-reap.md": "e691d6a1c232cf218157880655413005fcb2c4f3113ededffdb80899a5054bb8" + "release-notes/2026-07-26-workspace-reap.md": "e691d6a1c232cf218157880655413005fcb2c4f3113ededffdb80899a5054bb8", + "release-notes/2026-07-27-coreachable-recovery.md": "6ffc6b0e9a7d46c0f99a64112813c33d19571c73d02e98ac5573924f1663fd54" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "01dec93295af21787a594de9479acc16e0f85bba", + "commit": "f740356bfc30b59038162ed3c7ca849f77c76e7f", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 6edf347..c802778 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -1344,6 +1344,55 @@ type DiscardDeliveryResult struct { // present without force (fail closed). // Release condition: the named delivery exists and either bears no published // authority or force is set. +// +// discardOrphanFeatureArtifacts archives an orphaned product-loop feature +// directory — one carrying a pr.md but no plan.lock.json, with no managed delivery +// state — reversibly to a dotted .discarded sibling (which the slug pattern skips, +// so it is never re-scanned as live). It is the accepting side of the Coreachability +// contract for the orphan cause: ResolveNext prescribes discard-delivery for an +// orphan, so discard-delivery must clear it. It refuses a dir carrying a +// plan.lock.json (a registered, live feature) so it never touches active work. +func discardOrphanFeatureArtifacts(repo, feature string) (DiscardDeliveryResult, bool, error) { + dir := filepath.Join(repo, ".product-loop", "features", feature) + info, statErr := os.Stat(dir) + if os.IsNotExist(statErr) { + return DiscardDeliveryResult{}, false, nil + } + if statErr != nil { + return DiscardDeliveryResult{}, false, statErr + } + if !info.IsDir() { + return DiscardDeliveryResult{}, false, nil + } + if !fileExists(filepath.Join(dir, "pr.md")) || fileExists(filepath.Join(dir, "plan.lock.json")) { + return DiscardDeliveryResult{}, false, nil + } + archiveDir := filepath.Join(filepath.Dir(dir), ".discarded") + destination := filepath.Join(archiveDir, feature) + for suffix := 2; ; suffix++ { + if _, existErr := os.Stat(destination); os.IsNotExist(existErr) { + break + } else if existErr != nil { + return DiscardDeliveryResult{}, false, existErr + } + destination = filepath.Join(archiveDir, fmt.Sprintf("%s-%d", feature, suffix)) + } + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + return DiscardDeliveryResult{}, false, err + } + if err := os.Rename(dir, destination); err != nil { + return DiscardDeliveryResult{}, false, err + } + archive := destination + if rel, relErr := filepath.Rel(repo, destination); relErr == nil { + archive = filepath.ToSlash(rel) + } + return DiscardDeliveryResult{ + Feature: feature, Action: "discarded", ArchivePath: archive, + Reason: "orphaned feature artifacts (pr.md without a plan lock) archived; the feature can be re-planned", + }, true, nil +} + func DiscardDelivery(repoPath, feature string, force bool) (DiscardDeliveryResult, error) { repo, err := ResolveRepository(repoPath) if err != nil { @@ -1359,6 +1408,15 @@ func DiscardDelivery(repoPath, feature string, force bool) (DiscardDeliveryResul } featureDir := filepath.Dir(statePath) if info, statErr := os.Stat(featureDir); os.IsNotExist(statErr) { + // No delivery-state dir. The resolver also prescribes discard-delivery for an + // ORPHAN — a product-loop feature dir carrying a pr.md but no plan.lock.json — + // so discard-delivery must accept and archive that too (Coreachability: the + // verb accepts every state that prescribes it). + if archived, ok, orphanErr := discardOrphanFeatureArtifacts(repo, feature); orphanErr != nil { + return DiscardDeliveryResult{}, orphanErr + } else if ok { + return archived, nil + } return DiscardDeliveryResult{ Feature: feature, Action: "none", Reason: "no managed delivery state exists for this feature", diff --git a/boatstack/next.go b/boatstack/next.go index 3ab774b..8e7ae91 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -217,7 +217,10 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { } config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if configErr != nil { - return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack project configuration is invalid: "+configErr.Error()), nil + // Channel fault: an invalid config cannot be cleared by any mutation verb — + // the operator repairs the file. Route to the read-only doctor to diagnose, + // not repair-state (which quarantines a draft and would not help). Coreachability. + return blockedNextStatus("INVALID_STATE", "doctor", "Boatstack project configuration is invalid; fix the config file, then re-run (doctor diagnoses): "+configErr.Error()), nil } // Read-only boundary: apply the ignored-deliveries filter BEFORE a single @@ -230,7 +233,9 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { // unrelated new feature. control-law: stale-delivery-cannot-block-unrelated-feature active, invalidDeliveries, scanErr := scanManagedDeliveries(repo) if scanErr != nil { - return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack could not read the managed delivery store: "+scanErr.Error()), nil + // Channel fault reading the store: observation loss, diagnosed by doctor — + // not repaired by quarantining a draft. Coreachability. + return blockedNextStatus("INVALID_STATE", "doctor", "Boatstack could not read the managed delivery store; diagnose the channel with doctor: "+scanErr.Error()), nil } active = withoutIgnoredDeliveries(active, config.Workflow.IgnoredDeliveries) invalidDeliveries = withoutIgnoredDeliveries(invalidDeliveries, config.Workflow.IgnoredDeliveries) @@ -251,7 +256,9 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { } else if completedState, completedErr := CurrentDeliveryState(repo, explicitFeature); completedErr == nil && completedState.ActiveIndex >= len(completedState.Slices) { return nextForPublished(repo, completedState), nil } else { - return blockedNextStatus("INVALID_STATE", "repair-state", fmt.Sprintf("Feature %s is not a verifiable active or published managed delivery.", explicitFeature)), nil + // Unverifiable named delivery: discard-delivery accepts and archives it + // (repair-state refuses registered/tracked dirs). Coreachability. + return blockedNextStatus("INVALID_STATE", "discard-delivery", fmt.Sprintf("Feature %s is not a verifiable active or published managed delivery; clear it with discard-delivery.", explicitFeature), explicitFeature), nil } } @@ -267,7 +274,9 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { if len(active) == 1 { status, deliveryErr := nextForDelivery(repo, active[0]) if deliveryErr != nil { - return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack could not verify the active managed delivery. Preserve the artifacts and restore its evidence before continuing: "+deliveryErr.Error()), nil + // Unverifiable active delivery state: discard-delivery archives it + // (reversibly); repair-state would refuse the registered dir. Coreachability. + return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack could not verify the active managed delivery; restore its evidence, or archive it with discard-delivery to continue: "+deliveryErr.Error(), active[0]), nil } return status, nil } @@ -277,7 +286,10 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { return NextStatus{}, err } if len(orphans) > 0 { - return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack found a PR preview without the plan lock required to verify it. Preserve the artifacts and restore the feature evidence before continuing.", orphans...), nil + // An orphan (pr.md, no plan.lock) is a published-then-unlinked delivery. + // repair-state refuses it (pr.md is a durable-authority blocker); discard-delivery + // accepts and archives the orphaned artifacts. Coreachability. + return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack found a PR preview without the plan lock required to verify it; restore the feature evidence, or archive the orphan with discard-delivery.", orphans...), nil } candidates, err := featurePlanCandidates(repo) @@ -325,7 +337,9 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { completed, err := completedManagedStates(repo) if err != nil { - return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack found invalid completed delivery state. Preserve the artifacts and restore its evidence before continuing: "+err.Error()), nil + // Invalid completed delivery state: discard-delivery archives it reversibly; + // repair-state refuses a delivery-bearing dir. Coreachability. + return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack found invalid completed delivery state; restore its evidence, or archive it with discard-delivery to continue: "+err.Error()), nil } completed = withoutIgnoredDeliveryStates(completed, config.Workflow.IgnoredDeliveries) if len(completed) > 0 { diff --git a/boatstack/next_test.go b/boatstack/next_test.go index f04c6fd..1ef4f67 100644 --- a/boatstack/next_test.go +++ b/boatstack/next_test.go @@ -154,7 +154,7 @@ func TestResolveNextOrphanedEvidenceBlocks(t *testing.T) { if err != nil { t.Fatal(err) } - if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "repair-state" { + if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "discard-delivery" { t.Fatalf("orphaned evidence did not block: %+v", status) } } @@ -521,7 +521,7 @@ func TestResolveNextBlocksStaleManagedState(t *testing.T) { if err != nil { t.Fatal(err) } - if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "repair-state" { + if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "discard-delivery" { t.Fatalf("stale managed state was accepted: %+v", status) } } @@ -561,7 +561,7 @@ func TestResolveNextBlocksMissingLockAndOrphanPreview(t *testing.T) { if err != nil { t.Fatal(err) } - if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "repair-state" { + if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "discard-delivery" { t.Fatalf("unexpected invalid state: %+v", status) } if !reflect.DeepEqual(before, after) { diff --git a/boatstack/recovery.go b/boatstack/recovery.go index 9757d6c..108f79b 100644 --- a/boatstack/recovery.go +++ b/boatstack/recovery.go @@ -526,6 +526,10 @@ func RepairState(repoPath, feature string) (RepairStateResult, error) { return refusedRepairState(feature, "no plan.md exists for this feature; nothing to repair"), nil } if _, checkErr := CheckPlan(planPath); checkErr == nil { + // Coreachability: repair-state accepts only a malformed unregistered draft, so + // the resolver must never PRESCRIBE it for a valid one. A valid draft advances + // (plan-gate/activate); an orphan or unverifiable delivery is prescribed + // discard-delivery, not repair-state — see preActivationFinding / ResolveNext. return refusedRepairState(feature, "the saved plan is valid; repair-state only quarantines a malformed unregistered draft"), nil } diff --git a/boatstack/safety.go b/boatstack/safety.go index 65f8f39..71373f0 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -164,6 +164,18 @@ func controlledPhaseTransition(command, stage string) bool { 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" @@ -282,24 +294,33 @@ func planningMarkdownPath(path string) bool { return len(parts) == 4 && featureSlugPattern.MatchString(parts[2]) && planningArtifacts[parts[3]] } +// preActivationFinding decides whether a product mutation is denied before a plan +// reaches its activation boundary, and — per the Coreachability invariant — names +// a recovery verb that actually CLEARS the cause it reports. A "cannot verify / +// cannot resolve" error is observation loss (a channel fault), not a plant defect: +// no mutation verb repairs it, so it is classified distinctly and routed to the +// read-only doctor to diagnose the channel — never to repair-state, which acts on a +// malformed draft and would refuse. A genuinely malformed draft routes to +// repair-state; every other stage carries ResolveNext's own (already Coreachable) +// next operation. func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) { active, err := ActiveManagedDeliveries(repo) if err != nil { - return SafetyFinding{Category: "workflow-state-invalid", Reason: "managed delivery state cannot be verified", Source: "delivery-state", NextOperation: "repair-state"}, true + return SafetyFinding{Category: "workflow-observation-fault", Reason: "managed delivery state cannot be verified; diagnose the channel with doctor", Source: "delivery-state", NextOperation: "doctor"}, true } if len(active) > 0 { return SafetyFinding{}, false } candidates, err := featurePlanCandidates(repo) if err != nil { - return SafetyFinding{Category: "workflow-state-invalid", Reason: "saved feature plans cannot be verified", Source: "planning-state", NextOperation: "repair-state"}, true + return SafetyFinding{Category: "workflow-observation-fault", Reason: "saved feature plans cannot be verified; diagnose the channel with doctor", Source: "planning-state", NextOperation: "doctor"}, true } if len(candidates) == 0 { return SafetyFinding{}, false } status, err := ResolveNext(repo, "") if err != nil { - return SafetyFinding{Category: "workflow-state-invalid", Reason: "workflow state cannot be resolved", Source: "planning-state", NextOperation: "repair-state"}, true + return SafetyFinding{Category: "workflow-observation-fault", Reason: "workflow state cannot be resolved; diagnose the channel with doctor", Source: "planning-state", NextOperation: "doctor"}, true } if status.ObservedStage != "DRAFT_PLAN" && status.ObservedStage != "APPROVED" && status.ObservedStage != "POLICY_READY" && status.ObservedStage != "AMBIGUOUS" && status.ObservedStage != "INVALID_STATE" { return SafetyFinding{}, false diff --git a/boatstack/supervisory_control_test.go b/boatstack/supervisory_control_test.go index 2d79d91..210401e 100644 --- a/boatstack/supervisory_control_test.go +++ b/boatstack/supervisory_control_test.go @@ -1,6 +1,8 @@ package boatstack import ( + "os" + "path/filepath" "strings" "testing" ) @@ -199,6 +201,84 @@ func TestSupervisoryControlNeverDeadlocks(t *testing.T) { } } }) + + // Coreachability invariant (stronger than "some verb is admitted"): the verb a + // blocked state PRESCRIBES must be both admitted in-tool AND accept/clear that + // exact state — diagnosis-predicate ⊆ recovery-predicate. Otherwise the guard + // sends the operator to a verb that refuses, a fail-closed state with no exit + // (the repair-state-refuses-a-valid/orphan-dir wedge). All prescribed recovery + // verbs must be reachable through controlledPhaseTransition at INVALID_STATE. + t.Run("every prescribed recovery verb is admitted at INVALID_STATE", func(t *testing.T) { + for _, verb := range []string{"repair-state", "discard-delivery", "doctor", "undo"} { + if !controlledPhaseTransition("boatstack-helper "+verb+" --repo .", "INVALID_STATE") { + t.Fatalf("INVALID_STATE prescribes %q but the guard does not admit it — a dead-end", verb) + } + } + }) + + // An orphan (pr.md, no plan.lock) is prescribed discard-delivery; that verb must + // accept and clear it, and the state must resolve afterward. + t.Run("orphan is prescribed discard-delivery which clears it", func(t *testing.T) { + repo := nextTestRepo(t) + dir := filepath.Join(repo, ".product-loop", "features", "orphan") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "pr.md"), []byte("# Preview\n"), 0o644); err != nil { + t.Fatal(err) + } + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.NextOperation != "discard-delivery" { + t.Fatalf("orphan prescribed %q, want discard-delivery", status.NextOperation) + } + if !controlledPhaseTransition("boatstack-helper discard-delivery --repo . --feature orphan", "INVALID_STATE") { + t.Fatal("prescribed discard-delivery is not admitted at INVALID_STATE") + } + result, err := DiscardDelivery(repo, "orphan", false) + if err != nil { + t.Fatalf("discard-delivery refused the orphan it was prescribed for: %v", err) + } + if result.Action != "discarded" { + t.Fatalf("discard-delivery did not clear the orphan: %#v", result) + } + after, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if after.ObservedStage == "INVALID_STATE" && after.NextOperation == "discard-delivery" { + t.Fatalf("orphan still blocks after discard-delivery: %#v", after) + } + }) + + // A malformed, unregistered, untracked draft is prescribed repair-state; that + // verb must accept and quarantine it. + t.Run("malformed draft is prescribed repair-state which clears it", func(t *testing.T) { + repo := nextTestRepo(t) + dir := filepath.Join(repo, ".product-loop", "features", "broken") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "plan.md"), []byte("not a valid plan\n"), 0o644); err != nil { + t.Fatal(err) + } + finding, blocked := preActivationFinding(repo, filepath.Join(dir, "x.go")) + if !blocked || finding.NextOperation != "repair-state" { + t.Fatalf("malformed draft prescribed %q (blocked=%v), want repair-state", finding.NextOperation, blocked) + } + if !controlledPhaseTransition("boatstack-helper repair-state --repo .", finding.WorkflowStage) { + t.Fatal("prescribed repair-state is not admitted for the malformed draft") + } + result, err := RepairState(repo, "broken") + if err != nil { + t.Fatalf("repair-state refused the malformed draft it was prescribed for: %v", err) + } + if result.Action != "quarantined" { + t.Fatalf("repair-state did not clear the malformed draft: %#v", result) + } + }) } func gateSlice(t *testing.T, repo, feature, sliceID string) { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index a9a048d..6023c94 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 [`01dec93295af21787a594de9479acc16e0f85bba`](https://github.com/operatorstack/intelligence-flow/tree/01dec93295af21787a594de9479acc16e0f85bba/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 [`f740356bfc30b59038162ed3c7ca849f77c76e7f`](https://github.com/operatorstack/intelligence-flow/tree/f740356bfc30b59038162ed3c7ca849f77c76e7f/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 6976b28..b09b41b 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "01dec93295af21787a594de9479acc16e0f85bba", + "source_commit": "f740356bfc30b59038162ed3c7ca849f77c76e7f", "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" }, { "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:01dec93295af21787a594de9479acc16e0f85bba" + "last_verified_version": "source:f740356bfc30b59038162ed3c7ca849f77c76e7f" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index d1bac33..b356862 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": "01dec93295af21787a594de9479acc16e0f85bba", + "source_commit": "f740356bfc30b59038162ed3c7ca849f77c76e7f", "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-coreachable-recovery.md b/release-notes/2026-07-27-coreachable-recovery.md new file mode 100644 index 0000000..f3f0489 --- /dev/null +++ b/release-notes/2026-07-27-coreachable-recovery.md @@ -0,0 +1,19 @@ +### A blocked workflow is always sent to a recovery step that can clear it + +When Boatstack blocks work at an invalid state, it names the next operation to run. Some of those +names could not help. The guard sent the operator to `repair-state` for problems `repair-state` +refuses — a valid saved plan, an orphaned PR preview, an unverifiable delivery, or a configuration +error. The prescribed step declined, and no other step advanced the state. The workflow was stuck +with no way forward inside the tool. + +The rule now is that the step a blocked state names must be a step that accepts that state. Each cause +is routed to the recovery that clears it. A malformed unregistered draft still routes to `repair-state`, +which quarantines it. An orphaned preview, an unverifiable delivery, or invalid completed state routes +to `discard-delivery`, which archives the artifacts reversibly. A state that cannot be verified or a +broken configuration is a sensing fault, not a plant fault, so it routes to the read-only `doctor` to +diagnose the channel — no mutation step is asked to fix what it cannot. + +`discard-delivery` is now admitted as a bounded recovery step at every stage, so a step the guard +names is always a step the guard permits. `discard-delivery` also clears an orphaned feature directory +that carries a preview but no plan lock, archiving it to a hidden sibling that is never re-scanned as +live work. The archive is reversible; committed history and merged pull requests are untouched.