Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
105 changes: 104 additions & 1 deletion labs/12-product-engineering-loop/product-engineering-loop/denial.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) -------------------
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 + "`")
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
})
}
Loading
Loading