diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e480197..31a6102 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/666b0631c31ee9509a4b3cb0f1242fe865a97c24/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/c6d00bbcf9defd9df85ead397ec1612efb650aee/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 0138eaa..74f8533 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,12 +12,12 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "e937fe6f4cedacad98619d49538da76a0543f95990b2d51db271ffd03bcc1280", + "CONTRIBUTING.md": "b55a6677dc3d5180d97b25564268ae62e660633158ae6319210d78a7b7079959", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", "assets/boatstack-portability.svg": "66dfdfa85db857b3bd18b32047a6975f1fbbfc4dc091158e8277193f9969a346", - "boatstack/AGENTS.md": "8694b4f13fb8b0553ea065a5bb0fa216aefc69389dfc0e7ad7703d8b81baff4d", + "boatstack/AGENTS.md": "39574398c3c3f82c22077299b45fd46a587926e1c9a35b66998c65ab8a756554", "boatstack/BUG-worktree-delivery-state.md": "02469cf51c3849dad5743783e248e5c04583e4240507fbef0e3f890cd6a95724", "boatstack/SKILL.md": "7c7b3568d836cb176c92762a8315fa834cd61a531a629c97a5cd98d6f7689be0", "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", @@ -40,14 +40,15 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "e5eea92ddc73622566c4b66b3d99c63b998268617f7b3fa77be953f75a971a75", + "boatstack/cmd/boatstack-helper/main.go": "a388d8b607475350fe8989c096a54caad944273969c64f83e0ae15d8fbd2d6f0", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", "boatstack/config_documentation_test.go": "0632366edc5e88145bb080083ea03c6515da07b0162ce404d63e51bb5bc0774e", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "f7c780d9860e0d5456ecfad5f61242337b2ba1068fceec9eda7232f24374c03c", + "boatstack/delivery.go": "10098124cfb34d0c1d99885b7293ecaccb64c5b5cd80c08f1e2bd1ef43f8a756", + "boatstack/delivery_boundary_conformance_test.go": "800cd722d8d2a696a0529e8343d3523e453bb052f0917c8a2cad2990296ac1b3", "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", @@ -71,7 +72,7 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "29644ff0b03974fa9bce290c09695a46282c1fd3ca608c245b6027cf7588529e", + "boatstack/next.go": "07ec5fc7bf26975605fb18477c70bd66a3a1c9ac769d788f3e329c49a3128b25", "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", "boatstack/operation.go": "62f97bf2091f33eb2ca91915bf08bee73d53387611b673e849355bfd516ca467", "boatstack/operation_test.go": "2d624eaba342b2c81b45cdf50918a65a9c002b5376a02041b24180658ee6a25a", @@ -132,10 +133,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "65056f8f744b7ef5693bad2c3fd8c7f3ebff3989098ee074ede8ebc250bc1373", + "docs/evidence-engineered-coding.md": "dad43a315165883e40d73c428f610480b20d3e9c36830c99bb16762f7ed5afc2", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "f314270c5ed1a55bbef5f3ddbcb5596693dbee9374e5f0d3df8838cefbd68052", - "docs/public-claims.json": "a5923a6b53d922b3d515e64ce91d03ea4c251c7fcb00ef39be6ac0d056b11018", + "docs/public-claims.json": "657bf4734ca4e169e3c1b17054df383f65ab3500dc4af5428d5b36b54a76acd0", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -149,7 +150,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": "bb709ce18133d8d49a8b96249bb2e2d534d37f004b01df9027a6c3dd070dcd6a", + "labs/diagram-json/plan.lock.json": "b28d8bb0d854dc65fcd7201920e1dbea95fc7a14afb551fc8c3bdb017216bd45", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -223,6 +224,7 @@ "release-notes/2026-07-23-sync-title-contract.md": "2869d6d084ea60402e57ffe985d0fc4cd83ef9bb09958cc53e349157d3383202", "release-notes/2026-07-23-visual-evidence-external-host.md": "09edbe5e6e1bfc866cf5ee744a5001d678f7a67f0f330cf43bd5157eedf04276", "release-notes/2026-07-24-auto-hydrate-missing-runtime.md": "b376f75f7c9132f30b362392e0b31c05715edcef58c14dfa20e1fc0a17836b41", + "release-notes/2026-07-24-discard-delivery.md": "1a11ed133b9abab6f3ef79524feefbf5b2aae85d2b3dc3364cedc7599ec51e5f", "release-notes/2026-07-24-ignored-deliveries-publication-authority.md": "a25f8469316276101490c79a57c1a236c18072dfbb23682bb1778871d247067d", "release-notes/2026-07-24-provenance-verified-binary-install.md": "b64f3a8dbd750afb28bf6964489eb0f5f8519efa65fcee871db64b9e60c2fdb2", "release-notes/2026-07-24-publication-nonblocking-control.md": "2b9d8ea817896783273a843ec183fbf00bb2f7ac420b4ce3409aeda7f59b7fb5", @@ -233,7 +235,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "666b0631c31ee9509a4b3cb0f1242fe865a97c24", + "commit": "c6d00bbcf9defd9df85ead397ec1612efb650aee", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/AGENTS.md b/boatstack/AGENTS.md index 96c520d..cc25140 100644 --- a/boatstack/AGENTS.md +++ b/boatstack/AGENTS.md @@ -60,3 +60,107 @@ python3 labs/12-product-engineering-loop/scripts/release_notes.py \ Do not write "no release note required" for a change under this lab — a note is always required. State which note you added. + +## Boundary Conformance Requirement + +For every requested change, determine whether it creates, modifies, relies on, +or crosses a system boundary. + +A boundary is any point where authority, state, data, effects, trust, or +responsibility moves between components, actors, processes, repositories, +services, or execution stages. + +If the change affects a boundary, boundary conformance is part of the definition +of done. If it does not, say so explicitly (`Boundary-conformance impact: none`) +and do not invent artificial tests. + +The standing rule, in three lines: + +```text +Every boundary implies a control law. +Every control law implies conformance evidence. +Every relevant path must be shown to reach the boundary. +``` + +### 1. State the control law + +Before implementation, describe the boundary and write the invariant it must +enforce, in this form: + +```text +Boundary: +Control law: +Authorized actor: +Required evidence: +Failure behavior: +Release condition: +``` + +### 2. Enforce at the correct boundary + +Do not only patch the observed symptom. Trace the paths that can violate the +control law and enforce it at the earliest safe shared boundary that closes the +failure class without unnecessary scope expansion. + +If the correct fix requires materially broader work, do not silently expand the +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. + +### 3. Add boundary-conformance tests + +Tests must prove the control law, not merely exercise the implementation. Add +the applicable classes: + +- **Positive conformance** — the authorized actor completes the valid transition + when all required evidence is present. +- **Negative conformance** — unauthorized actors, invalid states, missing/stale + evidence, and malformed requests are rejected. +- **Relation conformance** — every relevant entry path reaches the intended + boundary; test `request → boundary → decision → effect → resulting state`, not + just the component in isolation. +- **Bypass conformance** — the protected effect cannot be reached through an + alternate path that avoids the boundary. +- **Failure-state conformance** — rejection leaves the protected state unchanged + and does not partially apply the effect. +- **Correlation and replay** (where relevant) — request/response identities + match; receipts cannot cross runs; duplicate/reordered events fail correctly; + replay reproduces the original decision; changed policy or evidence invalidates + replay. +- **Idempotency and reversal** (where relevant) — repeating an accepted + transition does not duplicate the effect; stale base state is rejected; a + recorded mutation reverses deterministically when its post-state still matches. + +### 4. Map tests to control laws + +Every boundary test must identify which control law it proves. Use a name or a +`control-law: ` comment. Avoid tests that pass without demonstrating the +invariant. + +### 5. Preserve deterministic authority + +The model may propose implementation and tests. The deterministic boundary +decides admissibility. Never treat an LLM assertion, completion claim, or +generated summary as conformance evidence unless policy explicitly allows it. +Prefer exact hashes, repository state, test results, validated schemas, +correlated receipts, deterministic path checks, and authoritative external state. + +### 6. Report completion evidence + +Before declaring the work complete, report: + +```text +Boundary: +Control law: +Affected paths: +Tests added: +Positive case: +Negative case: +Relation or bypass proof: +Failure-state behavior: +Residual risk: +Conformance status: +``` + +The change is not complete while a material boundary control law remains +untested or unsupported by evidence. diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 98ade9c..a969be2 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -793,6 +793,38 @@ func ignoreDeliveryCommand(arguments []string) int { return 0 } +func discardDeliveryCommand(arguments []string) int { + flags := flag.NewFlagSet("discard-delivery", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository containing the Boatstack installation") + feature := flags.String("feature", "", "feature slug of the delivery whose state should be discarded") + force := flags.Bool("force", false, "discard even a delivery that has published slices (git history and merged PRs are unaffected)") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *feature == "" { + return fail(fmt.Errorf("discard-delivery requires --feature")) + } + result, err := boatstack.DiscardDelivery(*repo, *feature, *force) + if err != nil { + return fail(err) + } + switch result.Action { + case "discarded": + fmt.Printf("PASS: delivery %s discarded; state archived to %s\n", result.Feature, result.ArchivePath) + return 0 + case "none": + fmt.Printf("PASS: %s\n", result.Reason) + return 0 + default: // refused + if len(result.Published) > 0 { + fmt.Printf("BLOCKED: delivery %s has published slices (%s); %s\n", result.Feature, strings.Join(result.Published, ", "), result.Reason) + } else { + fmt.Printf("BLOCKED: delivery %s: %s\n", result.Feature, result.Reason) + } + return 1 + } +} + func doctorCommand(arguments []string) int { flags := flag.NewFlagSet("doctor", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Boatstack installation should be checked") @@ -1221,6 +1253,8 @@ func run() int { return recordChangeCommand(os.Args[2:]) case "ignore-delivery": return ignoreDeliveryCommand(os.Args[2:]) + case "discard-delivery": + return discardDeliveryCommand(os.Args[2:]) case "record-delivery-gate": return recordDeliveryGateCommand(os.Args[2:]) case "record-pr-visual-evidence": diff --git a/boatstack/delivery.go b/boatstack/delivery.go index df2cf91..fa9b38a 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -1045,32 +1045,58 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { return saveDeliveryState(repo, state) } -func ActiveManagedDeliveries(repo string) ([]string, error) { +// scanManagedDeliveries partitions the delivery-state store into deliveries +// whose state.json is valid and still in progress (active) and slugs whose +// state.json is unreadable or malformed (invalid). Unlike ActiveManagedDeliveries +// it never lets one corrupt delivery poison the scan: invalid deliveries are +// returned as data, not as a fatal error, so a read-only caller (ResolveNext) +// can apply the ignored-deliveries filter and surface an actionable remedy for +// exactly the offending delivery instead of escalating one stale delivery into a +// repo-wide INVALID_STATE that blocks unrelated new features. It errors only +// when the store directory itself cannot be read. +func scanManagedDeliveries(repo string) (active []string, 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 } - active := []string{} for _, entry := range entries { if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) { continue } state, loadErr := LoadDeliveryState(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 } if state.ActiveIndex < len(state.Slices) || (state.Mode != "" && state.Mode != "NORMAL") { active = append(active, entry.Name()) } } sort.Strings(active) + sort.Strings(invalid) + return active, invalid, nil +} + +// ActiveManagedDeliveries is the strict, fail-closed enumeration used by the +// mutation and guard boundaries (run, publish, safety, workspace): any invalid +// delivery in the store aborts with an error so no mutation proceeds over +// unverifiable state. The tolerant read-only counterpart is scanManagedDeliveries. +func ActiveManagedDeliveries(repo string) ([]string, error) { + active, invalid, err := scanManagedDeliveries(repo) + if err != nil { + return nil, err + } + if len(invalid) > 0 { + _, loadErr := LoadDeliveryState(repo, invalid[0]) + return nil, fmt.Errorf("invalid managed delivery state for %s: %w", invalid[0], loadErr) + } return active, nil } @@ -1152,3 +1178,110 @@ func IgnoreDelivery(repo, feature string) (bool, error) { } return true, nil } + +// DiscardDeliveryResult is the host-neutral outcome of discarding one managed +// delivery's state from the store. +type DiscardDeliveryResult struct { + Feature string `json:"feature"` + Action string `json:"action"` // discarded | refused | none + ArchivePath string `json:"archive_path,omitempty"` + Reason string `json:"reason"` + Published []string `json:"published_slices,omitempty"` +} + +// DiscardDelivery removes one managed delivery's state from the delivery store +// so a stale or abandoned delivery can no longer block unrelated new features on +// the branches and worktrees that share the store. It is the bounded actuator +// behind the discard-delivery helper, and the escape hatch that makes a new +// mutation possible after Boatstack itself has been fixed but a divergent live +// delivery remains. +// +// control-law: discard-preserves-published-authority +// +// Boundary: operator request -> deletion of managed delivery state +// Control law: a delivery bearing PUBLISHED authority (any slice with a +// recorded PRState) is NOT discardable without an explicit +// force override; a discard NEVER touches git-tracked +// artifacts, the plan, the lock, or merged history, and it +// ARCHIVES (never hard-deletes) the state so the action is +// reversible. +// Authorized actor: operator naming the exact feature slug (no implicit/bulk delete) +// Required evidence: the delivery's own state.json (feature match) and its +// recorded per-slice PRState — deterministic and offline; no +// live gh call gates the unblock. +// Failure behavior: refuse and leave the store unchanged when the slug is +// invalid, no such delivery exists, or a published slice is +// present without force (fail closed). +// Release condition: the named delivery exists and either bears no published +// authority or force is set. +func DiscardDelivery(repoPath, feature string, force bool) (DiscardDeliveryResult, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return DiscardDeliveryResult{}, err + } + feature = strings.TrimSpace(feature) + if !featureSlugPattern.MatchString(feature) { + return DiscardDeliveryResult{}, fmt.Errorf("discard-delivery requires a valid feature slug") + } + statePath, err := deliveryStatePath(repo, feature) + if err != nil { + return DiscardDeliveryResult{}, err + } + featureDir := filepath.Dir(statePath) + if info, statErr := os.Stat(featureDir); os.IsNotExist(statErr) { + return DiscardDeliveryResult{ + Feature: feature, Action: "none", + Reason: "no managed delivery state exists for this feature", + }, nil + } else if statErr != nil { + return DiscardDeliveryResult{}, statErr + } else if !info.IsDir() { + return DiscardDeliveryResult{}, fmt.Errorf("managed delivery path for %s is not a directory", feature) + } + + // Published-authority gate: deterministic and offline, read from the recorded + // per-slice PRState. A malformed state that cannot be loaded carries no + // verifiable published authority — it IS the stuck state we must be able to + // clear — so it is discardable without force. A loadable state is refused when + // any slice has been published, unless force is set. + published := []string{} + if state, loadErr := LoadDeliveryState(repo, feature); loadErr == nil { + for _, slice := range state.Slices { + if strings.TrimSpace(slice.PRState) != "" { + published = append(published, slice.ID) + } + } + } + if len(published) > 0 && !force { + return DiscardDeliveryResult{ + Feature: feature, Action: "refused", Published: published, + Reason: "delivery has published slices; re-run with --force to discard published delivery state (git history and merged PRs are unaffected)", + }, nil + } + + // Archive rather than hard-delete so the discard is reversible. The archive + // lives under a dotted sibling that the slug pattern skips, so it is never + // re-scanned as a live delivery. Collision handling is deterministic (no + // clock/rng) so the actuator is replayable. + archiveDir := filepath.Join(filepath.Dir(featureDir), ".discarded") + destination := filepath.Join(archiveDir, feature) + for suffix := 2; ; suffix++ { + if _, statErr := os.Stat(destination); os.IsNotExist(statErr) { + break + } else if statErr != nil { + return DiscardDeliveryResult{}, statErr + } + destination = filepath.Join(archiveDir, fmt.Sprintf("%s-%d", feature, suffix)) + } + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + return DiscardDeliveryResult{}, err + } + if err := os.Rename(featureDir, destination); err != nil { + return DiscardDeliveryResult{}, err + } + return DiscardDeliveryResult{ + Feature: feature, Action: "discarded", + ArchivePath: ".git/boatstack/deliveries/.discarded/" + filepath.Base(destination), + Reason: "managed delivery state archived; the feature can be rebuilt or re-planned", + }, nil +} diff --git a/boatstack/delivery_boundary_conformance_test.go b/boatstack/delivery_boundary_conformance_test.go new file mode 100644 index 0000000..6ea8db3 --- /dev/null +++ b/boatstack/delivery_boundary_conformance_test.go @@ -0,0 +1,306 @@ +package boatstack + +import ( + "os" + "path/filepath" + "testing" +) + +// This file holds boundary-conformance tests for two control laws (see +// AGENTS.md "Boundary Conformance Requirement"): +// +// 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. +// +// control-law: discard-preserves-published-authority +// A delivery bearing published authority (any slice with a recorded PRState) +// is not discardable without an explicit force override; a discard archives +// (never hard-deletes) the state and never touches git-tracked artifacts. + +// writeInvalidDelivery plants a structurally-malformed state.json in the store so +// LoadDeliveryState fails for that slug — modelling a corrupt or partially +// written delivery left behind by an interrupted run. +func writeInvalidDelivery(t *testing.T, repo, feature string) string { + t.Helper() + path, err := deliveryStatePath(repo, feature) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{ this is not valid delivery state"), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// writePublishedDelivery plants a valid delivery whose single slice carries a +// recorded PRState — i.e. it bears published authority. +func writePublishedDelivery(t *testing.T, repo, feature, prState string) { + t.Helper() + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: "hash", + ActiveIndex: 1, + Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "PUBLISHED", PRState: prState}}, + }); err != nil { + t.Fatal(err) + } +} + +// ---- control-law: stale-delivery-cannot-block-unrelated-feature ---- + +// Negative + relation conformance: an unignored invalid delivery blocks, but the +// block names exactly the offending delivery and routes to the discard-delivery +// remedy (not the opaque repair-state) — request -> boundary -> decision. +func TestResolveNextInvalidDeliveryBlocksWithDiscardRemedy(t *testing.T) { + repo := nextTestRepo(t) + writeInvalidDelivery(t, repo, "stale-one") + writeSavedFeaturePlan(t, repo, "new-feature") + + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" { + t.Fatalf("invalid delivery did not block: %+v", status) + } + if status.NextOperation != "discard-delivery" { + t.Fatalf("block did not route to discard-delivery remedy: %+v", status) + } + found := false + for _, slug := range status.BlockingAmbiguity { + if slug == "stale-one" { + found = true + } + } + if !found { + t.Fatalf("block did not name the offending delivery: %+v", status.BlockingAmbiguity) + } +} + +// Positive + bypass conformance: an IGNORED invalid delivery no longer blocks; +// the unrelated new feature resolves. This is the core fix — the ignore filter +// runs before invalidity can become fatal, so one stale delivery cannot poison +// resolution of distinct work. +func TestResolveNextIgnoredInvalidDeliveryDoesNotBlockNewFeature(t *testing.T) { + repo := nextTestRepo(t) + writeInvalidDelivery(t, repo, "stale-one") + writeSavedFeaturePlan(t, repo, "new-feature") + + if _, err := IgnoreDelivery(repo, "stale-one"); err != nil { + t.Fatal(err) + } + + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "VERIFIED" || status.Feature != "new-feature" || + status.ObservedStage != "DRAFT_PLAN" || status.NextOperation != "plan-gate" { + t.Fatalf("ignored invalid delivery still blocked the new feature: %+v", status) + } +} + +// Bypass conformance (enforce at the correct boundary): the tolerance is scoped +// to the read-only ResolveNext boundary. The strict mutation-path enumeration +// still fails closed on the same invalid delivery even when it is ignored, so +// corrupt state cannot be laundered into a mutation via the ignore list. +func TestActiveManagedDeliveriesStaysFailClosedOnInvalid(t *testing.T) { + repo := nextTestRepo(t) + writeInvalidDelivery(t, repo, "stale-one") + if _, err := IgnoreDelivery(repo, "stale-one"); err != nil { + t.Fatal(err) + } + if _, err := ActiveManagedDeliveries(repo); err == nil { + t.Fatal("strict ActiveManagedDeliveries did not fail closed on invalid delivery state") + } +} + +// Failure-state conformance: ResolveNext is read-only. A blocking decision must +// leave the offending state file byte-for-byte unchanged (no partial repair). +func TestResolveNextLeavesInvalidStateUntouched(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 := ResolveNext(repo, ""); 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 resolution 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 +// live state directory is gone, an archive exists, the store no longer surfaces +// it, and an unrelated new feature then resolves cleanly. +// request -> boundary -> effect -> resulting state. +func TestDiscardDeliveryUnpublishedIsRemovedAndUnblocks(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "stale-active", "BUILD", 0) + statePath, err := deliveryStatePath(repo, "stale-active") + if err != nil { + t.Fatal(err) + } + featureDir := filepath.Dir(statePath) + + result, err := DiscardDelivery(repo, "stale-active", false) + if err != nil { + t.Fatal(err) + } + if result.Action != "discarded" { + t.Fatalf("unpublished delivery was not discarded: %+v", result) + } + if _, statErr := os.Stat(featureDir); !os.IsNotExist(statErr) { + t.Fatalf("live delivery directory still present after discard: %v", statErr) + } + // Effect: the archive exists and the store no longer surfaces the delivery. + archive := filepath.Join(filepath.Dir(featureDir), ".discarded", "stale-active") + if _, statErr := os.Stat(archive); statErr != nil { + t.Fatalf("discard did not archive the state: %v", statErr) + } + active, err := ActiveManagedDeliveries(repo) + if err != nil || len(active) != 0 { + t.Fatalf("discarded delivery still active: %#v %v", active, err) + } + + // Resulting state: a fresh feature now resolves without interference. + writeSavedFeaturePlan(t, repo, "new-feature") + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.Feature != "new-feature" || status.NextOperation != "plan-gate" { + t.Fatalf("new feature did not resolve after discard: %+v", status) + } +} + +// Bypass conformance: the archive lives under a dotted sibling the slug pattern +// skips, so a discarded delivery can never re-enter through the live scan. +func TestDiscardedDeliveryIsNotRescanned(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "stale-active", "BUILD", 0) + if _, err := DiscardDelivery(repo, "stale-active", false); err != nil { + t.Fatal(err) + } + active, invalid, err := scanManagedDeliveries(repo) + if err != nil { + t.Fatal(err) + } + if len(active) != 0 || len(invalid) != 0 { + t.Fatalf("archived delivery leaked back into the scan: active=%#v invalid=%#v", active, invalid) + } +} + +// Negative + failure-state conformance: a published delivery is refused without +// force, and the refusal leaves the live state directory unchanged (the effect +// is not partially applied). +func TestDiscardDeliveryPublishedRefusedWithoutForce(t *testing.T) { + repo := nextTestRepo(t) + writePublishedDelivery(t, repo, "shipped-feature", "OPEN") + statePath, err := deliveryStatePath(repo, "shipped-feature") + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + + result, err := DiscardDelivery(repo, "shipped-feature", false) + if err != nil { + t.Fatal(err) + } + if result.Action != "refused" { + t.Fatalf("published delivery was not refused: %+v", result) + } + if len(result.Published) == 0 { + t.Fatalf("refusal did not report the published slices: %+v", result) + } + // Failure-state: state untouched. + after, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("refused discard removed the live state: %v", err) + } + if string(before) != string(after) { + t.Fatalf("refused discard mutated the live state\nbefore=%s\nafter=%s", before, after) + } +} + +// Positive (override) conformance: the authorized actor may discard a published +// delivery with an explicit force override. +func TestDiscardDeliveryPublishedForced(t *testing.T) { + repo := nextTestRepo(t) + writePublishedDelivery(t, repo, "shipped-feature", "OPEN") + + result, err := DiscardDelivery(repo, "shipped-feature", true) + if err != nil { + t.Fatal(err) + } + if result.Action != "discarded" { + t.Fatalf("forced discard of published delivery did not succeed: %+v", result) + } +} + +// Negative conformance: a malformed request (invalid slug) is rejected, and a +// request for a delivery that does not exist is a no-op — never a spurious effect. +func TestDiscardDeliveryRejectsBadRequests(t *testing.T) { + repo := nextTestRepo(t) + if _, err := DiscardDelivery(repo, "Not A Slug", false); err == nil { + t.Fatal("discard accepted an invalid feature slug") + } + result, err := DiscardDelivery(repo, "never-existed", false) + if err != nil { + t.Fatal(err) + } + if result.Action != "none" { + t.Fatalf("discard of a nonexistent delivery was not a no-op: %+v", result) + } +} + +// Idempotency + reversal conformance: discarding twice does not duplicate the +// effect (second call is a no-op), and re-planting then discarding the same slug +// archives to a deterministic, collision-free location — the discard is +// reversible and replayable. +func TestDiscardDeliveryIdempotentAndCollisionFree(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "stale-active", "BUILD", 0) + + first, err := DiscardDelivery(repo, "stale-active", false) + if err != nil || first.Action != "discarded" { + t.Fatalf("first discard failed: %+v %v", first, err) + } + second, err := DiscardDelivery(repo, "stale-active", false) + if err != nil { + t.Fatal(err) + } + if second.Action != "none" { + t.Fatalf("repeating discard duplicated the effect: %+v", second) + } + + // Re-plant the same slug and discard again — the archive must not collide. + writeNextDelivery(t, repo, "stale-active", "BUILD", 0) + third, err := DiscardDelivery(repo, "stale-active", false) + if err != nil || third.Action != "discarded" { + t.Fatalf("third discard failed: %+v %v", third, err) + } + if first.ArchivePath == third.ArchivePath { + t.Fatalf("second archive collided with the first: %s", third.ArchivePath) + } + if _, statErr := os.Stat(filepath.Join(repo, ".git", "boatstack", "deliveries", ".discarded", "stale-active-2")); statErr != nil { + t.Fatalf("collision-free archive not created deterministically: %v", statErr) + } +} diff --git a/boatstack/next.go b/boatstack/next.go index 8d34ab3..5ca9be8 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -180,7 +180,15 @@ func completedManagedStates(repo string) ([]DeliveryState, error) { } state, err := CurrentDeliveryState(repo, entry.Name()) if err != nil { - return nil, fmt.Errorf("invalid managed delivery state for %s: %w", entry.Name(), err) + // A completed delivery that cannot be verified on THIS branch — e.g. a + // divergent or absent committed plan lock for work shipped on another + // branch that shares the delivery store — must not poison resolution of + // an unrelated new feature. Structurally corrupt state is already + // surfaced upstream by scanManagedDeliveries; skipping here only + // tolerates cross-branch lock divergence. A delivery the caller actually + // acts on is still verified at its own boundary (explicit-feature lookup + // / nextForDelivery). control-law: stale-delivery-cannot-block-unrelated-feature + continue } if state.ActiveIndex >= len(state.Slices) { completed = append(completed, state) @@ -211,11 +219,23 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack project configuration is invalid: "+configErr.Error()), nil } - active, err := ActiveManagedDeliveries(repo) - if err != nil { - return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack found invalid managed delivery state. Preserve the artifacts and restore the missing or stale evidence before continuing: "+err.Error()), nil + // Read-only boundary: apply the ignored-deliveries filter BEFORE a single + // invalid delivery can escalate into a repo-wide INVALID_STATE. The strict + // ActiveManagedDeliveries (used by mutation paths) aborts on any invalid + // delivery; here we partition instead, filter both lists by the operator's + // ignore policy, and only then block — and only on a still-unignored invalid + // delivery, naming it and pointing at the discard-delivery remedy. This is + // what keeps one stale delivery in the shared store from blocking an + // 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 } active = withoutIgnoredDeliveries(active, config.Workflow.IgnoredDeliveries) + invalidDeliveries = withoutIgnoredDeliveries(invalidDeliveries, config.Workflow.IgnoredDeliveries) + if len(invalidDeliveries) > 0 { + return blockedNextStatus("INVALID_STATE", "discard-delivery", "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 continuing.", invalidDeliveries...), nil + } if explicitFeature != "" { found := false diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 5d991fd..83d683e 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 [`666b0631c31ee9509a4b3cb0f1242fe865a97c24`](https://github.com/operatorstack/intelligence-flow/tree/666b0631c31ee9509a4b3cb0f1242fe865a97c24/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 [`c6d00bbcf9defd9df85ead397ec1612efb650aee`](https://github.com/operatorstack/intelligence-flow/tree/c6d00bbcf9defd9df85ead397ec1612efb650aee/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 58120f8..24eb7d3 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "666b0631c31ee9509a4b3cb0f1242fe865a97c24", + "source_commit": "c6d00bbcf9defd9df85ead397ec1612efb650aee", "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" }, { "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:666b0631c31ee9509a4b3cb0f1242fe865a97c24" + "last_verified_version": "source:c6d00bbcf9defd9df85ead397ec1612efb650aee" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 63047da..c1bf2f8 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": "666b0631c31ee9509a4b3cb0f1242fe865a97c24", + "source_commit": "c6d00bbcf9defd9df85ead397ec1612efb650aee", "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-24-discard-delivery.md b/release-notes/2026-07-24-discard-delivery.md new file mode 100644 index 0000000..fa17681 --- /dev/null +++ b/release-notes/2026-07-24-discard-delivery.md @@ -0,0 +1,21 @@ +### A stale delivery no longer blocks new work, and you can clear one + +A single stale or abandoned managed delivery in Boatstack's local store used to +block *every* operation — including planning an unrelated new feature — because +one unverifiable delivery poisoned the whole status scan, and the +`ignored_deliveries` escape hatch was applied too late to help. + +Two changes fix this: + +- **Ignore is now honored before a stale delivery can block you.** `next-status` + applies your `ignored_deliveries` list *before* reporting an unverifiable + delivery, so an ignored delivery can no longer stop unrelated new work. A + genuinely unverifiable delivery still blocks, but now names itself and points + you at the fix. (Mutation and publish paths remain strict and fail closed on + corrupt state.) +- **New `discard-delivery` command.** Run + `boatstack-helper discard-delivery --feature ` to clear a stuck or + abandoned delivery so you can rebuild or re-plan it. It archives the state + (reversible) and never touches your git history, merged PRs, plan, or lock. A + delivery that has already published a slice is refused unless you pass + `--force`.