diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f12722c..3693cdb 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/804141bc65d66fdb0a422a9c7c545a71180bffb1/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/d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221/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 4ce968a..fc80ff1 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,12 +12,12 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "c7f3497bbe061860f2ecec201bbb9526f045585371c5f62db2b1b5ce17362bc5", + "CONTRIBUTING.md": "204670c493aed31a5a0932d1075e2982bdf6e318c33d372b7dd2c28c424f3a31", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", "assets/boatstack-portability.svg": "66dfdfa85db857b3bd18b32047a6975f1fbbfc4dc091158e8277193f9969a346", - "boatstack/AGENTS.md": "39574398c3c3f82c22077299b45fd46a587926e1c9a35b66998c65ab8a756554", + "boatstack/AGENTS.md": "bc76221e1fe90a91afbacd7c6bc9b41a70e6c10fc128c275a6a0b9bc094d9506", "boatstack/BUG-worktree-delivery-state.md": "02469cf51c3849dad5743783e248e5c04583e4240507fbef0e3f890cd6a95724", "boatstack/SKILL.md": "b393fe00f23f701e1310d7c1006f339935d3082c7adb9b35c038a3ad1bcc459e", "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", @@ -50,13 +50,14 @@ "boatstack/config_documentation_test.go": "0632366edc5e88145bb080083ea03c6515da07b0162ce404d63e51bb5bc0774e", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "9bdcfecae7564c34a0d374ddbba4f237b241085db5c9d5bda6c4afb801055b35", - "boatstack/delivery_boundary_conformance_test.go": "800cd722d8d2a696a0529e8343d3523e453bb052f0917c8a2cad2990296ac1b3", + "boatstack/delivery.go": "86150374b14982b1e589714d6ef6230348ef57b6824d4e1552b72289c044af4b", + "boatstack/delivery_boundary_conformance_test.go": "c374eddf49b4597db78c0621f65f87199de9a26f1872ed88d9d790edf21fe3f2", "boatstack/delivery_migrate.go": "7566e49f9c1838d4d563866e941c7aacd61ac918c9e886222282398d287ca780", "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/deliverycontrol_parity_test.go": "f8662cfc35043395a0e1eef8a87051c2120752f38b09c56b78f82896008f1b65", + "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", "boatstack/export.go": "b3e28b571024b1b7a97b28f226f7c89734c95dcf1854c3d1a6dc672f34d4ded7", "boatstack/export_test.go": "dce5aa3ab5499c82d05859cf86b46dfcee308482491366d83e10ca3fb8605bb6", @@ -77,7 +78,7 @@ "boatstack/flow_trace_test.go": "99f89a831e904f6a8ef710b6977ed3a808ce1c7ddfaba457b292d84f2ddca51b", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "c8606417aec79fdcf84b3420758e7d491c3757e7b3117c7fc8df2bf4b0733e03", + "boatstack/hooks.go": "bed08eeaf80cc6c953c49463ffd5a6cf7bad595e8551e8d41c0a1580803c03cf", "boatstack/hooks_hydrate_test.go": "7beeb26b2b1398741e8a28963a9686e974047016cc736f233024004add1afc32", "boatstack/hooks_test.go": "fb75e3aabf2204871b3e6d16de98d26fb33b0ec19e41aae761cf1f34397c31f4", "boatstack/hydrate_runtime_test.go": "dbd5eae2ba85701e4af0430ba3a0d70ea98e028b66992bd4fc05f3f582398627", @@ -130,7 +131,7 @@ "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", "boatstack/published_slice_routing_test.go": "ea7e7351018bc13dcd31c4b96f50f8bc230e8a1dbf7806fba32a12ae58923e7e", - "boatstack/recovery.go": "7c06cdb52a31125cf3b944c304eca1df2273edc763242112527798bfb114874f", + "boatstack/recovery.go": "e45b3b3c2cda85c2b887fae32ee46ad205f1f3f707e6dc7b46f68ef6746ab5aa", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/reexec.go": "fed55416479d7bd3e0c3637057ffe8eb58a032f93fc358f76df906ab7acc677b", "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", @@ -176,10 +177,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "8c9ac13f925f67db325db9163a1384aa01591d17af82e47e7447922585f62e16", + "docs/evidence-engineered-coding.md": "4cbdb995edc8e285b4312ab9815808d886097f6d179de26602def7d142830deb", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "16d2327409e3bdaefab872aa46abeb88328a8ec263302ed0a4d5d48df431a392", + "docs/public-claims.json": "2d9f5e9e4fed027683d2a46e3f90e7f44d9bc5e878de977b79cd558c3e095b06", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -193,7 +194,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": "59951495e1695536035673cac5c799a174d43ac1091e455d4cf4a0e29c9f9763", + "labs/diagram-json/plan.lock.json": "a567d42bdc7341fcd8e81c44c786e2be5b794de9f04e6e6f02620e4259e882b8", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -292,15 +293,17 @@ "release-notes/2026-07-25-per-worktree-operation-ledger.md": "d0d4495fe4406cf67e7475032e3fc9eb74ed02a3e68f4928c086b5518422b46c", "release-notes/2026-07-25-published-slice-correction-routing.md": "129cdd62c80c8b93060726027d68ba3abdb0bca1a1ce9e64d6053271af3fd082", "release-notes/2026-07-25-readme-simplified-technical-english.md": "c362f46702c38dda6b0301d05b95a067da617d170ddfcca22fd7eb9f6e2c1881", + "release-notes/2026-07-25-recovery-tolerates-unrelated-stale-delivery.md": "70bf76d00d19455712d8e38c602b066982511aec89fed9aea2c196345ad783cb", "release-notes/2026-07-25-release-notes-simplified-technical-english.md": "70b273cbeb5ee46c49c10541540f31e8ca67a71102acfd47ae15451856c651db", "release-notes/2026-07-25-root-cause-operation.md": "5bf1f082e9123c5a7bcc8bc01b12e97b24b5ae15958577b4ff2a358994fca891", "release-notes/2026-07-25-runtime-simplified-technical-english.md": "917fbfb51ae56e5c6e0d9c705b84da492ef3b8f8782ea4b62635814259f11bb4", - "release-notes/2026-07-25-update-publish-guard-unblock.md": "adf06ee02b8d3c995525bb9673c2f1fea66a147df8751d65885ced83da0e96e2" + "release-notes/2026-07-25-update-publish-guard-unblock.md": "adf06ee02b8d3c995525bb9673c2f1fea66a147df8751d65885ced83da0e96e2", + "release-notes/2026-07-26-guard-etxtbsy-retry.md": "4238591804be62f8b9a76dd5cda18923af60ef67932d40d70cab0d815124bcaf" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "804141bc65d66fdb0a422a9c7c545a71180bffb1", + "commit": "d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/AGENTS.md b/boatstack/AGENTS.md index cc25140..264c9e4 100644 --- a/boatstack/AGENTS.md +++ b/boatstack/AGENTS.md @@ -107,6 +107,13 @@ change. Ask whether to (1) expand the current delivery, (2) split the shared boundary into a prerequisite delivery, or (3) apply bounded local containment and record the remaining risk. +State the law over the invariant and its failure class — never scoped to the one +call site where you found the bug. A single shared resource is usually crossed by +several boundaries; enumerate them all and extend the existing law to cover them +rather than minting a near-duplicate for the second one. See +[docs/control-law-scoping.md](docs/control-law-scoping.md) for the method and a +worked example. + ### 3. Add boundary-conformance tests Tests must prove the control law, not merely exercise the implementation. Add diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 39f6777..73cefd3 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -632,7 +632,7 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio if len(state.Slices) > 0 { observation.SliceID = state.Slices[len(state.Slices)-1].ID } - states, statesErr := allManagedDeliveryStates(repo) + states, _, statesErr := allManagedDeliveryStates(repo) if statesErr != nil { return ChangeObservation{}, DeliveryState{}, statesErr } diff --git a/boatstack/delivery_boundary_conformance_test.go b/boatstack/delivery_boundary_conformance_test.go index 6ea8db3..f6ed212 100644 --- a/boatstack/delivery_boundary_conformance_test.go +++ b/boatstack/delivery_boundary_conformance_test.go @@ -11,10 +11,12 @@ import ( // // control-law: stale-delivery-cannot-block-unrelated-feature // A stale/invalid delivery in the shared store must never escalate into a -// repo-wide INVALID_STATE that blocks resolution of an unrelated new feature. -// The ignored-deliveries filter is applied at the read-only ResolveNext -// boundary BEFORE invalidity becomes fatal; the mutation boundary -// (ActiveManagedDeliveries) stays fail-closed. +// repo-wide block on resolution of an unrelated delivery. This holds at EVERY +// read-only resolution boundary — ResolveNext (new work) and ResolveRecovery +// (recovering an existing delivery) alike: each partitions the store instead +// of failing closed and applies the ignored-deliveries filter BEFORE +// invalidity becomes fatal, blocking only on a still-unignored invalid +// delivery. The mutation boundary (ActiveManagedDeliveries) stays fail-closed. // // control-law: discard-preserves-published-authority // A delivery bearing published authority (any slice with a recorded PRState) @@ -142,6 +144,88 @@ func TestResolveNextLeavesInvalidStateUntouched(t *testing.T) { } } +// The same law holds at the OTHER read-only resolution boundary: ResolveRecovery. +// ResolveNext resolves new work; ResolveRecovery resolves an existing delivery +// that hit a problem. Both scan the shared store, so both must tolerate an +// unrelated stale delivery. These tests are the recovery-boundary twins of the +// ResolveNext cases above — the defect that motivated generalizing the law was +// that recovery had none of them and fell through to a repo-wide block. + +// Positive + bypass conformance: an IGNORED invalid delivery no longer poisons +// recovery of an unrelated healthy delivery on the current branch. The ignore +// filter runs before invalidity can become fatal, so recovery selects and routes +// the real target instead of blocking on abandoned state. +func TestResolveRecoveryIgnoredInvalidDeliveryDoesNotBlockHealthyBranch(t *testing.T) { + repo := nextTestRepo(t) + branch, _ := gitCommand(repo, "branch", "--show-current") + + writeNextDelivery(t, repo, "healthy-feature", "BUILD", 0) + updateRecoveryDelivery(t, repo, "healthy-feature", branch, "", "") + + writeInvalidDelivery(t, repo, "stale-one") + if _, err := IgnoreDelivery(repo, "stale-one"); err != nil { + t.Fatal(err) + } + + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the test failed", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "VERIFIED" || status.Feature != "healthy-feature" || + status.Lifecycle != "ACTIVE" || status.NextOperation != "repair_active" { + t.Fatalf("ignored invalid delivery poisoned recovery of an unrelated healthy branch: %#v", status) + } +} + +// Negative + relation conformance: a still-unignored invalid delivery does block +// recovery, but the block names exactly the offending delivery and routes to the +// discard-delivery remedy — request -> boundary -> decision. +func TestResolveRecoveryUnignoredInvalidDeliveryBlocksWithDiscardRemedy(t *testing.T) { + repo := nextTestRepo(t) + branch, _ := gitCommand(repo, "branch", "--show-current") + writeNextDelivery(t, repo, "healthy-feature", "BUILD", 0) + updateRecoveryDelivery(t, repo, "healthy-feature", branch, "", "") + writeInvalidDelivery(t, repo, "stale-one") + + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the test failed", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "BLOCKED" || status.NextOperation != "discard-delivery" { + t.Fatalf("unignored invalid delivery did not block with discard remedy: %#v", status) + } + found := false + for _, slug := range status.Blockers { + if slug == "stale-one" { + found = true + } + } + if !found { + t.Fatalf("block did not name the offending delivery: %#v", status.Blockers) + } +} + +// Failure-state conformance: ResolveRecovery is read-only. A blocking decision on +// an invalid delivery must leave the offending state file byte-for-byte unchanged. +func TestResolveRecoveryLeavesInvalidStateUntouched(t *testing.T) { + repo := nextTestRepo(t) + statePath := writeInvalidDelivery(t, repo, "stale-one") + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if _, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "boom", SourceStage: "ci"}); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatalf("read-only recovery mutated the invalid state file\nbefore=%s\nafter=%s", before, after) + } +} + // ---- control-law: discard-preserves-published-authority ---- // Positive + relation conformance: an unpublished delivery is discardable; the diff --git a/boatstack/docs/control-law-scoping.md b/boatstack/docs/control-law-scoping.md new file mode 100644 index 0000000..3f6fd4a --- /dev/null +++ b/boatstack/docs/control-law-scoping.md @@ -0,0 +1,109 @@ +# Scoping control laws so bug fixes don't under-constrain them + +A companion to the **Boundary Conformance Requirement** in `AGENTS.md`. That +section tells you to state a control law and add conformance tests. This guide is +about the mistake that section does *not* catch: writing a control law that is +**correct but too narrow** — scoped to the one call site where you found the bug, +so a sibling boundary keeps violating the same invariant. + +## The failure mode of control laws themselves + +When you fix a bug, the tempting move is to describe the law in terms of *where +you were standing when you found it*: + +> "The ignored-deliveries filter is applied at the read-only **ResolveNext** +> boundary before invalidity becomes fatal." + +That reads like a law. It is really a **description of one implementation**. The +actual invariant has nothing to do with `ResolveNext`: + +> A stale/invalid delivery in the shared store must never block resolution of an +> unrelated delivery — at **any** read-only resolution boundary. + +The two sound the same until a second boundary shows up. `ResolveRecovery` scans +the same shared store, so it is bound by the same invariant — but the law named +`ResolveNext`, the recovery path got no conformance test, and it shipped +fail-closed. A field session then hit exactly that: one abandoned, *already +ignored* delivery blocked recovery of a completely healthy feature. The code was +"fixed" months earlier; the fix just never reached the second door into the same +room. + +**A control law scoped to a call site is a latent bug at every other call site +that crosses the same boundary.** + +## The rule + +> State the law over the **invariant and its failure class**, then enumerate +> **every boundary** where that class can occur. The fix is done when a +> conformance test guards the law at each of them — not when the reported symptom +> stops reproducing. + +## Method: four questions before you call a fix done + +1. **What is the invariant, with no function name in it?** + Rewrite your law until it names only actors, state, and effects — never a + specific function. If you can't remove the function name without the sentence + going false, you have described an implementation, not a law. + +2. **What is the failure *class*, not the failure instance?** + "recovery-status blocked on `agentic-l3-full`" is an instance. "a read-only + resolver fails closed on an unrelated invalid delivery" is the class. Fix the + class. + +3. **Which boundaries cross this invariant?** + Grep for the shared resource, not the symptom. Here the resource was the + delivery-state store; every function that scans it (`ResolveNext`, + `ResolveRecovery`, and the strict mutation enumerator `ActiveManagedDeliveries`) + is a boundary the law touches. Enumerate them explicitly — in the law's comment + or the conformance file header — so the next reader sees the full set. + +4. **Does each boundary get the treatment its role demands?** + The same invariant resolves differently by role. Read-only resolvers + (`ResolveNext`, `ResolveRecovery`) **partition and tolerate**; the mutation + boundary (`ActiveManagedDeliveries`) **stays fail-closed** so corrupt state + can't be laundered into a write. "Account for every boundary" does not mean + "apply the same branch everywhere" — it means each boundary has a *stated, + tested* behavior under the law. + +## How to write it down so it can't narrow again + +- **One law, many boundaries, one name.** Keep a single `control-law: ` and + list its boundaries under it. Do **not** mint a second law for the second + boundary — that fragments one invariant across two names and neither owner sees + the whole set. (This is why the recovery fix *generalized* + `stale-delivery-cannot-block-unrelated-feature` instead of adding a + `recovery-cannot-block-...` twin.) +- **Co-locate the conformance tests.** All tests for a law live together (see + `delivery_boundary_conformance_test.go`) with the boundaries called out in the + header, so an incomplete boundary set is visible as a gap in one file rather + than an absence spread across the tree. +- **Test the classes, per boundary.** For each boundary assert positive, + negative, relation, and bypass conformance (per `AGENTS.md`). The recovery twin + of each `ResolveNext` test is what would have caught the original miss. +- **Reference the law from every enforcing site.** Each function that upholds the + law carries a `// control-law: ` comment. A boundary that touches the + shared resource but carries no such comment is your prompt to ask whether it, + too, is in scope. + +## Checklist + +Before marking a boundary bug fixed: + +- [ ] The law is stated with no function name in it. +- [ ] I fixed the failure *class*, not just the reported instance. +- [ ] I listed every boundary that crosses the shared resource/invariant. +- [ ] Each boundary has a stated behavior under the law (tolerate vs. fail-closed). +- [ ] Each boundary has conformance tests (positive/negative/relation/bypass). +- [ ] I extended the existing law rather than minting a near-duplicate. +- [ ] Every enforcing site carries the `control-law:` reference comment. + +## Worked example: `stale-delivery-cannot-block-unrelated-feature` + +| | | +|---|---| +| **Reported instance** | `recovery-status` blocked on the ignored, orphaned `agentic-l3-full` delivery while recovering an unrelated healthy PR. | +| **Failure class** | A read-only resolver fails closed on the *first* unreadable delivery in the shared store, before applying the operator's ignore policy. | +| **Invariant** | A stale/invalid delivery must never block resolution of an unrelated delivery at any read-only resolution boundary. | +| **Boundaries** | `ResolveNext` (tolerate) · `ResolveRecovery` (tolerate) · `ActiveManagedDeliveries` (fail-closed, by design). | +| **Original miss** | Law named only `ResolveNext`; `ResolveRecovery` had zero conformance tests and shipped fail-closed. | +| **Generalization** | Reworded the one law to cover *every* read-only resolution boundary; taught `allManagedDeliveryStates` to partition + ignore-filter like `scanManagedDeliveries`; added the recovery twins of the existing tests under the same law. | diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 68d1078..4725d5c 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -223,7 +223,24 @@ if [[ -z "$EXPECTED" || "$ACTUAL" != "$EXPECTED" ]]; then exit 2 fi -exec "$HELPER" bootstrap-safety-hook --host "$HOST" --repo "$ROOT" +# Linux refuses to exec a file another process still holds open for writing +# (ETXTBSY, surfaced as exit 126). Under concurrent first use a peer guard can be +# finishing hydration at this instant, even though the writer replaces the binary +# atomically. Retry briefly, then hand off. A genuinely non-executable helper keeps +# returning 126 and the final status still propagates unchanged. Running the helper +# as a child (not exec) is required so a failed start is observable; stdio and the +# exit code pass through, and an ETXTBSY start never consumes stdin. +ATTEMPT=0 +while :; do + "$HELPER" bootstrap-safety-hook --host "$HOST" --repo "$ROOT" + HELPER_STATUS=$? + if [[ $HELPER_STATUS -eq 126 && $ATTEMPT -lt 30 ]]; then + ATTEMPT=$((ATTEMPT + 1)) + sleep 0.1 + continue + fi + exit $HELPER_STATUS +done `, Version, SourceCommit, Version, SourceCommit, Version, Version, runtimeHydrateCommandBash(Version), runtimeHydrateCommandBash(Version))) } diff --git a/boatstack/recovery.go b/boatstack/recovery.go index 03c26c8..cf7a6c2 100644 --- a/boatstack/recovery.go +++ b/boatstack/recovery.go @@ -62,31 +62,41 @@ func blockedRecovery(reason string, blockers ...string) RecoveryStatus { } } -func allManagedDeliveryStates(repo string) ([]DeliveryState, error) { +// allManagedDeliveryStates partitions the delivery-state store the way the +// read-only recovery boundary needs it: states whose plan lock verifies on this +// branch are returned as data, and slugs that cannot be verified are returned as +// invalid rather than aborting the whole scan. Like scanManagedDeliveries (the +// ResolveNext counterpart) it never lets one corrupt or cross-branch delivery +// poison recovery of an unrelated one; ResolveRecovery applies the +// ignored-deliveries filter to both lists before any invalidity becomes fatal. +// control-law: stale-delivery-cannot-block-unrelated-feature +func allManagedDeliveryStates(repo string) (states []DeliveryState, invalid []string, err error) { directory, err := deliveryStateDirectory(repo) if err != nil { - return nil, err + return nil, nil, err } entries, err := os.ReadDir(directory) if os.IsNotExist(err) { - return nil, nil + return nil, nil, nil } if err != nil { - return nil, err + return nil, nil, err } - states := []DeliveryState{} + states = []DeliveryState{} for _, entry := range entries { if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) { continue } state, loadErr := CurrentDeliveryState(repo, entry.Name()) if loadErr != nil { - return nil, fmt.Errorf("invalid managed delivery state for %s: %w", entry.Name(), loadErr) + invalid = append(invalid, entry.Name()) + continue } states = append(states, state) } sort.Slice(states, func(i, j int) bool { return states[i].Feature < states[j].Feature }) - return states, nil + sort.Strings(invalid) + return states, invalid, nil } func deliveryBranchAndSlice(state DeliveryState) (string, string, string) { @@ -337,10 +347,29 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { NextOperation: "none", Reason: "This repository has no managed delivery installation to inspect.", }, nil } - states, err := allManagedDeliveryStates(repo) + // Read-only boundary: partition the store instead of failing closed, then + // apply the operator's ignored-deliveries filter to BOTH the readable states + // and the invalid slugs before any invalidity becomes fatal. Only a + // still-unignored invalid delivery blocks — named, and routed to the + // discard-delivery remedy. This is the same law the ResolveNext boundary + // already enforces; without it one abandoned, already-ignored delivery in the + // shared store poisons recovery of an unrelated healthy delivery. + // control-law: stale-delivery-cannot-block-unrelated-feature + states, invalidDeliveries, err := allManagedDeliveryStates(repo) if err != nil { return blockedRecovery("Managed delivery state cannot be verified: " + err.Error()), nil } + config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if configErr != nil { + return blockedRecovery("Boatstack project configuration is invalid: " + configErr.Error()), nil + } + states = withoutIgnoredDeliveryStates(states, config.Workflow.IgnoredDeliveries) + invalidDeliveries = withoutIgnoredDeliveries(invalidDeliveries, config.Workflow.IgnoredDeliveries) + if len(invalidDeliveries) > 0 { + status := blockedRecovery("Boatstack found managed delivery state it cannot verify. Restore its evidence, add it to workflow.ignored_deliveries, or run discard-delivery to clear it before recovering.", invalidDeliveries...) + status.NextOperation = "discard-delivery" + return status, nil + } branch, _ := gitCommand(repo, "branch", "--show-current") selected, ambiguity, selectErr := selectRecoveryDelivery(states, strings.TrimSpace(options.Feature), strings.TrimSpace(branch)) if selectErr != nil { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 3dfa759..ac4e7c5 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 [`804141bc65d66fdb0a422a9c7c545a71180bffb1`](https://github.com/operatorstack/intelligence-flow/tree/804141bc65d66fdb0a422a9c7c545a71180bffb1/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 [`d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221`](https://github.com/operatorstack/intelligence-flow/tree/d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221/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 be35666..bfa7697 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "804141bc65d66fdb0a422a9c7c545a71180bffb1", + "source_commit": "d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221", "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" }, { "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:804141bc65d66fdb0a422a9c7c545a71180bffb1" + "last_verified_version": "source:d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index ac85cbc..ff13113 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": "804141bc65d66fdb0a422a9c7c545a71180bffb1", + "source_commit": "d4c1a8ecc53f1bf82d3974f533b02eb0b9f93221", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-25-recovery-tolerates-unrelated-stale-delivery.md b/release-notes/2026-07-25-recovery-tolerates-unrelated-stale-delivery.md new file mode 100644 index 0000000..dee24a5 --- /dev/null +++ b/release-notes/2026-07-25-recovery-tolerates-unrelated-stale-delivery.md @@ -0,0 +1,9 @@ +### recovery-status no longer blocks on an unrelated, already-ignored stale delivery + +`recovery-status` scans the shared delivery-state store before it selects the delivery a correction belongs to. That scan (`allManagedDeliveryStates`) failed closed on the *first* unreadable delivery and never consulted `workflow.ignored_deliveries`. So a single delivery abandoned by an earlier session — malformed on disk, and already explicitly ignored — turned every recovery into a repo-wide `BLOCKED: Managed delivery state cannot be verified`, even when the delivery actually being recovered was healthy and on the current branch. An operator hit exactly this in the field: recovery of a fine, unrelated pull request was stranded by a stale `ignored` delivery it had no relationship to, with no in-tool way forward. + +This is the same failure class the `next` boundary already closed under the control law **stale-delivery-cannot-block-unrelated-feature** — but that law had been written as if it lived only at the `ResolveNext` boundary, so the twin read-only boundary, `ResolveRecovery`, never got the treatment. The store is one shared resource crossed by three boundaries: the two read-only resolvers (`ResolveNext`, `ResolveRecovery`), which must tolerate an unrelated corrupt delivery, and the mutation enumerator (`ActiveManagedDeliveries`), which must stay fail-closed so corrupt state can never be laundered into a write. + +The law is now stated over the invariant rather than one call site, and `ResolveRecovery` enforces it. `allManagedDeliveryStates` partitions the store the way `scanManagedDeliveries` does for `next`: readable states come back as data, unreadable slugs come back as a separate list rather than aborting the scan. `ResolveRecovery` then applies the operator's ignored-deliveries filter to both lists before any invalidity becomes fatal, and blocks only on a delivery that is *still* both invalid and unignored — naming it and routing to the `discard-delivery` remedy. Recovery of an unrelated healthy delivery proceeds with an ignored corrupt delivery in the store; the mutation boundary is unchanged and still fails closed. + +The regression suite pins the class shut at the recovery boundary with the twins of the existing `next` conformance tests — an ignored invalid delivery no longer blocks a healthy branch (positive/bypass), a still-unignored invalid delivery blocks while naming the offender and prescribing `discard-delivery` (negative/relation), and the read-only resolver leaves the offending state byte-for-byte untouched. A companion guide, `docs/control-law-scoping.md`, records the method that would have caught the original miss: state a control law over its invariant and failure class, enumerate every boundary that crosses the shared resource, and extend the existing law to cover them rather than minting a near-duplicate for the second one. diff --git a/release-notes/2026-07-26-guard-etxtbsy-retry.md b/release-notes/2026-07-26-guard-etxtbsy-retry.md new file mode 100644 index 0000000..239c8eb --- /dev/null +++ b/release-notes/2026-07-26-guard-etxtbsy-retry.md @@ -0,0 +1,15 @@ +### Concurrent first use no longer fails with "Text file busy" on Linux + +When several tool calls hit an empty shared-runtime slot at the same time, one guard +hydrates the slot and the others wait. On Linux the kernel refuses to run a file while +another process still holds it open for writing. It reports this as "Text file busy". A +waiting guard could reach the run step in that brief window, fail to start the helper, and +deny the tool call by mistake. This showed up as a flaky Linux CI failure under contention. +macOS and Windows do not enforce this rule, so only Linux saw the denial. + +The guard now retries the helper a bounded number of times when the start fails with this +exact condition, then hands off as before. The retry is short and self-clearing: the peer +closes the file the moment its write finishes, so the next attempt starts the helper. A +helper that genuinely cannot run still returns the same status after the retries, so no real +failure is hidden. The runtime binary is still written atomically, so the fix only closes +the read-side race.