From b054082708c703425ce8a0a994b7fe22fc2bf4a7 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Mon, 27 Jul 2026 21:48:34 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 5e9cd972c1a7 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 23 ++- boatstack/denial.go | 105 +++++++++- boatstack/denial_solutions.go | 194 ++++++++++++++++++ .../denial_solutions_conformance_test.go | 193 +++++++++++++++++ boatstack/denial_test.go | 4 +- boatstack/safety.go | 67 +++--- boatstack/safety_corpus_test.go | 11 +- boatstack/safety_test.go | 6 +- docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 +-- labs/diagram-json/plan.lock.json | 2 +- ...2026-07-27-denials-name-their-solutions.md | 7 + 13 files changed, 581 insertions(+), 59 deletions(-) create mode 100644 boatstack/denial_solutions.go create mode 100644 boatstack/denial_solutions_conformance_test.go create mode 100644 release-notes/2026-07-27-denials-name-their-solutions.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7514ff3..870f7a9 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/6f60e1b420c8236479d1ed76d755372de52620fc/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/5e9cd972c1a75c067b73625cf411ecd290086715/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 969440e..6348705 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "581c585e4246b2ef56f1d875a306446d09dc5dedc507b3aee3ee7f22697e52e2", + "CONTRIBUTING.md": "6833ef30ef884c47136f2bc2e77eec791b723c6cdf561c223cb114f1208dc9a7", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -61,8 +61,10 @@ "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/deliverycontrol_parity_test.go": "f8662cfc35043395a0e1eef8a87051c2120752f38b09c56b78f82896008f1b65", - "boatstack/denial.go": "4132674988f35b776dc3207e5179dbb2e4da6b8e815a5913acfc3c8771f6ce51", - "boatstack/denial_test.go": "b865bf041b42ad4b904de0c7cf8d26ab7b5ba5bcc29407afe6caafa318f54791", + "boatstack/denial.go": "fabc517434048c2c7327f0b123baa5e9594507f6d4be460d742f9e8f59d2d0b8", + "boatstack/denial_solutions.go": "5e5f110ee62bb08f65e8240b85d34a16cea5cb19c1993e4b39f2e83addfaf95d", + "boatstack/denial_solutions_conformance_test.go": "1055048d1d7935dded699abdb7015b30a966e8e1ae54b1253c106dde54aff10e", + "boatstack/denial_test.go": "9dc9f0f79328c4947073efaa785479b34e70eb214da57cd72348f39fd672e4fd", "boatstack/detached.go": "b0ea2a1f31bf2a2a83f6089a3065a9b47221194b64de48af9120046e6d70dee8", "boatstack/detached_test.go": "6cc70d15baa9a69afacf66ea29ce112efeb166836acb0a52bf9c4bb4c898cee5", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", @@ -168,9 +170,9 @@ "boatstack/runtime_cache.go": "e026ffc1906f7e1e98b768bae63e6658164d2826c07169c9121ce0f23c73faf8", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "0d04805e834c9cf10ba59b823fa1dd194c226fdfcd0d67889953cb38b6305653", - "boatstack/safety_corpus_test.go": "e7d8c493d8cee957e4590f2c9034d4aa4af08bdcbe02c9889d0d98a0168bbcf9", - "boatstack/safety_test.go": "500ad53cd5e3a700553eb781d9eaf4028ae27478796759a76bfd57321fff5a7c", + "boatstack/safety.go": "432e20956789c74dfefc4b174238f320876c9e68ad6c6392286c6acdbdb5ef6d", + "boatstack/safety_corpus_test.go": "a43fdc4324abc9db0d93e407336a2b0ca5b755121013ae62ce7d844795a6d8b9", + "boatstack/safety_test.go": "ddd7a72d4ff50c046aa46fd97b108d629cef27cf15816150b6d54baf5f1c4c36", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", @@ -199,10 +201,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "d8fce9c9a21e9ddb382811bfe397edc526360c1ebe4e8ec4f5e603436e1082b8", + "docs/evidence-engineered-coding.md": "f2c0402bd0608ffb0ee40d797d7ca464683fdc20ff3b89cdf62de37fc52ce855", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "4932a0b1c1a59b3d75fa3facc206b8ba94720079b7de8f1c01ab10c0458d3df4", + "docs/public-claims.json": "9387bb9b0de3aeba92f1558bb27aefd3faf17a89284cde234857b1aaafdcc6ed", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -216,7 +218,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": "76555eec4fcf58509beb849db669a1a924d0c9f123a784b51876ed1f9a92fd34", + "labs/diagram-json/plan.lock.json": "96dc2d5722262e62271f1488b158f726f277b0e6d63fffed95f474c2962587ab", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -332,6 +334,7 @@ "release-notes/2026-07-26-workspace-reap.md": "e691d6a1c232cf218157880655413005fcb2c4f3113ededffdb80899a5054bb8", "release-notes/2026-07-27-constitutional-boundary-floor.md": "41514cbea53867c264e354f149638444ab0d80f64896b9cf720f3979ae78b3de", "release-notes/2026-07-27-coreachable-recovery.md": "6ffc6b0e9a7d46c0f99a64112813c33d19571c73d02e98ac5573924f1663fd54", + "release-notes/2026-07-27-denials-name-their-solutions.md": "ce05bbbb49f6d5c8d92515ecd0e89e4637ecf36f1e7f5005506d79927118be8a", "release-notes/2026-07-27-discoverable-planning-errors.md": "d8099d1a6cd1805c3fcd446d9fa95739dec93e57ff421ecf4f99562b143c9966", "release-notes/2026-07-27-document-content-is-data.md": "c6a35c222bf53ba465fadf21e7e95e1764b52e12e41852e51584ce9cb6513f4a", "release-notes/2026-07-27-first-planning-write-owned-channel.md": "7a37e7abf7fd5f8612aca4323d55af1748c9e668bb294518619a1c39e195309f", @@ -347,7 +350,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "6f60e1b420c8236479d1ed76d755372de52620fc", + "commit": "5e9cd972c1a75c067b73625cf411ecd290086715", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/denial.go b/boatstack/denial.go index 1412fff..eca7d54 100644 --- a/boatstack/denial.go +++ b/boatstack/denial.go @@ -58,6 +58,18 @@ type Denial struct { Detail string // guidance; may contain `code` spans Reassurance string // "Nothing was written; your files are untouched." (empty if an effect occurred) Hint string // recovery command, e.g. "boatstack-helper diagnose-hook" + // Options is the denial's computed solution set: the admissible commands + // from exactly the position the finding describes, so a weaker model picks + // a legal move instead of retrying the blocked one. Derived from the same + // declarations the guard enforces; renders as a short "You can:" list and + // rides in full on the structured payload. + // control-law: solution-set-derives-from-guard-declarations + Options []PrescribedCommand + OptionsTruncated bool + // OwnerVerbs names the verbs that own a protected path (state-tamper + // denials), derived from the state-ownership map. Named, never compiled + // into runnable commands — their full arguments are not derivable here. + OwnerVerbs []string } // --- ANSI palette (truecolor; matches the approved mockup) ------------------- @@ -107,6 +119,29 @@ func (d Denial) Render(mode RenderMode) string { } } +// optionLines renders the solution set as at most `limit` numbered command +// lines, plus an overflow note. Shared by the three text renderers so every +// surface shows the same picks. +// control-law: solution-set-derives-from-guard-declarations +func (d Denial) optionLines(limit int) []string { + if len(d.Options) == 0 { + return nil + } + shown := d.Options + if len(shown) > limit { + shown = shown[:limit] + } + lines := make([]string, 0, len(shown)+1) + for i, option := range shown { + lines = append(lines, fmt.Sprintf(" %d) %s", i+1, option.CommandLine())) + } + hidden := len(d.Options) - len(shown) + if d.OptionsTruncated || hidden > 0 { + lines = append(lines, " (more legal moves: run boatstack-helper next-status)") + } + return lines +} + func (d Denial) renderPlain(badge string) string { var b strings.Builder head := badge @@ -122,6 +157,13 @@ func (d Denial) renderPlain(badge string) string { b.WriteString("\n\n↳ ") b.WriteString(d.Reassurance) } + if len(d.OwnerVerbs) > 0 { + b.WriteString("\n\nThis path is owned by: " + strings.Join(d.OwnerVerbs, ", ") + ".") + } + if lines := d.optionLines(solutionSetTextCap); len(lines) > 0 { + b.WriteString("\n\nYou can:\n") + b.WriteString(strings.Join(lines, "\n")) + } if d.Hint != "" { b.WriteString("\n\nFalse positive? run: ") b.WriteString(d.Hint) @@ -141,6 +183,15 @@ func (d Denial) renderMarkdown(badge string) string { if d.Reassurance != "" { b.WriteString("\n\n↳ _" + d.Reassurance + "_") } + if len(d.OwnerVerbs) > 0 { + b.WriteString("\n\nThis path is owned by: `" + strings.Join(d.OwnerVerbs, "`, `") + "`.") + } + if lines := d.optionLines(solutionSetTextCap); len(lines) > 0 { + b.WriteString("\n\nYou can:\n") + for _, line := range lines { + b.WriteString("\n" + line) + } + } if d.Hint != "" { b.WriteString("\n\nFalse positive? run `" + d.Hint + "`") } @@ -160,6 +211,15 @@ func (d Denial) renderANSI(badge string) string { if d.Reassurance != "" { b.WriteString("\n" + fgGray + "↳ " + d.Reassurance + ansiReset) } + if len(d.OwnerVerbs) > 0 { + b.WriteString("\n" + fgGray + "this path is owned by: " + ansiReset + fgCode + strings.Join(d.OwnerVerbs, ", ") + ansiReset) + } + if lines := d.optionLines(solutionSetTextCap); len(lines) > 0 { + b.WriteString("\n" + fgGray + "you can:" + ansiReset) + for _, line := range lines { + b.WriteString("\n" + fgCode + line + ansiReset) + } + } if d.Hint != "" { b.WriteString("\n" + fgGray + ansiDim + "false positive? run " + ansiReset + fgCode + d.Hint + ansiReset) } @@ -206,6 +266,33 @@ func (d Denial) Structured() map[string]any { if d.Hint != "" { out["hint"] = d.Hint } + // Additive keys only — schema_version stays 1; a consumer that ignores them + // loses nothing (the flat reason string already carries the capped picks). + // control-law: solution-set-derives-from-guard-declarations + if len(d.Options) > 0 { + options := make([]map[string]any, 0, len(d.Options)) + for _, option := range d.Options { + row := map[string]any{ + "verb": option.Verb, + "command_line": option.CommandLine(), + "transition": string(option.Transition), + } + if len(option.Args) > 0 { + row["args"] = option.Args + } + if len(option.RequiresHumanInput) > 0 { + row["requires_human_input"] = option.RequiresHumanInput + } + options = append(options, row) + } + out["options"] = options + if d.OptionsTruncated { + out["options_truncated"] = true + } + } + if len(d.OwnerVerbs) > 0 { + out["owner_verbs"] = d.OwnerVerbs + } return out } @@ -296,11 +383,27 @@ func DenialDemo(host string, mode RenderMode) string { if i > 0 { b.WriteString("\n\n") } - b.WriteString(denialFor(host, finding).Render(mode)) + b.WriteString(denialWithOptions(".", host, finding).Render(mode)) } return b.String() } +// denialWithOptions composes the pure finding→Denial mapping with the +// enumerated solution set for the finding's position. denialFor stays pure +// (DenialDemo and tests use it directly); the hook deny paths call this so +// every real denial carries its picks. +// control-law: solution-set-derives-from-guard-declarations +func denialWithOptions(repo, host string, finding SafetyFinding) Denial { + d := denialFor(host, finding) + set := enumerateDenialSolutions(repo, host, finding) + d.Options = set.Options + d.OptionsTruncated = set.Truncated + if finding.Category == "workflow-state-tamper" { + d.OwnerVerbs = tamperOwnerVerbs(repo, finding.AttemptedPath) + } + return d +} + const reassureUntouched = "Nothing was written; your files are untouched." // denialFor maps a SafetyFinding to a structured Denial. It preserves every diff --git a/boatstack/denial_solutions.go b/boatstack/denial_solutions.go new file mode 100644 index 0000000..3eaf32f --- /dev/null +++ b/boatstack/denial_solutions.go @@ -0,0 +1,194 @@ +package boatstack + +import ( + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// A denial that only states the law leaves a weaker model looping on the same +// blocked call. Each denial therefore carries the law's computed solution set: +// the admissible commands from exactly the position the finding describes, +// derived from the same declarations the guard enforces (the planning +// enumeration for phase findings, the registry's observe rows, the +// state-ownership map for protected paths) — never a hand-written list. +// The enumeration reads only the finding's own fields and the declared tables, +// so the deny path stays fast and cannot itself fail on unreadable state. +// control-law: solution-set-derives-from-guard-declarations + +// denialMarker names a denial-prescribed helper verb outside the delivery +// model, mirroring the planning./recovery. marker convention: self-describing +// provenance, never a legal registry transition, never auto-driven. +func denialMarker(verb string) deliverycontrol.TransitionID { + return deliverycontrol.TransitionID("denial." + strings.ReplaceAll(verb, "-", "_")) +} + +// denialSolutionExceptions are the finding categories that deliberately carry +// no solution set, with the reason. The totality sweep fails a new category +// until it gains an enumeration rule or an entry here. +var denialSolutionExceptions = map[string]string{ + "malformed-tool-input": "the tool event itself is unreadable; the Detail already names diagnose-hook with the exact host", + "unsupported-host": "an unknown host has no trusted verb surface to enumerate", + "unresolved-repository": "without a repository identity no command can be assembled faithfully", +} + +// enumerateDenialSolutions computes the solution set for a denial finding. +// host is the coding host the hook is serving (for diagnose-hook assembly). +func enumerateDenialSolutions(repo, host string, finding SafetyFinding) SolutionSet { + set := SolutionSet{Basis: "denial", Stage: finding.WorkflowStage} + if _, excepted := denialSolutionExceptions[finding.Category]; excepted { + return set + } + switch { + case finding.Category == "workflow-phase-bypass", finding.Category == "workflow-state-invalid": + // The finding carries the exact planning position; re-run the planning + // enumeration from it. Pure — no filesystem reads on the deny path. + status := NextStatus{ + ObservedStage: finding.WorkflowStage, + NextOperation: finding.NextOperation, + Feature: finding.BlockingFeature, + } + if finding.Category == "workflow-state-invalid" { + status.ObservedStage = "INVALID_STATE" + if finding.BlockingFeature != "" { + status.BlockingAmbiguity = []string{finding.BlockingFeature} + } + } + next := FlowNext{} + if cmd, _ := prescribePlanning(repo, status); cmd != nil { + next.Prescribed = cmd + } + planning := enumeratePlanningSolutions(repo, status, next) + set.Options, set.Truncated = planning.Options, planning.Truncated + return set + + case finding.Category == "workflow-state-tamper": + // The state-ownership map already declares who may write the path; the + // pick list is the position observers plus the hook diagnosis, and the + // owning verbs surface separately (OwnerVerbs) — a verb whose full + // arguments we cannot derive is named, never fabricated into a command. + appendObserveOption(&set, repo, "", "delivery.next") + appendDiagnoseHook(&set, repo, host) + return set + + case finding.Category == "workflow-publication-bypass": + if finding.BlockingFeature != "" { + if cmd, ok := prescribeCommand(repo, finding.BlockingFeature, NextStatus{ActiveSlice: finding.BlockingSlice}, PublishTransition); ok { + appendSolution(&set, *cmd) + } + } + appendObserveOption(&set, repo, finding.BlockingFeature, "delivery.recovery_status") + appendObserveOption(&set, repo, "", "delivery.next") + return set + + case strings.HasPrefix(finding.Category, "operation-"): + // Observation-only by design: inspect the durable operation state before + // any retry (the observed-effect discipline). + appendSolution(&set, PrescribedCommand{ + Verb: "operation-status", Args: repoFlagArgs(repo), AutoDerivable: true, + Transition: denialMarker("operation-status"), + }) + appendSolution(&set, PrescribedCommand{ + Verb: "mutation-status", Args: repoFlagArgs(repo), AutoDerivable: true, + Transition: denialMarker("mutation-status"), + }) + return set + + case finding.Category == "filesystem-destruction": + // The sanctioned actuator for the one deletion Boatstack owns; the + // operator confirmation is owed, never assumed. + appendSolution(&set, PrescribedCommand{ + Verb: "workspace-reap", Args: repoFlagArgs(repo), + RequiresHumanInput: []string{"--confirm"}, + Transition: denialMarker("workspace-reap"), + }) + appendDoctor(&set, repo) + appendObserveOption(&set, repo, "", "delivery.next") + return set + } + + // Generic fallthrough (destruction families, sync bypass, anything new): + // the position observers and the installation diagnosis are always legal. + appendObserveOption(&set, repo, "", "delivery.next") + appendDoctor(&set, repo) + return set +} + +// tamperOwnerVerbs derives the owning verbs of a protected path from the +// state-ownership map: the guard-protected entry whose boatstack subtree the +// attempted path names. Derived at runtime from StateRegistry — the same +// declaration the statemap conformance holds to the guard patterns. +// control-law: every-managed-path-has-a-declared-owner +func tamperOwnerVerbs(repo, attempted string) []string { + if attempted == "" { + return nil + } + normalized := filepath_ToSlashLower(attempted) + w := WorkspaceFor(repo) + for _, entry := range StateRegistry() { + if !entry.GuardProtected { + continue + } + sample, err := entry.Sample(w) + if err != nil { + continue + } + key := boatstackSubtreeKey(filepath_ToSlashLower(sample)) + if key != "" && strings.Contains(normalized, "boatstack/"+key) { + return entry.OwnerVerbs + } + } + return nil +} + +// boatstackSubtreeKey extracts the first path segment after the last +// "boatstack/" in a sample path — the subtree a guard-protected entry owns. +func boatstackSubtreeKey(path string) string { + marker := "boatstack/" + index := strings.LastIndex(path, marker) + if index < 0 { + return "" + } + rest := path[index+len(marker):] + if cut := strings.IndexByte(rest, '/'); cut >= 0 { + return rest[:cut] + } + return rest +} + +// filepath_ToSlashLower normalizes a path for fragment matching across +// platforms and case conventions. +func filepath_ToSlashLower(path string) string { + return strings.ToLower(strings.ReplaceAll(path, "\\", "/")) +} + +func appendObserveOption(set *SolutionSet, repo, feature, transition string) { + descriptor, ok := deliverycontrol.Transition(deliverycontrol.TransitionID(transition)) + if !ok { + return + } + if cmd, ok := prescribeObserve(repo, feature, descriptor); ok { + appendSolution(set, *cmd) + } +} + +func appendDoctor(set *SolutionSet, repo string) { + appendSolution(set, PrescribedCommand{ + Verb: "doctor", Args: repoFlagArgs(repo), AutoDerivable: true, + Transition: MarkerRecoveryDoctor, + }) +} + +func appendDiagnoseHook(set *SolutionSet, repo, host string) { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + appendDoctor(set, repo) + return + } + appendSolution(set, PrescribedCommand{ + Verb: "diagnose-hook", + Args: append([]string{"--host", host}, repoFlagArgs(repo)...), + AutoDerivable: true, + Transition: denialMarker("diagnose-hook"), + }) +} diff --git a/boatstack/denial_solutions_conformance_test.go b/boatstack/denial_solutions_conformance_test.go new file mode 100644 index 0000000..dfee3c8 --- /dev/null +++ b/boatstack/denial_solutions_conformance_test.go @@ -0,0 +1,193 @@ +package boatstack + +import ( + "strings" + "testing" +) + +// control-law: guard-never-prescribes-what-it-would-deny +// control-law: solution-set-derives-from-guard-declarations +// +// Denial-basis conformance for the solution set. A denial that only states the +// law leaves a weaker model looping on the same blocked call — the sibling +// harness's paid canary recorded exactly that trajectory: sixteen no-progress +// repair attempts escalating into a protected-boundary write. Every denial +// therefore carries computed picks, and these sweeps hold that carrier total +// (every category enumerates or is a documented exception) and closed (every +// pick passes the guard's own text laws). + +// denialCategoryInventory is one representative finding per category +// denialFor distinguishes, including the operation-* and generic fallthroughs. +// Extend it together with denialFor; the totality sweep fails a category that +// is neither enumerated nor excepted. +var denialCategoryInventory = []SafetyFinding{ + {Category: "malformed-tool-input", Reason: "empty-command", Source: "hook"}, + {Category: "workflow-state-invalid", NextOperation: "discard-delivery", BlockingFeature: "stale", Source: "delivery-state"}, + {Category: "workflow-state-tamper", Source: "delivery-state", AttemptedPath: ".git/boatstack/deliveries/demo/state.json"}, + {Category: "workflow-phase-bypass", Source: "planning-state", WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo"}, + {Category: "workflow-phase-bypass", Source: "planning-state", WorkflowStage: "NOT_STARTED", NextOperation: "planning-write", AttemptedPath: ".product-loop/features/demo/plan.md"}, + {Category: "workflow-publication-bypass", BlockingFeature: "demo", BlockingSlice: "s1", Source: "tool-input"}, + {Category: "operation-in-flight", OperationID: "op_1", OperationState: "RUNNING", Source: "operation-state"}, + {Category: "operation-already-succeeded", OperationID: "op_2", OperationState: "SUCCEEDED", Source: "operation-state"}, + {Category: "operation-reconciliation-required", OperationID: "op_3", Source: "operation-state"}, + {Category: "operation-retry-exhausted", OperationID: "op_4", Source: "operation-state"}, + {Category: "operation-state-invalid", Source: "operation-state"}, + {Category: "git-history-destruction", Source: "command"}, + {Category: "workspace-sync-bypass", Source: "command"}, + {Category: "filesystem-destruction", Source: "command"}, + {Category: "database-destruction", Source: "command"}, + {Category: "infrastructure-destruction", Source: "command"}, + {Category: "external-resource-destruction", Source: "tool-input"}, + {Category: "unsupported-host", Source: "hook"}, + {Category: "unresolved-repository", Source: "hook"}, + {Category: "symlink-entrypoint", Source: "entry.sh"}, +} + +// Totality: every denial category yields a non-empty solution set or sits on +// the documented exception list with a reason. +func TestEveryDenialCategoryEnumeratesOrIsExcepted(t *testing.T) { + for _, finding := range denialCategoryInventory { + set := enumerateDenialSolutions(".", "claude", finding) + if reason, excepted := denialSolutionExceptions[finding.Category]; excepted { + if reason == "" { + t.Errorf("exception for %s must carry a reason", finding.Category) + } + if len(set.Options) != 0 { + t.Errorf("%s is excepted but enumerates options — remove the stale exception", finding.Category) + } + continue + } + if len(set.Options) == 0 { + t.Errorf("category %s enumerates no options and is not a documented exception", finding.Category) + } + } +} + +// Closure: every denial pick, after owed-input substitution, passes the +// text-level guard laws — the managed-state path law and the destruction +// classifier. The guard never hands out a command it would then deny as text. +func TestDenialSolutionCommandsPassTheTextGuards(t *testing.T) { + for _, finding := range denialCategoryInventory { + set := enumerateDenialSolutions(".", "claude", finding) + for _, option := range set.Options { + line := substituteOwedFlags(option.CommandLine()) + if deliveryStatePathPattern.MatchString(line) && !isPureReadOnlyCommand(line) && !approvedUpdatePublisherPattern.MatchString(line) { + t.Errorf("%s: pick %q names managed state the guard would deny", finding.Category, line) + } + if findings := classifySafetyText(line, "command", commandExecutesLiveSQL(line)); len(findings) > 0 { + t.Errorf("%s: pick %q trips the text guard: %+v", finding.Category, line, findings) + } + if option.AutoDerivable != (len(option.RequiresHumanInput) == 0) { + t.Errorf("%s: AutoDerivable must equal owed-input emptiness: %+v", finding.Category, option) + } + for _, owed := range option.RequiresHumanInput { + for _, arg := range option.Args { + if arg == owed { + t.Errorf("%s: owed flag %s fabricated into Args: %+v", finding.Category, owed, option) + } + } + } + } + } +} + +// Relation: a phase-bypass denial's picks are exactly guard-admitted at the +// finding's own stage — the same closure the flow basis holds, entered through +// the denial door. +func TestPhaseBypassDenialPicksAreGuardAdmitted(t *testing.T) { + finding := SafetyFinding{ + Category: "workflow-phase-bypass", Source: "planning-state", + WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo", + } + set := enumerateDenialSolutions(".", "claude", finding) + if len(set.Options) == 0 { + t.Fatal("phase-bypass must enumerate picks") + } + for _, option := range set.Options { + line := substituteOwedFlags(option.CommandLine()) + if !controlledPhaseTransition(line, finding.WorkflowStage) { + t.Errorf("denial pick %q is not admitted at %s", line, finding.WorkflowStage) + } + } +} + +// Ownership: a state-tamper denial names the attempted path's declared owner +// verbs from the state-ownership map, and each named verb is a real one the +// map declares for that subtree. +func TestTamperDenialNamesDeclaredOwnerVerbs(t *testing.T) { + repo := safetyTestRepo(t) + cases := map[string][]string{ + ".git/boatstack/deliveries/demo/state.json": {"activate-plan", "record-delivery-gate", "record-change", "publish-pr", "repair-state", "discard-delivery"}, + ".git/boatstack/updates/v9.9.9/pr-preview.json": {"prepare-update-pr", "publish-update-pr"}, + ".git/boatstack/mutations/v1/abc.json": {"activate-plan", "undo"}, + ".git/boatstack/quarantine/demo/receipt.json": {"repair-state"}, + "state-root/boatstack/registry.json": {"attach", "detach"}, + ".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication"}, + "boatstack/repositories/sample/binding.json": {"attach", "detach", "activate"}, + } + for attempted, want := range cases { + got := tamperOwnerVerbs(repo, attempted) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("owner verbs for %s = %v, want %v", attempted, got, want) + } + } + if got := tamperOwnerVerbs(repo, ""); got != nil { + t.Errorf("empty attempted path must derive no owners, got %v", got) + } + if got := tamperOwnerVerbs(repo, "src/app.ts"); got != nil { + t.Errorf("unmanaged path must derive no owners, got %v", got) + } +} + +// Rendering: the plain denial carries the capped "You can:" list; the +// structured payload carries the full set additively under schema_version 1. +func TestDenialRenderingCarriesTheSolutionSet(t *testing.T) { + finding := SafetyFinding{ + Category: "workflow-phase-bypass", Source: "planning-state", + WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo", + } + denial := denialWithOptions(".", "claude", finding) + if len(denial.Options) == 0 { + t.Fatal("denial must carry options") + } + for _, mode := range []RenderMode{RenderPlain, RenderMarkdown, RenderANSI} { + out := denial.Render(mode) + if !strings.Contains(strings.ToLower(out), "you can:") { + t.Errorf("mode %v must render the You can list:\n%s", mode, out) + } + } + plain := denial.Render(RenderPlain) + if got := strings.Count(plain, "\n "); got > solutionSetTextCap+1 { + t.Errorf("plain rendering must cap the pick list, got %d lines:\n%s", got, plain) + } + structured := denial.Structured() + if structured["schema_version"] != 1 { + t.Fatalf("options are additive; schema_version must stay 1, got %v", structured["schema_version"]) + } + options, ok := structured["options"].([]map[string]any) + if !ok || len(options) != len(denial.Options) { + t.Fatalf("structured options must carry the full set: %v", structured["options"]) + } + for _, row := range options { + if row["command_line"] == "" || row["verb"] == "" || row["transition"] == "" { + t.Errorf("structured option incomplete: %v", row) + } + } + + // Ownership derivation resolves per-worktree sample paths, which needs a + // real Git directory — the projected distribution runs these tests outside + // any repository, so the tamper case uses the git-backed fixture. + tamper := denialWithOptions(safetyTestRepo(t), "claude", SafetyFinding{ + Category: "workflow-state-tamper", Source: "delivery-state", + AttemptedPath: ".git/boatstack/deliveries/demo/state.json", + }) + if len(tamper.OwnerVerbs) == 0 { + t.Fatal("tamper denial must derive owner verbs") + } + if out := tamper.Render(RenderPlain); !strings.Contains(out, "This path is owned by: activate-plan") { + t.Errorf("tamper rendering must name the owners:\n%s", out) + } + if verbs, ok := tamper.Structured()["owner_verbs"].([]string); !ok || len(verbs) == 0 { + t.Errorf("structured tamper payload must carry owner_verbs") + } +} diff --git a/boatstack/denial_test.go b/boatstack/denial_test.go index 4f4aaaf..f2c5517 100644 --- a/boatstack/denial_test.go +++ b/boatstack/denial_test.go @@ -154,7 +154,7 @@ func TestStructuredDenialObjectAndRichGate(t *testing.T) { // Rich object is gated off by default; the flat reason stays complete. t.Setenv("BOATSTACK_DENIAL_RICH", "") - out, err := structuredHookDeny("claude", finding) + out, err := structuredHookDeny(".", "claude", finding) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestStructuredDenialObjectAndRichGate(t *testing.T) { // Opt-in adds the object while keeping the flat reason. t.Setenv("BOATSTACK_DENIAL_RICH", "1") - out, err = structuredHookDeny("claude", finding) + out, err = structuredHookDeny(".", "claude", finding) if err != nil { t.Fatal(err) } diff --git a/boatstack/safety.go b/boatstack/safety.go index f176863..d0f85b6 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -807,7 +807,10 @@ func ClassifyCommand(repo, command string) []SafetyFinding { return []SafetyFinding{{Category: "malformed-tool-input", Reason: "empty-command", Source: "tool-input"}} } if deliveryStatePathPattern.MatchString(command) && !isPureReadOnlyCommand(command) && !approvedUpdatePublisherPattern.MatchString(command) { - return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}} + // AttemptedPath carries the matched managed-path fragment (bounded and + // secret-free, like the phase-bypass finding) so the denial can name the + // path's declared owner verbs from the state-ownership map. + return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state", AttemptedPath: deliveryStatePathPattern.FindString(command)}} } if directPublicationPattern.MatchString(command) && !approvedPublisherPattern.MatchString(command) { if finding, blocked := publicationBypassFinding(repo, "direct push or PR mutation is denied while a managed delivery slice is active", "tool-input"); blocked { @@ -895,7 +898,7 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { } publicationText := strings.ToLower(combined) if deliveryStatePathPattern.MatchString(combined) && regexp.MustCompile(`(?:write|edit|delete|remove|move|rename|create|update)`).MatchString(nameLower) { - findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}) + findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state", AttemptedPath: deliveryStatePathPattern.FindString(combined)}) } if (strings.Contains(publicationText, "pull_request") || strings.Contains(publicationText, "pull request")) && regexp.MustCompile(`(?:create|update|edit|merge|publish)`).MatchString(publicationText) { @@ -1121,7 +1124,10 @@ func dedupeFindings(values []SafetyFinding) []SafetyFinding { type hookHostContract struct { decode func([]byte) (string, any, error) allow func() ([]byte, error) - deny func(SafetyFinding) ([]byte, error) + // deny takes the resolved repository so the denial can carry its computed + // solution set (empty when the repository could not be resolved). + // control-law: solution-set-derives-from-guard-declarations + deny func(repo string, finding SafetyFinding) ([]byte, error) } func decodeJSONObject(host string, value []byte) (map[string]any, error) { @@ -1259,17 +1265,17 @@ func decodeGeminiHook(value []byte) (string, any, error) { return name, input, nil } -func structuredHookDeny(host string, finding SafetyFinding) ([]byte, error) { - message := denialMessage(host, finding) +func structuredHookDeny(repo, host string, finding SafetyFinding) ([]byte, error) { + denial := denialWithOptions(repo, host, finding) hookOutput := map[string]any{ - "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message, + "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": denial.Render(RenderPlain), } // Opt-in structured object for hosts that adopt rich denial rendering. Nested // inside the host's existing container; the flat reason above is always the // complete fallback for any host that ignores it. Off by default (no host // documents tolerating unknown keys — see references/host-hook-contracts.md). if denialRichEnabled() { - hookOutput["boatstackDenial"] = denialFor(host, finding).Structured() + hookOutput["boatstackDenial"] = denial.Structured() } value, err := json.Marshal(map[string]any{"hookSpecificOutput": hookOutput}) return append(value, '\n'), err @@ -1282,13 +1288,14 @@ var hookHostContracts = map[string]hookHostContract{ value, err := json.Marshal(map[string]any{"continue": true, "permission": "allow"}) return append(value, '\n'), err }, - deny: func(finding SafetyFinding) ([]byte, error) { - message := denialMessage("cursor", finding) + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + denial := denialWithOptions(repo, "cursor", finding) + message := denial.Render(RenderPlain) payload := map[string]any{ "continue": true, "permission": "deny", "user_message": message, "agent_message": message, } if denialRichEnabled() { - payload["boatstackDenial"] = denialFor("cursor", finding).Structured() + payload["boatstackDenial"] = denial.Structured() } value, err := json.Marshal(payload) return append(value, '\n'), err @@ -1297,12 +1304,16 @@ var hookHostContracts = map[string]hookHostContract{ "claude": { decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("claude", value) }, allow: func() ([]byte, error) { return nil, nil }, - deny: func(finding SafetyFinding) ([]byte, error) { return structuredHookDeny("claude", finding) }, + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + return structuredHookDeny(repo, "claude", finding) + }, }, "codex": { decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("codex", value) }, allow: func() ([]byte, error) { return nil, nil }, - deny: func(finding SafetyFinding) ([]byte, error) { return structuredHookDeny("codex", finding) }, + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + return structuredHookDeny(repo, "codex", finding) + }, }, "gemini": { decode: decodeGeminiHook, @@ -1310,10 +1321,11 @@ var hookHostContracts = map[string]hookHostContract{ value, err := json.Marshal(map[string]any{"decision": "allow"}) return append(value, '\n'), err }, - deny: func(finding SafetyFinding) ([]byte, error) { - payload := map[string]any{"decision": "deny", "reason": denialMessage("gemini", finding)} + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + denial := denialWithOptions(repo, "gemini", finding) + payload := map[string]any{"decision": "deny", "reason": denial.Render(RenderPlain)} if denialRichEnabled() { - payload["boatstackDenial"] = denialFor("gemini", finding).Structured() + payload["boatstackDenial"] = denial.Structured() } value, err := json.Marshal(payload) return append(value, '\n'), err @@ -1322,12 +1334,13 @@ var hookHostContracts = map[string]hookHostContract{ } // denialMessage renders the human-facing reason string embedded in a host's hook -// decision. It delegates to the structured Denial model (denial.go) and renders -// the plain, multi-line form — the safe default that every host displays. Richer -// treatments (markdown, ANSI, the structured object) are produced from the same -// Denial by the CLI/guard surfaces and the opt-in rich path. -func denialMessage(host string, finding SafetyFinding) string { - return denialFor(host, finding).Render(RenderPlain) +// decision. It delegates to the structured Denial model (denial.go) — including +// the finding's computed solution set — and renders the plain, multi-line form, +// the safe default that every host displays. Richer treatments (markdown, ANSI, +// the structured object) are produced from the same Denial by the CLI/guard +// surfaces and the opt-in rich path. +func denialMessage(repo, host string, finding SafetyFinding) string { + return denialWithOptions(repo, host, finding).Render(RenderPlain) } // AmbientHookDecision is the entry point for a developer-level (user-scoped) guard @@ -1355,19 +1368,19 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { contract, supported := hookHostContracts[host] if !supported { finding := SafetyFinding{Category: "unsupported-host", Reason: "unknown host is denied by the fail-closed guard", Source: "hook"} - value, _ := structuredHookDeny("codex", finding) + value, _ := structuredHookDeny("", "codex", finding) return value, true } repo, err := ResolveRepository(options.Repo) if err != nil { finding := SafetyFinding{Category: "unresolved-repository", Reason: "repository identity could not be established", Source: "hook"} - value, _ := contract.deny(finding) + value, _ := contract.deny("", finding) return value, true } if handled, malformed := completeSupervisedToolEvent(repo, host, options.Input); handled { if malformed { finding := SafetyFinding{Category: "malformed-tool-input", Reason: "invalid-post-event", Source: "hook"} - value, _ := contract.deny(finding) + value, _ := contract.deny(repo, finding) return value, true } value, _ := contract.allow() @@ -1381,19 +1394,19 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { reason = decodeErr.code } finding := SafetyFinding{Category: "malformed-tool-input", Reason: reason, Source: "hook"} - value, _ := contract.deny(finding) + value, _ := contract.deny(repo, finding) return value, true } findings := ClassifyTool(repo, name, input) if len(findings) == 0 { if finding := superviseToolAttempt(repo, host, name, input, options.Input); finding != nil { - value, _ := contract.deny(*finding) + value, _ := contract.deny(repo, *finding) return value, true } value, _ := contract.allow() return value, false } - value, _ := contract.deny(findings[0]) + value, _ := contract.deny(repo, findings[0]) return value, true } diff --git a/boatstack/safety_corpus_test.go b/boatstack/safety_corpus_test.go index 633f2cf..c5f786c 100644 --- a/boatstack/safety_corpus_test.go +++ b/boatstack/safety_corpus_test.go @@ -109,10 +109,19 @@ func TestGuardCorpusDualReward(t *testing.T) { "content": "Delivery state lives under `.git/boatstack/deliveries/` and is owned by the helper.", }, "routine", false}, {"edit-note-mentions-git-reset", "Edit", map[string]any{ - "file_path": filepath.Join(repo, "notes.md"), + "file_path": filepath.Join(repo, "notes.md"), "old_string": "TODO", "new_string": "Never run `git reset --hard` on shared branches; use revert PRs.", }, "routine", false}, + // Solution-set picks: commands the guard itself enumerates as legal moves + // at THIS fixture's stage must pass the guard + // (guard-never-prescribes-what-it-would-deny). Stage-scoped picks like + // record-approval are held in their own fixture by the closure sweep. + {"pick-undo", "", `boatstack-helper undo --mutation abc123`, "routine", false}, + {"pick-discard-delivery", "", `boatstack-helper discard-delivery --feature stale`, "routine", false}, + {"pick-operation-status", "", `boatstack-helper operation-status`, "routine", false}, + {"pick-record-change", "", `boatstack-helper record-change --feature demo --slice s1 --message fix --source-stage ci --classification implementation_repair`, "routine", false}, + {"pick-next-status", "", `boatstack-helper next-status`, "routine", false}, } var constTotal, constBlocked, routineTotal, routinePassed int diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index e921645..9b12703 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -100,7 +100,7 @@ func TestReapHelperIsExemptWhileRawWorktreeRemovalStaysDenied(t *testing.T) { if len(rawFilesystem) == 0 || rawFilesystem[0].Category != "filesystem-destruction" { t.Fatalf("raw worktree deletion was not denied: %#v", rawFilesystem) } - if message := denialMessage("cursor", rawFilesystem[0]); !strings.Contains(message, "workspace-reap") { + if message := denialMessage(".", "cursor", rawFilesystem[0]); !strings.Contains(message, "workspace-reap") { t.Fatalf("filesystem-destruction denial should redirect to workspace-reap: %s", message) } @@ -109,7 +109,7 @@ func TestReapHelperIsExemptWhileRawWorktreeRemovalStaysDenied(t *testing.T) { if len(rawState) == 0 || rawState[0].Category != "workflow-state-tamper" { t.Fatalf("raw runtime-state deletion was not denied: %#v", rawState) } - if message := denialMessage("cursor", rawState[0]); !strings.Contains(message, "workspace-reap") { + if message := denialMessage(".", "cursor", rawState[0]); !strings.Contains(message, "workspace-reap") { t.Fatalf("workflow-state-tamper denial should mention workspace-reap: %s", message) } } @@ -142,7 +142,7 @@ func TestWorkspaceSyncIsTheOnlyAllowedRepositoryAlignmentCommand(t *testing.T) { if len(raw) == 0 || raw[0].Category != "git-history-destruction" { t.Fatalf("raw hard reset was not denied: %#v", raw) } - message := denialMessage("cursor", raw[0]) + message := denialMessage(".", "cursor", raw[0]) for _, expected := range []string{"project-local workspace-sync", "do not scan delivery artifacts", "do not", "retry"} { if !strings.Contains(message, expected) { t.Fatalf("hard-reset denial omitted %q: %s", expected, message) diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 5f47a0d..a4cb151 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 [`6f60e1b420c8236479d1ed76d755372de52620fc`](https://github.com/operatorstack/intelligence-flow/tree/6f60e1b420c8236479d1ed76d755372de52620fc/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 [`5e9cd972c1a75c067b73625cf411ecd290086715`](https://github.com/operatorstack/intelligence-flow/tree/5e9cd972c1a75c067b73625cf411ecd290086715/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 e9e4f74..5139a4c 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "6f60e1b420c8236479d1ed76d755372de52620fc", + "source_commit": "5e9cd972c1a75c067b73625cf411ecd290086715", "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" }, { "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:6f60e1b420c8236479d1ed76d755372de52620fc" + "last_verified_version": "source:5e9cd972c1a75c067b73625cf411ecd290086715" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 57c8c09..08c9853 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": "6f60e1b420c8236479d1ed76d755372de52620fc", + "source_commit": "5e9cd972c1a75c067b73625cf411ecd290086715", "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-denials-name-their-solutions.md b/release-notes/2026-07-27-denials-name-their-solutions.md new file mode 100644 index 0000000..8fa5d7e --- /dev/null +++ b/release-notes/2026-07-27-denials-name-their-solutions.md @@ -0,0 +1,7 @@ +### Denials now hand you the legal moves + +A denial that only states the rule leaves an agent — especially a smaller model — retrying the same blocked call. Every guard denial now carries its computed solution set: a short "You can:" list of exact runnable commands that are legal from the position the denial describes, with owed human inputs marked. A plan-gate denial lists the planning channel commands for that stage; an operation denial lists the inspection commands; a protected-path denial additionally names the verbs that own the path, straight from the state-ownership map. + +The picks are computed from the same declarations the guard enforces, and conformance sweeps keep the loop closed: every category of denial either enumerates picks or is a documented exception, and every pick passes the guard's own laws — the guard never hands out a command it would then deny. + +The plain reason string carries up to three picks on every host; the full set rides on the opt-in structured payload, additively, under the same schema version.