diff --git a/.serena/memories/core.md b/.serena/memories/core.md index a2b58820a..820181aad 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -1872,6 +1872,22 @@ judge_fingerprint`, its own domain tag), so a caller can reference content it projection that spawned would be the class's whole point undone. `Surface::Hook` is refused (`tests/facts.rs`'s `no_effect_fact_is_hook_resolvable`), as a census over `Fact::ALL` rather than an assertion about this one variant. +- `preset.rs` — one manifest per vendored preset (CLOUD-1181): identity, the + `scope` its modules decide, the modules themselves, and the refusal classes + they raise. It exists because a preset used to be three unrelated `const`s that + nothing tied together — the name-to-modules table here, the verdict rows inside + `verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the + `[[pattern]]` refusal — so a preset carried no identity beyond its name, no + version, and no declared scope. **Below `policy` and `verdict`, and it reads + neither**: both project the one declaration rather than three tables knowing + about each other, which is what `module-layering` pins. `scope` is the field + the row was written for, and the honest reading of what it buys is in the load + site's own comment: a mismatch was ALREADY refused by the module input-key + check, so this refuses earlier and names the preset a consumer enabled rather + than a module inside the binary. **A manifest is not permission to fetch one** + — CLOUD-129's no-network verdict is unchanged, `include_str!` at build time, + and what the manifest buys CLOUD-970 is that the trust question becomes + askable at all. - `policy.rs` — the policy evaluator (CLOUD-647, CLOUD-689): a `[[rule]]` of kind `policy` names a **registered** Rego module, and the module decides over the resolved fact set. It exists because `run` is a flat loop where no row diff --git a/README.md b/README.md index ed2e4958f..92e9750e9 100644 --- a/README.md +++ b/README.md @@ -288,17 +288,82 @@ _diagnostic_, so it never returns `2` — every failure it can report is the config-or-usage class, and a harness must never read "this checkout is misconfigured" as a policy denial. -## Extending Batten: three surfaces, and which to reach for +## Extending Batten: ten rule kinds, and which to reach for Any predicate you can express as a command plus an exit code is expressible in -Batten. There are three ways to do it, and the failure mode is picking the wrong -one — so the boundary matters more than the mechanics. - -| What you are gating on | Reach for | Where it is configured | -| ------------------------------------------------------- | ------------------------ | -------------------------------- | -| A **file's contents** | a `command` rule kind | `[[rule]]` with `kind="command"` | -| A **command's output**, when the tool lies about exit 0 | `exec` output predicates | `[[exec_pattern]]` | -| An **existing warn finding**, to make it block | `fail_on_warning` | a top-level key | +Batten — and most of them need no command at all. The failure mode is picking the +wrong kind, so the boundary matters more than the mechanics. + +**Reach for the narrowest kind that fits.** A `command` rule spawns a process, +which can read any file and reach the network; every other kind is decided from +facts the boundary already resolved. That is why a `command` rule runs only under +`batten enforce` while the rest are admitted to the read-only `check` surface. + +| What you are gating on | Reach for | Where it is configured | +| ------------------------------------------------------------------ | ---------- | --------------------------------- | +| A **literal string** banned from matched files | `forbid` | `[[rule]]` with `kind="forbid"` | +| A **file's contents**, judged by a program you supply | `command` | `[[rule]]` with `kind="command"` | +| A **command line** an agent is about to run | `shape` | `[[rule]]` with `kind="shape"` | +| A **count that must not grow** — a budget you are paying down | `ratchet` | `[[rule]]` with `kind="ratchet"` | +| Whether a **verification receipt** exists and still answers | `receipt` | `[[rule]]` with `kind="receipt"` | +| The **shape of a pipeline** — how a call is composed | `pipeline` | `[[rule]]` with `kind="pipeline"` | +| A judgement a **model** makes, recorded with its own no-fix reason | `judge` | `[[rule]]` with `kind="judge"` | +| **Credentials** reaching a file, via a pinned scanner | `secrets` | `[[rule]]` with `kind="secrets"` | +| A **document's** own structure | `document` | `[[rule]]` with `kind="document"` | +| A **relationship between facts** no single row can express | `policy` | `[[rule]]` with `kind="policy"` | + +Two surfaces are not rule kinds and are configured on their own: + +| What you are gating on | Reach for | Where it is configured | +| ------------------------------------------------------- | ------------------------ | ---------------------- | +| A **command's output**, when the tool lies about exit 0 | `exec` output predicates | `[[exec_pattern]]` | +| An **existing warn finding**, to make it block | `fail_on_warning` | a top-level key | + +### Gating on a relationship between facts — a `policy` rule + +The other kinds are each one predicate over one object. A `policy` rule is a +[Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) module +deciding over the whole resolved fact set, which is what makes a predicate over +the _relationship between_ facts expressible at all — the engine's own rule loop +is flat, and no row can consume another's verdict. + +```toml +[[rule]] +id = "no-orphan-workflow" +kind = "policy" +scope = "tree" +sources = [".github/workflows/*.yml"] +module = "policy/no-orphan-workflow.rego" +severity = "deny" +``` + +A module is **deny-only by construction** — there is no allow spelling — so +enabling one can never weaken policy, which is what preserves the raise-only +invariant above. A refusal it raises is `{rule, verdict, subjects}`, and the +`verdict` is a declared class rather than free prose, so +`batten policy explain ` reaches the remedy from any refusal. + +### Presets: the batteries, and how to switch one on + +Batten ships policy modules for common practices, compiled into the binary. They +are the reason a new repository does not have to author every predicate from +scratch — the same shape Conftest, Semgrep, ESLint and Clippy all take. + +```toml +[[rule]] +id = "trunk-based" +kind = "policy" +scope = "mediated_call" +preset = "trunk-based" +severity = "deny" +``` + +`batten config show` lists the presets this binary ships. Each declares the +**scope** its modules decide, and enabling one at the other scope is refused at +load rather than quietly deciding nothing. + +There is **no network and no registry**: a preset's bytes ship inside the binary +you already trust, under the same checksum as the rest of it. Everything a consumer adds is **raise-only** (§8): a git-ignored `batten.local.toml` may add a rule or a pattern, never redefine or remove one the diff --git a/batten.example.toml b/batten.example.toml index b030f56f8..5a73f443d 100644 --- a/batten.example.toml +++ b/batten.example.toml @@ -497,3 +497,33 @@ raw = ["span_text"] max_payload_bytes = 16384 run = "my-judge --model {{model}}" model = "some-model-id" + +# THE BATTERIES: policy presets, compiled into the binary. +# +# Every rule above is one predicate over one object. A `policy` rule is a Rego +# module deciding over the whole resolved fact set, which is what makes a +# predicate over the RELATIONSHIP between facts expressible — the engine's rule +# loop is flat, so no row can consume another's verdict. +# +# You do not have to write one to get value from them. Batten ships modules for +# common practices, and enabling one is a rule row naming it. `batten config show` +# lists what this binary carries; today that is `ci-hygiene`, `commit-hygiene`, +# `landing-loop`, `pinned-toolchain`, `shell-hygiene` and `trunk-based`. +# +# SCOPE IS THE FIELD TO GET RIGHT. A preset's modules read one surface's facts: +# `tree` for the working tree, `mediated_call` for the single command a hook is +# adjudicating. Enabling one at the wrong scope is refused at LOAD, naming both — +# it does not quietly evaluate and decide nothing. +# +# A preset is deny-only, like every module, so switching one on can only ADD +# refusals. There is no network and no registry: the bytes ship inside the binary +# you already trust, under the same checksum as the rest of it. +# +# Uncomment to enable one: +# +# [[rule]] +# id = "trunk-based" +# kind = "policy" +# scope = "mediated_call" +# preset = "trunk-based" +# severity = "deny" diff --git a/batten.toml b/batten.toml index 021c498c2..8d69c082d 100644 --- a/batten.toml +++ b/batten.toml @@ -5389,13 +5389,13 @@ keep = 2 mb = 7938 worst_mb = 7938 multiplier = 1 -measured = "2026-09-01" +measured = "2026-09-02" [prune.cold] mb = 18984 worst_mb = 18984 multiplier = 1 -measured = "2026-09-01" +measured = "2026-09-02" # THE BASIS EACH FLOOR WAS MEASURED AGAINST (CLOUD-1158), because `measured` is a # pointer to a basis and not the basis itself. @@ -5468,14 +5468,32 @@ measured = "2026-09-01" # exactly this basis move. Refreshing the count without claiming a floor # measurement I did not take is the honest half of the remedy. +# THE 2026-09-02 MOVE, AND IT IS THE 2026-09-01 MOVE AGAIN, DELIBERATELY. +# A bundle connecting the refusal ABI added two compiled-binary tiers — +# `config_fault_class.rs` and `preset_manifest.rs` — and the live count reached +# 164, twelve past the basis and outside the tolerance. That is this gate working: +# the floor it was defending was taken against a smaller tree. +# +# `count` moves to the live 164 with `measured`, as the block above instructs, and +# THE FLOORS AGAIN DELIBERATELY DO NOT MOVE. The reasoning is unchanged and is +# worth restating rather than assumed: both are far above what the grouped-target +# tree needs — the lap that tripped this reported 10733MB free against a 7938MB +# warm floor, so free space was never the problem — and a floor too high only +# refuses laps while one too low fails silently inside a test run. Moving them +# down needs the independent measurement this block names (a build from an empty +# `target` for cold, a minimal post-prune tree for warm), which is CLOUD-1158's +# and was not taken here. Refreshing the count without claiming a floor +# measurement I did not take is the honest half of the remedy, exactly as the +# entry above says. + [prune.warm.basis] glob = "crates/batten/tests/**/*.rs" -count = 152 +count = 164 tolerance = 10 [prune.cold.basis] glob = "crates/batten/tests/**/*.rs" -count = 152 +count = 164 tolerance = 10 # THE REGROWABLE ROOTS THE ESCALATION MAY DROP (CLOUD-1157), in the order it drops @@ -6234,6 +6252,10 @@ gloss = "a tracked surface moving under a session" word = "event" gloss = "a harness or forge event" +[[vocabulary.subject]] +word = "fact" +gloss = "a declared fact row" + [[vocabulary.subject]] word = "forge" gloss = "the code host" @@ -6282,14 +6304,26 @@ gloss = "a lockfile" word = "manifest" gloss = "a package manifest" +[[vocabulary.subject]] +word = "marker" +gloss = "a declared marker row" + [[vocabulary.subject]] word = "memory" gloss = "an agent memory document" +[[vocabulary.subject]] +word = "mint" +gloss = "a declared receipt-minting row" + [[vocabulary.subject]] word = "module" gloss = "a policy module" +[[vocabulary.subject]] +word = "output" +gloss = "a declared exec output predicate" + [[vocabulary.subject]] word = "patch" gloss = "a change identified by its content" @@ -6298,6 +6332,10 @@ gloss = "a change identified by its content" word = "path" gloss = "a filesystem path" +[[vocabulary.subject]] +word = "pattern" +gloss = "a declared named-pattern row" + [[vocabulary.subject]] word = "pin" gloss = "fixed at a version" @@ -6310,6 +6348,18 @@ gloss = "an executable" word = "prose" gloss = "authored text" +[[vocabulary.subject]] +word = "provision" +gloss = "a declared provisioned-tool row" + +[[vocabulary.subject]] +word = "recorder" +gloss = "a declared recorder row" + +[[vocabulary.subject]] +word = "redirect" +gloss = "a declared per-path remedy row" + [[vocabulary.subject]] word = "release" gloss = "a cut release" @@ -6386,10 +6436,22 @@ gloss = "a third-party validator" word = "turn" gloss = "one agent turn" +[[vocabulary.subject]] +word = "verb" +gloss = "a declared mutating-verb row" + +[[vocabulary.subject]] +word = "verdict" +gloss = "a declared refusal class" + [[vocabulary.subject]] word = "version" gloss = "a declared version" +[[vocabulary.subject]] +word = "waiver" +gloss = "a declared waiver row" + [[vocabulary.subject]] word = "workflow" gloss = "a CI workflow" @@ -8217,12 +8279,38 @@ target = "mise run lint:deno" [[verdict]] id = "lane guard missing" -gloss = "a job an outside author can reach holds contents:write and tests no head origin" +gloss = "a job whose TRIGGER carries an outside head holds contents:write and tests no origin" +class = """ +The privileged-lane shape where the event itself hands the job a head: \ +`pull_request`, `pull_request_target` or `workflow_run`, a token that can write, \ +and no check that the head came from this repository. The head is on the event, \ +so the test is on the event -- compare `head_repository.full_name` against \ +`github.repository` before the job does anything with its permissions, or drop \ +the permission. + +Its sibling `lane resolve missing` is the same shape reached the other way, and \ +the split exists because the two name DIFFERENT FIELDS: a lane told to read \ +`head_repository.full_name` on an `issue_comment` event is being sent to a field \ +its payload does not carry. +""" + +[[verdict.route]] +id = "workflow read first" +kind = "document" +target = ".github/workflows" + +[[verdict]] +id = "lane resolve missing" +gloss = "a job that LOOKS UP an outside head holds contents:write and tests no origin" class = """ -The privileged-lane shape: a trigger an outside author can fire, a token that can \ -write, and no check that the head being built came from this repository. Test the \ -head's origin before the job does anything with its permissions, or drop the \ -permission. +The same privileged-lane shape reached the other way: no trigger carries a head, \ +so the job goes and finds one through the pulls API -- a cron arm, or an \ +`issue_comment` responding to a command. The event payload has no head to test, \ +so the test belongs on the RESOLVED pull request: compare `.head.repo.full_name` \ +against this repository on what the lookup returned, before checking it out. + +Reached only where no trigger carries a head, so a job that is both is reported \ +once, under `lane guard missing`, whose remedy is the earlier of the two. """ [[verdict.route]] diff --git a/completions/batten.bash b/completions/batten.bash index db079156d..3d8b1f6cc 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -562,6 +562,9 @@ _batten() { batten__subcmd__help__subcmd__state,record) cmd="batten__subcmd__help__subcmd__state__subcmd__record" ;; + batten__subcmd__help__subcmd__state,settle) + cmd="batten__subcmd__help__subcmd__state__subcmd__settle" + ;; batten__subcmd__help__subcmd__target,prune) cmd="batten__subcmd__help__subcmd__target__subcmd__prune" ;; @@ -796,6 +799,9 @@ _batten() { batten__subcmd__state,record) cmd="batten__subcmd__state__subcmd__record" ;; + batten__subcmd__state,settle) + cmd="batten__subcmd__state__subcmd__settle" + ;; batten__subcmd__state__subcmd__help,adopt) cmd="batten__subcmd__state__subcmd__help__subcmd__adopt" ;; @@ -811,6 +817,9 @@ _batten() { batten__subcmd__state__subcmd__help,record) cmd="batten__subcmd__state__subcmd__help__subcmd__record" ;; + batten__subcmd__state__subcmd__help,settle) + cmd="batten__subcmd__state__subcmd__help__subcmd__settle" + ;; batten__subcmd__target,help) cmd="batten__subcmd__target__subcmd__help" ;; @@ -3720,7 +3729,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__state) - opts="adopt record migrate list" + opts="adopt record migrate settle list" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3789,6 +3798,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__state__subcmd__settle) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__target) opts="prune" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -5786,7 +5809,7 @@ _batten() { return 0 ;; batten__subcmd__state) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help adopt record migrate list help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help adopt record migrate settle list help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5846,7 +5869,7 @@ _batten() { return 0 ;; batten__subcmd__state__subcmd__help) - opts="adopt record migrate list help" + opts="adopt record migrate settle list help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5929,6 +5952,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__state__subcmd__help__subcmd__settle) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__state__subcmd__list) opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -6019,6 +6056,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__state__subcmd__settle) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__target) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help prune help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 1c40f5882..07711e2a5 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -1939,32 +1939,33 @@ complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_subcommand_from audit" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_subcommand_from help" -f -a "audit" -d 'Audit a JSONL design-evidence claim stream on stdin for record integrity' complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "list" -d 'List stored findings and the refs they were observed in' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "settle" -d 'Record what was decided about a stored finding' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "list" -d 'List stored findings and the refs they were observed in' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from adopt" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2028,6 +2029,27 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from migrate" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from migrate" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from migrate" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from list" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2053,6 +2075,7 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "settle" -d 'Record what was decided about a stored finding' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' @@ -2253,6 +2276,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "settle" -d 'Record what was decided about a stored finding' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' diff --git a/completions/batten.zsh b/completions/batten.zsh index 203e107e1..83e71a0f6 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3485,6 +3485,37 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(settle) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':identity -- The stored finding'\''s identity, as `state list` prints it:_default' \ +':disposition -- What was decided\: acted, rejected-by-design or rejected-wrong:_default' \ +&& ret=0 +;; (list) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -3540,6 +3571,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(settle) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (list) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -4405,6 +4440,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(settle) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (list) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -5474,6 +5513,7 @@ _batten__subcmd__help__subcmd__state_commands() { 'adopt:Bind this checkout to its findings store, minting one only if none exists' \ 'record:Record this ref'\''s findings into the store, and GC instances whose ref is gone' \ 'migrate:Upgrade the findings store to this binary'\''s record version' \ +'settle:Record what was decided about a stored finding' \ 'list:List stored findings and the refs they were observed in' \ ) _describe -t commands 'batten help state commands' commands "$@" @@ -5498,6 +5538,11 @@ _batten__subcmd__help__subcmd__state__subcmd__record_commands() { local commands; commands=() _describe -t commands 'batten help state record commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__state__subcmd__settle_commands] )) || +_batten__subcmd__help__subcmd__state__subcmd__settle_commands() { + local commands; commands=() + _describe -t commands 'batten help state settle commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__target_commands] )) || _batten__subcmd__help__subcmd__target_commands() { local commands; commands=( @@ -6066,6 +6111,7 @@ _batten__subcmd__state_commands() { 'adopt:Bind this checkout to its findings store, minting one only if none exists' \ 'record:Record this ref'\''s findings into the store, and GC instances whose ref is gone' \ 'migrate:Upgrade the findings store to this binary'\''s record version' \ +'settle:Record what was decided about a stored finding' \ 'list:List stored findings and the refs they were observed in' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -6082,6 +6128,7 @@ _batten__subcmd__state__subcmd__help_commands() { 'adopt:Bind this checkout to its findings store, minting one only if none exists' \ 'record:Record this ref'\''s findings into the store, and GC instances whose ref is gone' \ 'migrate:Upgrade the findings store to this binary'\''s record version' \ +'settle:Record what was decided about a stored finding' \ 'list:List stored findings and the refs they were observed in' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -6112,6 +6159,11 @@ _batten__subcmd__state__subcmd__help__subcmd__record_commands() { local commands; commands=() _describe -t commands 'batten state help record commands' commands "$@" } +(( $+functions[_batten__subcmd__state__subcmd__help__subcmd__settle_commands] )) || +_batten__subcmd__state__subcmd__help__subcmd__settle_commands() { + local commands; commands=() + _describe -t commands 'batten state help settle commands' commands "$@" +} (( $+functions[_batten__subcmd__state__subcmd__list_commands] )) || _batten__subcmd__state__subcmd__list_commands() { local commands; commands=() @@ -6127,6 +6179,11 @@ _batten__subcmd__state__subcmd__record_commands() { local commands; commands=() _describe -t commands 'batten state record commands' commands "$@" } +(( $+functions[_batten__subcmd__state__subcmd__settle_commands] )) || +_batten__subcmd__state__subcmd__settle_commands() { + local commands; commands=() + _describe -t commands 'batten state settle commands' commands "$@" +} (( $+functions[_batten__subcmd__target_commands] )) || _batten__subcmd__target_commands() { local commands; commands=( diff --git a/crates/batten/src/baseline.rs b/crates/batten/src/baseline.rs index b0bdf1f76..6d5fcaa34 100644 --- a/crates/batten/src/baseline.rs +++ b/crates/batten/src/baseline.rs @@ -523,6 +523,7 @@ impl Drifted { identity::scope_fingerprint(&rule, &scope), ); Some(Finding { + owner: None, rule, severity: RuleSeverity::Deny, // The pointer is the entry, never a path in the tree: the finding @@ -700,6 +701,7 @@ mod tests { identity::code_fingerprint(rule, path, span, identity::SpanNormalization::Collapsed) .expect("mint a code identity"); Finding { + owner: None, rule: rule.to_owned(), severity: RuleSeverity::Deny, path: path.to_owned(), diff --git a/crates/batten/src/budget.rs b/crates/batten/src/budget.rs index 7d2aec5b7..e661eeb17 100644 --- a/crates/batten/src/budget.rs +++ b/crates/batten/src/budget.rs @@ -245,6 +245,7 @@ impl Report { identity::scope_fingerprint(&rule, &self.name), ); Some(Finding { + owner: None, rule, severity: RuleSeverity::Deny, path: self.name.clone(), diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index f5de7e9f8..082b31600 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -799,6 +799,15 @@ pub enum StateCommand { Record, /// Upgrade the store's record version. The only upgrade path. Migrate, + /// Answer a stored finding (CLOUD-587). + Settle { + /// The stored finding's identity, as the hex fingerprint `state list` + /// prints. The identity rather than a rule id, because a rule can have + /// many findings and settling all of them is not what an agent means. + identity: String, + /// What was decided, as a [`crate::findings::Disposition`] token. + disposition: String, + }, /// List stored findings. List { /// Emit the listing as byte-stable JSON instead of pointer lines. @@ -1470,6 +1479,10 @@ fn state_of(matches: &ArgMatches) -> Option { }), ("record", _) => Some(StateCommand::Record), ("migrate", _) => Some(StateCommand::Migrate), + ("settle", matches) => Some(StateCommand::Settle { + identity: matches.get_one::("identity").cloned()?, + disposition: matches.get_one::("disposition").cloned()?, + }), ("list", matches) => Some(StateCommand::List { json: flag(matches, "json"), }), diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 11bbf83bc..91c6776a4 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -57,6 +57,7 @@ use serde::{Deserialize, Serialize}; use crate::error::UsageError; use crate::rules::Rule; +use crate::verdict::Native; use crate::{outputs, waiver}; /// The config schema version this build understands. A file declaring any other @@ -1010,9 +1011,18 @@ pub fn parse_override(text: &str, source: &str) -> Result { // The same validators the authority runs, over the same tables. An override // row is a policy row: one that loads here and gates nothing is the defect // CLOUD-242 named, and it does not become acceptable for being uncommitted. - crate::rules::validate_in(&config.rules, text, source)?; - crate::outputs::validate(&config.exec_patterns)?; - crate::waiver::validate(&config.waivers)?; + under( + Native::RuleTableRefused, + crate::rules::validate_in(&config.rules, text, source), + )?; + under( + Native::OutputTableRefused, + crate::outputs::validate(&config.exec_patterns), + )?; + under( + Native::WaiverTableRefused, + crate::waiver::validate(&config.waivers), + )?; Ok(config) } @@ -1031,16 +1041,56 @@ pub fn override_schema() -> Result { ))?) } +/// Attach a table's declared class to a validator's refusal (CLOUD-1313). +/// +/// # Why the class is attached HERE and not at the raise site +/// +/// The twelve `VALIDATED_AT_LOAD` validators raise from ~172 sites, and only +/// about thirty of those sit at a top-level `validate` — the rest are in +/// per-entry helpers (`validate_shape`, `Rule::validate_*`), several of them +/// shared between tables. A class per site is therefore unbuildable, and a class +/// per site is also the wrong grain: what a reader needs to know first is which +/// TABLE would not load, and the message the validator already composed says +/// which row and key. That is the same division `[[verdict]]` draws between a +/// class and its subjects. +/// +/// So the call site is the one place that knows the table, and it is the only +/// place that has to change. +/// +/// # What it deliberately does not do +/// +/// It classes a [`UsageError`] that carries none, and passes everything else +/// through untouched — an internal failure stays internal (exit `3`), and an +/// error that already names a class keeps it. Rewording the refusals themselves +/// is outside CLOUD-1313. +fn under(native: crate::verdict::Native, result: Result) -> Result { + result.map_err(|err| match err.downcast::() { + Ok(usage) if usage.verdict.is_none() => UsageError::raise_as(native, usage.message), + Ok(usage) => anyhow::Error::new(usage), + Err(other) => other, + }) +} + /// The shared body: deserialize and check the schema `version`. -fn parse_ungated(text: &str, source: &str) -> Result { - let config: Config = toml::from_str(text) - .map_err(|err| UsageError::raise(format!("invalid config {source}: {err}")))?; - if config.version != SUPPORTED_VERSION { - return Err(UsageError::raise(format!( - "unsupported config version {} in {source}; this build supports version {SUPPORTED_VERSION}", - config.version - ))); - } +/// Prove every declared table well formed, each refusal naming its own class. +/// +/// # Why this is its own function +/// +/// It is the whole of what "the config loaded" means beyond deserializing, and +/// it is what `every_load_time_validator_refuses_under_a_declared_class` reads: +/// a census over a body that also carried the deserialize and the version gate +/// would be scanning text that has nothing to do with what it decides. +/// +/// Called from [`parse_ungated`] rather than from `parse`, so an override layer +/// is held to the same table rules — `batten.local.toml` may add rows, and a +/// raise-only override that adds an inert one has still written something that +/// cannot mean anything. +/// +/// # Errors +/// +/// Returns a [`UsageError`] (→ exit `1`) under the class of whichever table +/// refused; see [`under`]. +fn validate_tables(config: &Config, text: &str, source: &str) -> Result<()> { // The verb table is validated here, at load, because nothing else validates // it anywhere: `verbs::validate` had no caller outside its own tests, so a // `[[verb]]` row that is inert — `effect = "read"` in a table named for @@ -1053,21 +1103,33 @@ fn parse_ungated(text: &str, source: &str) -> Result { // In `parse_ungated` rather than `parse` so an override layer is held to it // too: `batten.local.toml` may add verb rows, and a raise-only override that // adds an inert one has still written something that cannot mean anything. - crate::verbs::validate(&config.verbs)?; + under( + Native::VerbTableRefused, + crate::verbs::validate(&config.verbs), + )?; // The named-regex table, at parse for the identical reason (CLOUD-885): a // malformed expression is a config fault, and refusing it here means // `config lint` and `doctor` catch it rather than a mediated call // discovering it at adjudication, which is the worst time and the wrong exit // class (house style §8). - crate::pattern::validate(&config.patterns)?; + under( + Native::PatternTableRefused, + crate::pattern::validate(&config.patterns), + )?; // The refusal vocabulary, at parse for the identical reason (CLOUD-1050). // Every clause is a property of the TABLE — a token's prefix, a gloss that // is one line, a route list that is not an override alone, a tombstone chain // that terminates — so it is knowable without a tree and belongs where a // config fault is reported. Registry EQUALITY against what the modules // actually emit needs the compiled bundles and lives in `policy::load`. - crate::verdict::validate(&config.verdicts, &config.vocabulary)?; - crate::redirect::validate(&config.redirects)?; + under( + Native::VerdictTableRefused, + crate::verdict::validate(&config.verdicts, &config.vocabulary), + )?; + under( + Native::RedirectTableRefused, + crate::redirect::validate(&config.redirects), + )?; // The remedies those two tables carry, resolved against the command surface // and the rule table (CLOUD-1189). Here rather than in `redirect::validate` // because it is the one clause needing a THIRD table — the `[[rule]]` ids — @@ -1100,7 +1162,10 @@ fn parse_ungated(text: &str, source: &str) -> Result { .as_deref() .map(|text| (format!("verb[{}].redirect", verb.verb), text)) })); - crate::redirect::validate_remedies(remedies, &rule_ids)?; + under( + Native::RemedyUnresolved, + crate::redirect::validate_remedies(remedies, &rule_ids), + )?; } // And the MCP table, at load for the identical reason (CLOUD-1260). Every // clause is a property of the TABLE — a duplicated id, a path that would @@ -1115,7 +1180,10 @@ fn parse_ungated(text: &str, source: &str) -> Result { // them up and nobody checked the sibling, so an empty `token` — which // matches every line of every file — still loaded clean. The completeness // test below is what stops the next table arriving orphaned the same way. - crate::markers::validate(&config.markers)?; + under( + Native::MarkerTableRefused, + crate::markers::validate(&config.markers), + )?; // And the action table, where "validated only by the runner" would be worst // of all: an action is a command, and a row that loads clean but names no // event is a side effect the operator believes is attached and which fires @@ -1131,31 +1199,70 @@ fn parse_ungated(text: &str, source: &str) -> Result { // malformed `mediated_call` row validated only by `check` is a policy row // that loads, matches nothing at the mediation channel, and reads as // coverage. `run_rule` still calls `Rule::validate` as defence in depth. - crate::rules::validate_in(&config.rules, text, source)?; - crate::outputs::validate(&config.exec_patterns)?; + under( + Native::RuleTableRefused, + crate::rules::validate_in(&config.rules, text, source), + )?; + under( + Native::OutputTableRefused, + crate::outputs::validate(&config.exec_patterns), + )?; // And the waiver table, where the stakes are inverted from every other row // here: a malformed rule fails to gate, but a malformed *waiver* is a hatch // whose expiry nobody could read. Refusing at load is what makes "every // waiver carries an expiry" true of the resolved config rather than aspirational. - crate::waiver::validate(&config.waivers)?; - crate::facts::validate(&config.facts)?; + under( + Native::WaiverTableRefused, + crate::waiver::validate(&config.waivers), + )?; + under( + Native::FactTableRefused, + crate::facts::validate(&config.facts), + )?; // The cross-table half (CLOUD-859), which needs both lists and so cannot live // in either one's own validator: a `named` receipt row over an agent-sourced // check is a gate no record can satisfy. crate::facts::validate_keying(&config.facts, &config.rules)?; - crate::mint::validate(&config.mints)?; + under( + Native::MintTableRefused, + crate::mint::validate(&config.mints), + )?; // AFTER the pattern table is validated, because a recorder's `section` names // a pattern id and the refusal for a missing one is only honest once the ids // are known to be well-formed themselves. - crate::recorder::validate( - &config.recorders, - &config.programs, - &config - .patterns - .iter() - .map(|pattern| pattern.id.clone()) - .collect(), + under( + Native::RecorderTableRefused, + crate::recorder::validate( + &config.recorders, + &config.programs, + &config + .patterns + .iter() + .map(|pattern| pattern.id.clone()) + .collect(), + ), )?; + validate_sections(config) +} + +/// Prove the SINGLETON sections well formed — the `Option` tables the census +/// cannot reach, plus the two list tables that follow them. +/// +/// # Why the split is here and not somewhere tidier +/// +/// It is the smallest cut that keeps `validate_tables` inside the line lint +/// **without reordering a single refusal**. Which fault a multi-fault config +/// reports first is observable output under house style §6, so a split chosen +/// for looks rather than for sequence would have been a silent contract change. +/// +/// `every_load_time_validator_refuses_under_a_declared_class` reads this body +/// together with [`validate_tables`]', because the loader is two functions and +/// the predicate is about the loader. +/// +/// # Errors +/// +/// As [`validate_tables`]. +fn validate_sections(config: &Config) -> Result<()> { // `[budget]` is a table rather than a list, so the census below (which scans // `Vec` fields) does not reach it — but the failure it guards against is // the same one: a table that parses and gates nothing. A `[budget]` header @@ -1207,7 +1314,23 @@ fn parse_ungated(text: &str, source: &str) -> Result { // A pin that can never match, a name that owns a cache path twice, an empty // required field: each is refused here rather than at fetch time, where the // failure would blame the artifact for a typo in this file. - crate::provision::validate(&config.provisions)?; + under( + Native::ProvisionTableRefused, + crate::provision::validate(&config.provisions), + )?; + Ok(()) +} + +fn parse_ungated(text: &str, source: &str) -> Result { + let config: Config = toml::from_str(text) + .map_err(|err| UsageError::raise(format!("invalid config {source}: {err}")))?; + if config.version != SUPPORTED_VERSION { + return Err(UsageError::raise(format!( + "unsupported config version {} in {source}; this build supports version {SUPPORTED_VERSION}", + config.version + ))); + } + validate_tables(&config, text, source)?; Ok(config) } @@ -1686,27 +1809,82 @@ mod tests { use super::*; use crate::error::UsageError; - /// Tables whose entries are proven well formed at load, and the call in - /// [`parse_ungated`] that does it. Deleting a call fails the test below. - const VALIDATED_AT_LOAD: &[(&str, &str)] = &[ - ("verbs", "crate::verbs::validate("), - ("patterns", "crate::pattern::validate("), - ("verdicts", "crate::verdict::validate("), - ("redirects", "crate::redirect::validate("), - ("markers", "crate::markers::validate("), + /// Tables whose entries are proven well formed at load, the call in + /// [`parse_ungated`] that does it, and the class its refusal names. + /// + /// The third column is CLOUD-1313's, and it is what makes the census a gate + /// over the refusal ABI rather than only over the call's existence: a + /// validator whose call is still there but is no longer wrapped raises a + /// classless `UsageError` again, which `batten policy explain` cannot + /// resolve, and the test below fails naming the table. + const VALIDATED_AT_LOAD: &[(&str, &str, Native)] = &[ + ("verbs", "crate::verbs::validate(", Native::VerbTableRefused), + ( + "patterns", + "crate::pattern::validate(", + Native::PatternTableRefused, + ), + ( + "verdicts", + "crate::verdict::validate(", + Native::VerdictTableRefused, + ), + ( + "redirects", + "crate::redirect::validate(", + Native::RedirectTableRefused, + ), + ( + "markers", + "crate::markers::validate(", + Native::MarkerTableRefused, + ), // The LOCATED form (CLOUD-773): the loaders hold the config text, so a // composition refusal points at a line rather than only at a rule id. // `rules::validate_in` runs `rules::validate` first — one implementation, // an optional locator — so naming it here is naming the whole check. - ("rules", "crate::rules::validate_in("), - ("exec_patterns", "crate::outputs::validate("), - ("provisions", "crate::provision::validate("), - ("waivers", "crate::waiver::validate("), - ("facts", "crate::facts::validate("), - ("mints", "crate::mint::validate("), - ("recorders", "crate::recorder::validate("), + ( + "rules", + "crate::rules::validate_in(", + Native::RuleTableRefused, + ), + ( + "exec_patterns", + "crate::outputs::validate(", + Native::OutputTableRefused, + ), + ( + "provisions", + "crate::provision::validate(", + Native::ProvisionTableRefused, + ), + ( + "waivers", + "crate::waiver::validate(", + Native::WaiverTableRefused, + ), + ("facts", "crate::facts::validate(", Native::FactTableRefused), + ("mints", "crate::mint::validate(", Native::MintTableRefused), + ( + "recorders", + "crate::recorder::validate(", + Native::RecorderTableRefused, + ), ]; + /// The one CLASSED refusal that is not a `Config` table. + /// + /// `redirect::validate_remedies` needs a third table — the rule ids — so it + /// is a call at the load rather than a validator over one field, and the + /// census above (which scans `Vec` fields) structurally cannot reach it. + /// It is listed anyway because it is the refusal CLOUD-1189 owed a class to + /// and could not declare one for, which is the case that produced + /// CLOUD-1313: leaving it out would close the row without closing its cause. + const CLASSED_BESIDE_THE_TABLES: &[(&str, Native)] = &[( + "crate::redirect::validate_remedies(", + Native::RemedyUnresolved, + )]; + /// Tables proven well formed somewhere else, each with the reason. Listing /// an exemption is the point: a reader sees the justification rather than /// an absence, which is what an orphaned validator looks like. @@ -1733,13 +1911,22 @@ mod tests { let rest = &source[start..]; &rest[..rest.find("\n}").expect("the struct closes")] }; - let parse_body = { - let start = source - .find("fn parse_ungated") - .expect("the shared parse body is declared here"); - let rest = &source[start..]; - &rest[..rest.find("\n}").expect("the function closes")] - }; + // BOTH bodies, because the loader is two functions and the predicate is + // about the loader. Reading only the first would report every section + // the split moved as unwrapped — the false positive that gets a gate + // switched off. + let parse_body = ["fn validate_tables", "fn validate_sections"] + .iter() + .map(|name| { + let start = source + .find(name) + .unwrap_or_else(|| panic!("`{name}` is declared here")); + let rest = &source[start..]; + &rest[..rest.find("\n}").expect("the function closes")] + }) + .collect::>() + .join("\n"); + let parse_body = parse_body.as_str(); let mut seen = Vec::new(); for line in struct_body.lines() { @@ -1754,7 +1941,7 @@ mod tests { } seen.push(field); - let at_load = VALIDATED_AT_LOAD.iter().find(|(name, _)| *name == field); + let at_load = VALIDATED_AT_LOAD.iter().find(|(name, _, _)| *name == field); let by_runner = VALIDATED_BY_ITS_RUNNER .iter() .any(|(name, _)| *name == field); @@ -1764,11 +1951,11 @@ mod tests { are proven well formed: at load, or by the runner that evaluates them. A \ table nothing validates is a refusal that cannot fire (CLOUD-253)." ); - if let Some((_, call)) = at_load { + if let Some((_, call, _)) = at_load { assert!( parse_body.contains(call), "config table `{field}` is listed as validated at load, but \ - `parse_ungated` does not call `{call}`." + `validate_tables` does not call `{call}`." ); } } @@ -1777,7 +1964,11 @@ mod tests { !seen.is_empty(), "the struct scan must actually find tables" ); - for (name, _) in VALIDATED_AT_LOAD.iter().chain(VALIDATED_BY_ITS_RUNNER) { + for name in VALIDATED_AT_LOAD + .iter() + .map(|(name, _, _)| name) + .chain(VALIDATED_BY_ITS_RUNNER.iter().map(|(name, _)| name)) + { assert!( seen.contains(name), "`{name}` is listed but is no longer a Config table; drop the stale entry." @@ -1785,6 +1976,96 @@ mod tests { } } + /// The refusal-ABI half of the census (CLOUD-1313). + /// + /// The test above proves the call is THERE. This one proves it is still + /// *classed* — that its refusal names a declared class rather than the bare + /// `String` that made a config fault the one refusal in this engine + /// `batten policy explain` could not resolve. + /// + /// It reads the wrapping rather than trusting it, because a `?` that has + /// lost its `under(..)` compiles, passes every other test, and silently + /// returns to the pre-CLOUD-1313 shape. + #[test] + fn every_load_time_validator_refuses_under_a_declared_class() { + let source = include_str!("config.rs"); + // BOTH bodies, because the loader is two functions and the predicate is + // about the loader. Reading only the first would report every section + // the split moved as unwrapped — the false positive that gets a gate + // switched off. + let parse_body = ["fn validate_tables", "fn validate_sections"] + .iter() + .map(|name| { + let start = source + .find(name) + .unwrap_or_else(|| panic!("`{name}` is declared here")); + let rest = &source[start..]; + &rest[..rest.find("\n}").expect("the function closes")] + }) + .collect::>() + .join("\n"); + let parse_body = parse_body.as_str(); + + let listed: Vec<(&str, &str, Native)> = VALIDATED_AT_LOAD + .iter() + .copied() + .chain( + CLASSED_BESIDE_THE_TABLES + .iter() + .map(|(call, native)| ("(not a Config table)", *call, *native)), + ) + .collect(); + + for (subject, call, native) in &listed { + let at = parse_body + .find(call) + .unwrap_or_else(|| panic!("`{subject}`: `validate_tables` does not call `{call}`")); + // The nearest `under(` before the call, and everything between it + // and the call. Unwrapping the call makes that span reach back to + // some *other* validator's wrapper, so the variant no longer + // matches — which is the direction this has to fail in. + let wrapper = parse_body[..at] + .rfind("under(") + .unwrap_or_else(|| panic!("`{subject}`: no `under(..)` precedes `{call}`")); + let span = &parse_body[wrapper..at]; + let variant = format!("{native:?}"); + assert!( + span.contains(&variant) && span.len() < 80, + "`{subject}`: `{call}` is not wrapped in `under(Native::{variant}, ..)`, so its \ + refusal carries no class and `batten policy explain {}` cannot reach it \ + (CLOUD-1313).", + native.id() + ); + } + + // One table, one class. Two tables sharing a class would report the + // wrong file to edit, and the span check above cannot see it. + let mut classes: Vec<&str> = listed.iter().map(|(_, _, n)| n.id()).collect(); + classes.sort_unstable(); + let before = classes.len(); + classes.dedup(); + assert_eq!( + before, + classes.len(), + "two load-time refusals share a class; each names the table a reader must edit" + ); + + // Both directions against the one authority. The forward direction + // stops a class being declared for a loader that does not raise it; the + // reverse stops a wrapped call whose class the published set — and so + // the compiled-binary tier that reads it — has never heard of. + let mut here: Vec<&str> = classes; + let mut published: Vec<&str> = Native::CONFIG_FAULTS.iter().map(|n| n.id()).collect(); + published.sort_unstable(); + here.sort_unstable(); + assert_eq!( + here, published, + "`Native::CONFIG_FAULTS` and this census disagree about which classes the loader \ + raises; they are read by different tiers, so a disagreement means one of them is \ + describing a loader that does not exist" + ); + } + fn is_usage_error(err: &anyhow::Error) -> bool { err.downcast_ref::().is_some() } diff --git a/crates/batten/src/defects.rs b/crates/batten/src/defects.rs index 4b6cb6a7c..a5808a735 100644 --- a/crates/batten/src/defects.rs +++ b/crates/batten/src/defects.rs @@ -220,6 +220,7 @@ impl Problem { crate::identity::scope_fingerprint(&rule, path), ); Finding { + owner: None, rule, severity: RuleSeverity::Deny, path: path.to_owned(), diff --git a/crates/batten/src/design.rs b/crates/batten/src/design.rs index 752d533e7..e1acc42aa 100644 --- a/crates/batten/src/design.rs +++ b/crates/batten/src/design.rs @@ -362,6 +362,7 @@ impl Problem { crate::identity::scope_fingerprint(&rule, &self.claim), ); Finding { + owner: None, rule, severity: self.severity(), path: STREAM.to_owned(), diff --git a/crates/batten/src/emission.rs b/crates/batten/src/emission.rs index e38d9d047..bdfb3f79a 100644 --- a/crates/batten/src/emission.rs +++ b/crates/batten/src/emission.rs @@ -209,6 +209,13 @@ pub fn assess(log: &[Entry], window: usize, percent: u32) -> Assessment { emitted.entry(entry.identity.clone()).or_default().push(at); } } + // A SETTLE IS NEITHER AN EVALUATION NOR AN EMISSION (CLOUD-587), so + // it enters neither side of the ratio. Counting it as an evaluation + // would put an agent's answer in the denominator of a flap rate that + // measures what the ENGINE saw; counting it as an emission would say + // the drain showed something it never did. The disposition it + // carries is folded by `journal::merge`, which is where it belongs. + Origin::Settle => {} } } diff --git a/crates/batten/src/error.rs b/crates/batten/src/error.rs index 6867ecd38..dd814bc7d 100644 --- a/crates/batten/src/error.rs +++ b/crates/batten/src/error.rs @@ -91,24 +91,75 @@ impl Passthrough { /// An expected bad-input error that maps to [`ExitCode::Usage`] (exit `1`). /// +/// # The class is a field, not a formatted prefix +/// +/// CLOUD-1050's measured lesson is that a refusal carried as a free `String` +/// makes a bad refusal **expressible and uncheckable** — that is why a policy +/// `violation` binds `{rule, verdict, subjects}` and has no `msg`. The +/// config-load surface had the same defect: ~172 `raise` sites, none naming a +/// class, so `batten policy explain` could not resolve a config fault and no +/// gate held one to the `[[verdict]]` registry (CLOUD-1313). +/// +/// Formatting a token into the message would have reproduced that defect one +/// surface over, because nothing would check that the token is declared. +/// [`Native`] is an enum, so a raise site can only name a class that exists, +/// and `the_vendored_table_validates` proves every variant carries a row. +/// /// [`ExitCode::Usage`]: crate::ExitCode::Usage +/// [`Native`]: crate::verdict::Native #[derive(Debug)] -pub struct UsageError(pub String); +pub struct UsageError { + /// The prose. Unchanged from the pre-class shape, deliberately: rewording + /// the twelve validators' refusals is explicitly outside CLOUD-1313. + pub message: String, + /// The declared class this fault belongs to, where one is known. + /// + /// `None` is not a gap to be closed everywhere — most `UsageError`s are + /// argument faults, not config faults, and inventing a class for each would + /// be the per-site explosion CLOUD-1313 rejected. It is `Some` exactly where + /// a raiser can name the table it was validating. + pub verdict: Option, +} impl fmt::Display for UsageError { + /// `: ` when a class is known, the bare message otherwise. + /// + /// The separator is a deliberate deviation from CLOUD-1286's + /// ` `, and the reason is that these messages are PROSE. + /// That shape reads as a class followed by pointers precisely because + /// nothing separates them; running a class straight into a sentence would + /// make the first three words of the sentence look like part of the class. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) + match self.verdict { + Some(native) => write!(f, "{}: {}", native.id(), self.message), + None => f.write_str(&self.message), + } } } impl std::error::Error for UsageError {} impl UsageError { - /// Build a [`UsageError`] as an [`anyhow::Error`], ready to `return Err(..)`. + /// Build a classless [`UsageError`] as an [`anyhow::Error`], ready to + /// `return Err(..)`. /// /// Named `raise` rather than `new` because it returns an [`anyhow::Error`] /// wrapping the `UsageError`, not `Self`. pub fn raise(message: impl Into) -> anyhow::Error { - anyhow::Error::new(UsageError(message.into())) + anyhow::Error::new(UsageError { + message: message.into(), + verdict: None, + }) + } + + /// Build a [`UsageError`] that names the class it belongs to. + /// + /// Callers are the config loader's per-table wrappers; see + /// [`crate::config`]'s `under`. + pub fn raise_as(verdict: crate::verdict::Native, message: impl Into) -> anyhow::Error { + anyhow::Error::new(UsageError { + message: message.into(), + verdict: Some(verdict), + }) } } diff --git a/crates/batten/src/findings.rs b/crates/batten/src/findings.rs index e38ef432a..2d8955425 100644 --- a/crates/batten/src/findings.rs +++ b/crates/batten/src/findings.rs @@ -1179,6 +1179,7 @@ mod tests { fn finding_for(severity: RuleSeverity) -> Finding { Finding { + owner: None, rule: "r".to_owned(), severity, path: "src/a.rs".to_owned(), diff --git a/crates/batten/src/hookcost.rs b/crates/batten/src/hookcost.rs index 5072888fc..5789d569a 100644 --- a/crates/batten/src/hookcost.rs +++ b/crates/batten/src/hookcost.rs @@ -293,6 +293,7 @@ pub fn judge(reading: &Reading, ceiling: Option<&Ceiling>) -> Vec { /// it rather than a `Fix::Run` naming a command that would not help. fn finding(rule: &str, subject: String, line: Option, remedy: &str) -> Finding { Finding { + owner: None, rule: rule.to_owned(), severity: RuleSeverity::Deny, identity: StoredIdentity::new( diff --git a/crates/batten/src/journal.rs b/crates/batten/src/journal.rs index 2cd4d8627..4f4f7a9a9 100644 --- a/crates/batten/src/journal.rs +++ b/crates/batten/src/journal.rs @@ -324,6 +324,23 @@ pub enum Origin { /// silently move the false-positive denominator [`crate::findings::effective_fp_rates`] /// computes. Scan, + /// An agent answering a finding (CLOUD-587): a statement about **a + /// disposition** — this identity was surfaced and here is what was decided. + /// + /// Distinct from [`Origin::Scan`] because the two make different claims and a + /// reader auditing why a finding is settled should not have to infer which. + /// They fold identically — [`merge`] applies `disposition` from any origin + /// and `presentation` from [`Origin::Drain`] alone — so this variant costs + /// the fold nothing and buys the record its provenance. + /// + /// **The mixed-fleet cost, stated because [`Origin`]'s own doc raises it.** A + /// binary predating this variant cannot deserialize an entry carrying it, so + /// it skips that shard line and does not fold the disposition. That is the + /// same cost [`Origin::Scan`] paid when CLOUD-529 added it, and it fails in + /// the safe direction: an unread settle leaves the finding unsettled, where + /// spelling it `Scan` to stay readable would have made the record lie about + /// who decided. + Settle, } impl Origin { diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index acebaa452..440cdb72c 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -67,6 +67,7 @@ pub mod perf; pub mod pinned; pub mod policy; pub mod pr_watch; +pub mod preset; pub mod provision; pub mod prune; pub mod ready; @@ -292,6 +293,10 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re StateCommand::Adopt { store } => store::run_adopt(store.as_deref(), err), StateCommand::Record => run_state_record(&overrides, mode, err), StateCommand::Migrate => run_state_migrate(err), + StateCommand::Settle { + identity, + disposition, + } => run_state_settle(&identity, &disposition, err), StateCommand::List { json } => run_state_list(json, mode, out, err), }, // The §8 config chain DOES apply, and only to the tool half: `record tool` @@ -1058,6 +1063,101 @@ fn run_state_migrate(err: &mut dyn Write) -> Result { Ok(ExitCode::Success) } +/// `batten state settle`: answer a stored finding (CLOUD-587). +/// +/// # Why this verb has to exist +/// +/// CLOUD-78 gave every finding a three-valued `disposition`, `journal::merge` +/// folds it, `FindingRecord::merge_disposition` joins two by precedence and +/// `stop.rs` READS it — `deny-stop` means at-risk work or an undischarged +/// denial, where undischarged is `disposition == None`. **Nothing minted one.** +/// The only producers anywhere in the tree were unit tests, so the field was +/// read by a gate, joined by a merge rule, persisted by a journal, and +/// unreachable from any caller. +/// +/// That bit once CLOUD-98 landed `bypass.rs`, whose finding anchors to an +/// immutable transcript event: a bypass that happened, happened, so +/// re-evaluation keeps finding it and the observation never resolves to zero. +/// The finding is right to persist; what was missing is the answer channel. +/// +/// # What it deliberately does not do +/// +/// It writes through [`journal::append`], the append that already exists, so +/// there is no second writer and no new lock. It does not touch +/// [`crate::findings::Disposition::merge`], which stays the one join — this adds +/// a caller, never a second convergence rule. And it does not clear a +/// STATE-anchored finding by any other route: those clear by the condition +/// vanishing, and answering one would be a bypass of the work itself. +/// +/// # Errors +/// +/// [`UsageError`] (→ exit `1`) when no store is bound, when the identity is not +/// a fingerprint, or when the token is not a declared disposition. Recording a +/// disposition is bookkeeping, never a verdict, so the success path is exit `0` +/// and no finding it settles can move an exit code. +fn run_state_settle(identity: &str, disposition: &str, err: &mut dyn Write) -> Result { + let repo = git::repo_root(Path::new("."))?; + let opened = store::resolve(&repo)?; + let Some(dir) = store::bound_dir(&opened) else { + return Err(UsageError::raise( + "no store is bound to this repository; run `batten state adopt` first", + )); + }; + let Ok(fingerprint) = crate::identity::Fingerprint::from_hex(identity) else { + return Err(UsageError::raise(format!( + "`{identity}` is not a finding identity; `batten state list` prints the fingerprint each finding is stored under" + ))); + }; + // NAMED, never guessed. An unrecognised token is refused rather than folded + // to a default: a disposition is an agent's answer, and an answer nobody + // gave is the un-auditable settlement this verb exists to prevent. + let Some(decided) = findings::Disposition::ALL + .iter() + .copied() + .find(|candidate| candidate.as_str() == disposition) + else { + let known: Vec<&str> = findings::Disposition::ALL + .iter() + .map(|entry| entry.as_str()) + .collect(); + return Err(UsageError::raise(format!( + "`{disposition}` is not a disposition; declared: {}", + known.join(", ") + ))); + }; + // THE RECORD MUST EXIST. `journal::merge` keeps an entry whose record it + // cannot find, so appending for an unknown identity would silently succeed + // and settle nothing a reader could ever see — the shape CLOUD-845 calls a + // vacuous pass, one surface over. + if findings::load_one(&dir, fingerprint)?.is_none() { + return Err(UsageError::raise(format!( + "no stored finding has identity {identity}; `batten state list` prints what this store holds" + ))); + } + journal::append( + &dir, + &journal::shard_id(&repo), + &journal::Entry { + identity: fingerprint.to_hex(), + rule: String::new(), + origin: journal::Origin::Settle, + context: None, + // NEITHER FIELD IS THIS WRITER'S. `observation` is occurrence state + // and belongs to `findings::record`; `presentation` is the drain's + // suppression record and `merge` takes it from `Origin::Drain` + // alone. A settle that wrote either would be a second authority on + // a field it knows nothing about. + observation: None, + disposition: Some(decided), + presentation: findings::Presentation::Shown, + }, + )?; + // POINTER-ONLY (rule 4): the identity and the token. These findings are + // drawn from a transcript, so the content is exactly what must not travel. + writeln!(err, "batten: state settle: {identity} {}", decided.as_str())?; + Ok(ExitCode::Success) +} + /// Navigate a frozen capture (CLOUD-121). /// /// A dispatcher only. The three sub-verbs are separate functions rather than @@ -3224,11 +3324,30 @@ fn run_policy_explain( overrides: &Overrides, out: &mut dyn Write, ) -> Result { - let config = resolve::resolve(Path::new("."), overrides)?; - // The same union the engine decides against, so `explain` cannot resolve a - // token differently from the gate that raised it — which is the drift a - // second reader of one table always produces. - let registry = policy::registry_for(&config.verdicts)?; + // A CONFIG THAT WILL NOT LOAD IS EXACTLY WHEN A CLASS NEEDS EXPLAINING + // (CLOUD-1313). This used to be `resolve::resolve(..)?`, so `explain` died + // on the load before it could consult any registry — measured on a repo whose + // `batten.toml` carries one malformed table: + // + // $ batten policy explain "path write refused" + // batten: invalid config ./batten.toml: TOML parse error at line 3 + // + // `path write refused` is VENDORED. It needs no consumer config, it is what + // the mediated boundary raises dozens of times a session, and its remedy was + // unreachable in the one repository state where a reader is most likely to be + // stuck. The remedy channel went dark precisely when the config broke. + // + // So a load failure degrades rather than refuses: the union where a config + // loads — which is what stops `explain` resolving a token differently from + // the gate that raised it — and this binary's vendored classes where it does + // not. What genuinely needs the config still says so below rather than + // guessing: a `[[rule]]` id and a `[[redirect]]` remedy are the consumer's, + // and neither is answerable from a config nobody could read. + let config = resolve::resolve(Path::new("."), overrides).ok(); + let registry = match &config { + Some(config) => policy::registry_for(&config.verdicts)?, + None => verdict::vendored(), + }; let Some((resolved, retired)) = verdict::resolve(®istry, token) else { // A RULE ID RESOLVES HERE TOO (CLOUD-1286), and that is what makes "the // token is the pointer to the fix" true rather than aspirational. The @@ -3242,8 +3361,14 @@ fn run_policy_explain( // Tried second rather than first because a class is what a reader most // often has, and the two namespaces cannot collide: a class is three // lowercase words and a rule id is a kebab-case identifier. - if let Some(rule) = config.rules.iter().find(|rule| rule.id == token) { - return explain_rule(rule, &config.facts, json, out); + if let Some(rule) = config + .as_ref() + .and_then(|config| config.rules.iter().find(|rule| rule.id == token)) + { + let facts = config + .as_ref() + .map_or(&[][..], |config| config.facts.as_slice()); + return explain_rule(rule, facts, json, out); } // THE DERIVED PROTECTED GATE HAS NO `[[rule]]` ROW, and its remedy is // per PATH CLASS rather than per rule (CLOUD-280): a `[[redirect]]` @@ -3252,7 +3377,11 @@ fn run_policy_explain( // to land — a class hop answers about `path write refused` generically // and a rule hop has no row to find. So the gate's own id resolves here, // to the table that answers "what do I do instead for THIS path". - if token == hook::PROTECTED_MUTATION { + // THE CONSUMER'S OWN TABLES, so this arm needs a config that loaded. + // Absent one it falls through to the refusal below, which says the class + // is undeclared HERE rather than pretending an empty redirect table is + // an answer — an empty "what to do instead" reads as "nothing to do". + if let (true, Some(config)) = (token == hook::PROTECTED_MUTATION, config.as_ref()) { return explain_redirects(&config.redirects, &config.verbs, json, out); } // Named, and the token is the caller's own argument rather than @@ -3261,9 +3390,15 @@ fn run_policy_explain( // pointer-shaped answer. return Err(error::UsageError::raise(format!( "no `[[verdict]]` row and no `[[rule]]` row declares `{token}`; this registry \ - declares {} class(es) and this config declares {} rule(s)", + declares {} class(es) and this config declares {}", registry.len(), - config.rules.len(), + // THREE-VALUED, because "0 rules" and "no config could be read" are + // different answers and collapsing them would send a reader looking + // for a missing row when the real fault is the file. + config.as_ref().map_or_else( + || "no rules (this config could not be read)".to_owned(), + |config| format!("{} rule(s)", config.rules.len()), + ), ))); }; if json { @@ -7945,9 +8080,27 @@ fn register_enforce_findings(scan: &rules::Scan, mode: Mode, err: &mut dyn Write // `record` refuses a finding with no remediation as a usage error, which is // the right answer for a recording verb and the wrong one here: it would let - // one unfixable rule row turn a policy verdict into exit 1. `Rule::validate` - // already refuses such a row, so this partition should never fire — which is - // exactly why it reports a count instead of being an `expect`. + // one unfixable rule row turn a policy verdict into exit 1. So this reports a + // count rather than being an `expect`. + // + // THE REASON THIS COMMENT USED TO GIVE WAS FALSE, and correcting it is half + // of CLOUD-1220. It said "`Rule::validate` already refuses such a row, so + // this partition should never fire". `Rule::validate` refuses no such thing + // for the one kind it mattered for: `RuleKind::Policy` requires only + // `severity`, where `RuleKind::Judge` requires `no_fix_reason` outright and + // says why — a judge finding reaches the store and CLOUD-81's ingest refuses + // one nothing can close. Policy rows never got that treatment, so the + // partition fired on EVERY policy-module finding this tree produced and the + // whole findings subsystem was blind to them. + // + // What is true now, and it is a different claim: a policy finding takes its + // remedy from the `[[verdict]]` class it raises (`policy_remediation`), so + // the kind that used to fall through no longer can. The partition stays a + // count rather than an `expect` because a consumer's own registry could + // still fail to resolve a token this binary did not vendor, and that is a + // config fault to report rather than a panic — CLOUD-242's lesson is that a + // guarantee which does not hold is worse than none, so this one is stated as + // narrowly as it is actually true. let (recordable, unrecordable): (Vec<_>, Vec<_>) = scan .findings .iter() @@ -9490,15 +9643,12 @@ fn run_dispositions( fail_on_warning: bool, rules: &[rules::Rule], ) -> Vec { - let attributed = &scan.attributed; let mut dispositions = Vec::with_capacity(scan.not_evaluated.len() + 1); - dispositions.push( - if rules::any_blocking(findings, fail_on_warning, rules, attributed) { - decision::Outcome::Violation - } else { - decision::Outcome::Pass - }, - ); + dispositions.push(if rules::any_blocking(findings, fail_on_warning, rules) { + decision::Outcome::Violation + } else { + decision::Outcome::Pass + }); for observation in scan.not_evaluated.values() { dispositions.push(match observation { findings::NotObserved::RuleErrored => decision::Outcome::Internal, diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index 58187cc43..f1a5369a2 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -214,181 +214,34 @@ pub struct Violation { pub subjects: Vec, } -/// Every vendored preset: its name, and the modules it ships. -/// -/// # Why Batten ships defaults at all -/// -/// A consumer adopting Batten got an empty `batten.toml` and had to author every -/// predicate from scratch, which is the anomaly rather than the discipline — -/// Conftest ships OCI bundles, Semgrep `p/default`, `ESLint`'s `recommended`, -/// Clippy its lint groups. And the non-negotiable that looks like it forbids -/// this argues *for* it: "adopt prior art; don't expand the core". A preset is -/// prior art shipped **as data**, which is the opposite of expanding the core. -/// -/// # Why this is not the OCI distribution CLOUD-129 rejected -/// -/// That verdict was about *remote policy fetch* being a supply-chain surface, -/// and it is intact. There is no network here, no registry and no -/// trust-on-first-use: `include_str!` at build time, so the bytes ship inside -/// the binary the operator already trusts, under the same checksum as everything -/// else in it. Its other ground — one committed authority per repo — does not -/// reach a preset either, because **a preset is not an authority; it is content -/// the authority enables.** -/// -/// # Rule 1 lives here, and this is the most inviting place in the crate to -/// break it -/// -/// The temptation is to vendor *this repository's* gates. A preset may contain -/// predicates true of a **practice** — trunk-based branching, commit shape — and -/// never one naming a path, a task, a tracker key or an entity. The mechanism -/// is not new prose: `batten.toml`'s rule-1 `forbid` rows glob `crates/**`, and -/// these sources are under it, so they are already scanned on every gate -/// invocation. `presets_are_inside_the_rule_one_glob` asserts that coverage -/// rather than leaving it to be true by accident. -/// -/// # One list, derived -/// -/// The valid name set is [`preset_names`], read off this table — never a -/// hand-maintained second list, which is `surface::SURFACE`'s discipline and the -/// reason a preset cannot be enabled that does not exist. -const PRESETS: &[(&str, &[(&str, &str)])] = &[ - ( - "commit-hygiene", - &[( - "/no-empty-commit.rego", - include_str!("policy/presets/commit-hygiene/no-empty-commit.rego"), - )], - ), - ( - "trunk-based", - &[( - "/no-force-push.rego", - include_str!("policy/presets/trunk-based/no-force-push.rego"), - )], - ), - // The first TREE-scoped preset (CLOUD-864). The two above judge a command; - // this one judges files, which is why it is the one that needed `lines` to - // reach paths by glob — a practice about 143 files cannot be a row that - // names 143 paths. - ( - "shell-hygiene", - &[ - ( - "/shebang-names-its-language.rego", - include_str!("policy/presets/shell-hygiene/shebang-names-its-language.rego"), - ), - ( - "/sibling-resolves.rego", - include_str!("policy/presets/shell-hygiene/sibling-resolves.rego"), - ), - ], - ), - // CLOUD-1028. The first preset whose predicate reads a RESOLVED SET rather - // than the call alone: which programs a pin provides is a different answer in - // every project, so a practice about them cannot be spelled as a pattern. It - // names no tool, no task and no mediator, which is what keeps it on the - // preset side of non-negotiable rule 1 — the boundary answers both halves, - // and the module asks only whether they line up. - ( - "pinned-toolchain", - &[( - "/pinned-program-via-the-pin.rego", - include_str!("policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego"), - )], - ), - // CLOUD-1161. The generic half of `ci-local-parity`'s retirement: what a - // hosted-CI run COSTS, and whether the wiring that decides it can be - // reached at all. Tree-scoped like `shell-hygiene`, and the second preset - // to read a PARSED document rather than lines — a workflow's jobs, triggers - // and concurrency block are structure, and a line-oriented reading cannot - // say which job a key belongs to. - // - // The split from the consumer's own module is not tidiness. A required-check - // roster, a task name and a bot's branch prefix are that repository's facts; - // shipping them here would bake one consumer's job names into every - // consumer's binary, which is the violation non-negotiable rule 1 names. - ( - "ci-hygiene", - &[ - ( - "/spend-is-authorised.rego", - include_str!("policy/presets/ci-hygiene/spend-is-authorised.rego"), - ), - ( - "/wiring-can-be-reached.rego", - include_str!("policy/presets/ci-hygiene/wiring-can-be-reached.rego"), - ), - ], - ), - // CLOUD-1269. The generic third of a landing loop. Landing judgements split - // three ways and only one third belongs here: the effect and the poll stay - // outside (CLOUD-1170's own split), the roster and the trunk's name are the - // consumer's `batten.toml`, and what is left is a predicate over facts the - // boundary already resolved. - // - // TREE-SCOPED, because it decides over what the forge recorded rather than - // over a command. Its sibling `landing-spend` is the same practice on the - // other surface, and the two are separate presets rather than one because a - // `[[rule]]` row carries ONE scope and `policy::load` refuses two rows - // naming one preset — a bundle cannot span both. - ( - "landing-loop", - &[ - ( - "/graded-head-is-not-regraded.rego", - include_str!("policy/presets/landing-loop/graded-head-is-not-regraded.rego"), - ), - // The second landing judgement, and the one whose NAME had to change - // to match its fact (CLOUD-1280). CLOUD-1269 asked for - // `target-is-fast-forwardable`; no fact decides descendant-ness, - // because CLOUD-36 decides merged-ness by patch identity and - // `policy/ancestry-decides-nothing.rego` now refuses acquiring a - // reachability answer at all. What `input.tree.landing` does answer - // is whether the target already carries this work — a real landing - // judgement, under the name that describes it. - ( - "/already-landed-work-is-not-relanded.rego", - include_str!( - "policy/presets/landing-loop/already-landed-work-is-not-relanded.rego" - ), - ), - // The lease, and the one predicate here that reads a record the - // CONSUMER's own `[[recorder]]` wrote rather than a fact the engine - // acquires. It selects on a generic `lease` kind column instead of on - // the record's name, which is the consumer's (rule 1). - ( - "/lease-authorises-the-branch.rego", - include_str!("policy/presets/landing-loop/lease-authorises-the-branch.rego"), - ), - ], - ), -]; +// `PRESETS` moved to `crate::preset::MANIFESTS` (CLOUD-1181). It was one of +// three parallel tables describing a preset, none of which knew about the +// others; the doc comment that stood here — why Batten ships defaults, why this +// is not the OCI distribution CLOUD-129 rejected, and why rule 1 is most +// inviting to break at a preset — moved with it. +// +// CLOUD-129's VERDICT IS UNCHANGED and the manifest is not permission to fetch +// one: `include_str!` at build time, no network, no registry, no +// trust-on-first-use. `crate::preset`'s own header says so at length, because a +// reader arriving at a "manifest" reasonably wonders whether something resolves +// it, and the answer is that nothing does. /// Every vendored preset's name, in a stable order. /// -/// Derived from [`PRESETS`] so the binary and the published schema cannot +/// Derived from [`crate::preset::MANIFESTS`] so the binary and the published +/// schema cannot /// disagree about what may be enabled — the same discipline `surface::SURFACE` /// carries, and the reason an unknown name is a config error rather than a /// silent no-op. #[must_use] pub fn preset_names() -> Vec<&'static str> { - PRESETS.iter().map(|(name, _)| *name).collect() + crate::preset::names() } -/// The modules a named preset ships, or `None` when nothing ships under that -/// name. -/// -/// The pointer paths are `/…` rather than a filesystem path, -/// deliberately: a preset has no path in the consumer's tree, and printing one -/// would send a reader looking for a file that is not there. A finding still -/// names the PREDICATE rather than this, so it stays indistinguishable in shape -/// from an in-repo one. -fn preset_modules(name: &str) -> Option<&'static [(&'static str, &'static str)]> { - PRESETS - .iter() - .find(|(preset, _)| *preset == name) - .map(|(_, modules)| *modules) -} +// `preset_modules` is gone (CLOUD-1181): the load site reads the whole manifest +// now, so a second lookup returning only the modules was one more place that +// could answer about a preset without seeing its scope — which is the fact the +// manifest exists to stop being separable. /// One module inside a bundle: its repository-relative path, and nothing else. /// @@ -856,7 +709,7 @@ pub fn load( // consumer who enabled `trunk-basd` should be told, not quietly gated by // nothing. if let Some(name) = rule.preset.as_deref() { - let modules = preset_modules(name).ok_or_else(|| { + let manifest = crate::preset::find(name).ok_or_else(|| { UsageError::raise(format!( "rule `{}` enables the preset `{name}`, which this binary does not ship; \ the ones it does are {}", @@ -864,6 +717,41 @@ pub fn load( preset_names().join(", ") )) })?; + // THE SCOPE CHECK (CLOUD-1181) — AND WHAT IT DOES IS NOT WHAT THE + // ROW PREDICTED, WHICH IS WORTH THE PARAGRAPH. + // + // The row expected a scope mismatch to produce an empty violation + // set: modules read the other surface's `input.*` keys, read + // undefined, refuse nothing, and a dead gate is byte-identical to a + // clean tree. MEASURED, THAT WAS ALREADY REFUSED. With this branch + // disabled and the binary rebuilt, enabling `trunk-based` at `tree` + // still fails to load — `check_tree_paths_are_emittable` catches the + // module reading `input.call` on the tree surface and says so. + // + // So this is not a hole being closed. What it buys is narrower and + // still worth having: the refusal now PRECEDES compilation and names + // the preset the consumer enabled, where the existing one names a + // module inside the binary that the consumer never wrote and cannot + // open. And the manifest's `scope` is a DECLARATION — the row's real + // gap was that "a consumer enabling a preset today cannot see which + // surface it decides", which no refusal fixes because it is a + // question asked before anything is enabled. + // + // Stated rather than absorbed, because shipping this as "closes the + // silent dead gate" would have been a claim about a channel nobody + // measured, which is the defect `.claude/rules/policy-modules.md` + // records against its own earlier revisions. + if manifest.scope != rule.scope { + return Err(UsageError::raise(format!( + "rule `{}` enables the preset `{name}` at scope `{}`, but its modules \ + decide `{}` — at the wrong scope they read keys the engine never builds, \ + so the rule would evaluate and refuse nothing", + rule.id, + rule.scope.as_str(), + manifest.scope.as_str() + ))); + } + let modules = manifest.modules; let sources: Vec<(String, String)> = modules .iter() .map(|(path, source)| ((*path).to_owned(), (*source).to_owned())) diff --git a/crates/batten/src/preset.rs b/crates/batten/src/preset.rs new file mode 100644 index 000000000..c7206d7f4 --- /dev/null +++ b/crates/batten/src/preset.rs @@ -0,0 +1,618 @@ +//! One manifest per vendored preset: its identity, the surface it decides, the +//! modules it ships, and the refusal vocabulary they raise (CLOUD-1181). +//! +//! # Why this module exists rather than three tables +//! +//! A preset used to be three unrelated `const`s that nothing tied together: the +//! name-to-modules table in `policy.rs`, its verdict rows sitting inside +//! `verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the +//! `[[pattern]]` refusal. Nothing declared a preset, so a preset carried no +//! identity beyond its name, no version, and — the load-bearing omission — no +//! SCOPE. +//! +//! Scope is where the silence costs most. `.claude/rules/policy-modules.md` +//! opens on the class: a module reading a key from the wrong surface evaluates, +//! reads undefined, refuses nothing, and *"a dead gate and a clean tree are +//! byte-identical on the decision surface"*. A consumer enabling a preset could +//! not see which surface it decided, so that mistake was theirs to make blind. +//! +//! # The inversion this ends +//! +//! Every place a preset was exempted from a rule a consumer module obeys, it was +//! exempted **because there was nowhere to write the declaration** — not because +//! the rule did not apply. A preset ships to every consumer while a consumer +//! module reaches one, so those exemptions were pointed the wrong way. The +//! manifest is the place, and it turns exemptions into declarations. +//! +//! # THIS DOES NOT OPEN THE NETWORK, AND THE MANIFEST IS NOT PERMISSION TO +//! +//! CLOUD-129 rejected remote policy fetch and that verdict is **unchanged**. +//! Every manifest here is `include_str!`d at build time: no network, no +//! registry, no trust-on-first-use, and the bytes ship inside the binary the +//! operator already trusts, under the same checksum as everything else in it. +//! A manifest is a declaration FORMAT. Whether a preset may arrive from outside +//! the binary is CLOUD-970's question, and a manifest is what makes that +//! question askable — before this, a third-party preset had no shape to arrive +//! in, so the trust question could not even be posed. Read no further permission +//! into it than that. +//! +//! # Rule 1 binds a manifest exactly as it binds a preset source +//! +//! A field may describe a PRACTICE and may never name a path, a task, a tracker +//! key or an entity. This file is under `crates/**`, so `batten.toml`'s rule-1 +//! `forbid` rows already scan it on every gate invocation; +//! `manifests_are_inside_the_rule_one_glob` asserts that coverage rather than +//! leaving it true by accident. + +use crate::rules::RuleScope; +use crate::verdict::{DeclaredVerdict, VendoredVerdict, admit, read, run}; + +/// One vendored preset, declared once. +#[derive(Debug)] +#[non_exhaustive] +pub struct Manifest { + /// The name a consumer enables, and the only thing they could know before. + pub name: &'static str, + /// The manifest's own version. + /// + /// Separate from the crate version deliberately, and for the reason + /// `spec::SPEC_VERSION` is separate: a version moving with the binary says + /// "the binary changed", which the release tag already says. This moves when + /// what the preset DECIDES changes. + pub version: u32, + /// The surface these modules decide. + /// + /// The field the absence of which was the silent dead gate. A rule enabling + /// this preset at the other scope is refused at LOAD rather than evaluating + /// to an empty violation set — see `policy::load`. + pub scope: RuleScope, + /// The modules, as `(pointer, source)`. The pointer is `/…` + /// rather than a filesystem path: a preset has no path in the consumer's + /// tree, and printing one sends a reader looking for a file that is not there. + pub modules: &'static [(&'static str, &'static str)], + /// The refusal classes these modules raise, with their glosses and remedies. + /// + /// Declared HERE rather than in `verdict.rs`'s native table, which is the + /// change that makes the registry's two directions reachable for a preset: + /// a token a module raises that no row here declares, and a row here that no + /// module raises, are both findings a manifest can now express. + pub verdicts: &'static [VendoredVerdict], + /// The `[[pattern]]` ids these modules would cite if they could. + /// + /// **The declaration site CLOUD-934 needs, and deliberately not its fix.** A + /// preset reaches a consumer who wrote no `[[pattern]]` rows, so + /// `data.batten.patterns["x"]` resolves to undefined there and a preset + /// citing one decides nothing while loading clean — which is why the + /// exemption exists and why a preset still writes its literal inline. + /// Listing the ids is what makes the exemption countable instead of + /// invisible; closing it is CLOUD-934's row and this one does not do its work. + pub patterns: &'static [&'static str], +} + +/// Every vendored preset, in a stable order. +/// +/// The ONE authority. `preset_names`, the modules a preset ships, and the +/// presets' half of the vendored verdict registry are all read off this table — +/// never a hand-maintained second list, which is `surface::SURFACE`'s discipline +/// and the reason a preset cannot be enabled that does not exist. +pub const MANIFESTS: &[Manifest] = &[ + Manifest { + name: "ci-hygiene", + version: 1, + scope: RuleScope::Tree, + modules: &[ + ( + "/spend-is-authorised.rego", + include_str!("policy/presets/ci-hygiene/spend-is-authorised.rego"), + ), + ( + "/wiring-can-be-reached.rego", + include_str!("policy/presets/ci-hygiene/wiring-can-be-reached.rego"), + ), + ], + verdicts: &[ + VendoredVerdict { + id: "cache build loose", + gloss: "a cache-warming build recompiles and writes nothing on every run", + class: "A build that compiles to fill a cache and runs nothing judges nothing, which is \ +why it is exempt from parity rules — and that exemption is what makes it easy to leave running \ +for nothing. Measured: two cache entries carrying the same key across five merges, each cycle \ +compiling for ~145s and saving nothing, because the restore skips saving when the key already \ +exists. One condition reading the restore's hit flag is the whole fix.", + routes: &[read("source read first", "the compile step")], + }, + VendoredVerdict { + id: "cache name unknown", + gloss: "the cache guard names a step that does not exist, so it admits every run", + class: "The other direction of the same defect, and it has the same symptom with no \ +signal. If the action stops emitting the hit flag the expression is empty, the guard holds, and \ +the compile runs — wasteful, but visible in the bill. If the step id is dropped or renamed while \ +the guard keeps naming it, the expression is ALSO empty and the build silently reverts to \ +compiling every time. So the class names both halves: the guard must be present, and the step it \ +reads must exist.", + routes: &[read("source read first", "the restore step")], + }, + VendoredVerdict { + id: "event bind loose", + gloss: "a comment predicate fires from anywhere in a body anyone can write", + class: "An unanchored substring test fires from mid-sentence, from inside backticks, from \ +a quoted block. That makes the repository's own writing ABOUT a trigger an invocation of it, and \ +every artifact that has to name the token in order to be about it a live round. The class is the \ +unanchored read of a body anyone can write, not the one token read that way.", + routes: &[read("source read first", "the job condition")], + }, + VendoredVerdict { + id: "event reach dead", + gloss: "a declared trigger starts a run in which every job skips", + class: "The trigger exists and does nothing: the run list shows a run, and only the job's \ +conclusion says it did not happen. Measured on one lane where a manual trigger was added so it \ +could be exercised without waiting on a late cron, and every job's condition still admitted only \ +the two original events. Judged only where a condition MENTIONS the event name at all, since a \ +workflow that does not discriminate by event answers for every trigger it declares.", + routes: &[read("source read first", "the job conditions")], + }, + VendoredVerdict { + id: "input render dropped", + gloss: "an unquoted comment truncates a value before it ever reaches the forge", + class: "YAML opens a comment at an unquoted space-hash, so a value carrying an \ +interpolation after one parses to the bare text before it and the rest is discarded. Measured: \ +one workflow carried exactly that for a day and 30 consecutive runs reported a title equal to the \ +workflow name, so a caller keying on the interpolated value could never match. Linters pass over \ +the line because a comment is legal YAML, and review reads it as the thing it was meant to be. \ +Read pre-parse, because the parse is what destroys the evidence. Quoting the value is the fix.", + routes: &[read("source read first", "the truncated line")], + }, + VendoredVerdict { + id: "job require unseen", + gloss: "a fan-in enumerates its own dependencies and has gone stale", + class: "Branch protection points at one aggregating job so that adding a leg never needs \ +a ruleset change — which only holds if that job's assertion follows its dependency list by \ +itself. Measured: a fan-in enumerated three of its four dependencies, so a red fourth left green \ +the one check the host requires. A set-wide predicate cannot go stale, because it names nothing.", + routes: &[read("source read first", "the fan-in job")], + }, + VendoredVerdict { + id: "job run early", + gloss: "a job spends a runner on a pull request still being verified locally", + class: "A draft says the author is still verifying locally, and it is also the lever a \ +red run pulls: a lander that re-drafts stops further spend while the failure is diagnosed. A \ +single job missing the guard defeats both, and the run it buys is one nobody reads. Measured on \ +one repository: a workflow triggered by any pull request touching a workflow file spent a runner \ +on every push to a draft for its whole life, and re-drafting did not close the tap.", + routes: &[read("source read first", "the job's condition")], + }, + VendoredVerdict { + id: "job start same", + gloss: "two scheduled workflows contend for the same runners at the same minute", + class: "Every scheduled workflow's header tends to claim a staggered slot and nothing \ +checks it, so two pairs drifted onto the same minute and the second pair landed after the first \ +was found. Compared as LITERAL expressions rather than firing times: an every-30-minutes \ +schedule genuinely overlaps every hourly slot, and flagging that would make the class fire \ +forever on a workflow doing nothing wrong.", + routes: &[read("source read first", "the schedule trigger")], + }, + VendoredVerdict { + id: "merge run early", + gloss: "a comment-triggered merge delegates the draft question to the ruleset", + class: "A draft head grades no checks where every pull-request workflow is draft-gated, \ +and a branch ruleset admits that empty set as satisfying required-checks-green. So a merge path \ +that never reads the draft state has no draft check at all, and can advance the trunk to a commit \ +CI never ran on. Deciding not to ask is not the same as asking.", + routes: &[read("source read first", "the merge job")], + }, + VendoredVerdict { + id: "review watch missing", + gloss: "a draft-gated workflow can never be superseded once it skips", + class: "Omitting `types:` defaults to `[opened, synchronize, reopened]`. Where the jobs \ +are draft-gated, a pull request created as a draft mints a skipped run on `opened`, and with no \ +`ready_for_review` there is no event left that could replace it — a waiter correctly refuses to \ +read a skip as an answer and polls forever. Measured as a deadlock across two pull requests at \ +once, both fully green but for one such name.", + routes: &[read( + "source read first", + "the pull_request trigger's types", + )], + }, + VendoredVerdict { + id: "workflow declare missing", + gloss: "a workflow can have two runs racing at all", + class: "Superseding is the pull-request half of this and is not the whole of it: a \ +comment- or schedule-triggered workflow never reaches that guard, so the property that matters \ +off the landing path — that a workflow cannot race itself — reaches none of them. Measured: N \ +concurrent comment invocations ran N concurrent attempts to advance a trunk branch, at 245 \ +refusals against 6 merges in half an hour. A scheduled workflow must NOT cancel its own previous \ +tick, so declaring a group is all this asks.", + routes: &[read("source read first", "the workflow")], + }, + VendoredVerdict { + id: "workflow run loose", + gloss: "a branch scope written where filtering is already too late", + class: "A job condition is evaluated AFTER the run exists, so a branch scope expressed \ +only there creates a run and then skips it. Measured on one lane: 1131 inserted-and-skipped runs \ +in 25 hours — no runner minutes, which is why it survived, but 46% of every run in the \ +repository, enough that paginating the run list stops being stable. The filter belongs on the \ +trigger, where it is free.", + routes: &[read("source read first", "the workflow_run trigger")], + }, + VendoredVerdict { + id: "workflow run twice", + gloss: "a pull-request workflow pays out a run its own next push made obsolete", + class: "A landing lap rebases and pushes. Without `cancel-in-progress` the superseded \ +commit's run is billed in full for a verdict nobody will read, and a lander loses the ability to \ +cancel a doomed run by simply pushing the next one. Declaring the group is not enough — the \ +value is what does the work, and it is a boolean rather than the string `true`.", + routes: &[read( + "source read first", + "the workflow's concurrency block", + )], + }, + ], + patterns: &[], + }, + Manifest { + name: "commit-hygiene", + version: 1, + scope: RuleScope::MediatedCall, + modules: &[( + "/no-empty-commit.rego", + include_str!("policy/presets/commit-hygiene/no-empty-commit.rego"), + )], + verdicts: &[VendoredVerdict { + id: "commit ship empty", + gloss: "an empty commit records that somebody wanted a new SHA", + class: "A commit records a change. The reachable use of an empty one is kicking a \ +pipeline, which spends a run to re-ask a question the previous run already answered and \ +leaves a commit in the history no reader can act on. If the goal is a fresh run, re-run \ +the pipeline.", + routes: &[run("task run first", "re-run the pipeline")], + }], + patterns: &[], + }, + Manifest { + name: "landing-loop", + version: 1, + scope: RuleScope::Tree, + modules: &[ + ( + "/graded-head-is-not-regraded.rego", + include_str!("policy/presets/landing-loop/graded-head-is-not-regraded.rego"), + ), + ( + "/already-landed-work-is-not-relanded.rego", + include_str!( + "policy/presets/landing-loop/already-landed-work-is-not-relanded.rego" + ), + ), + ( + "/lease-authorises-the-branch.rego", + include_str!("policy/presets/landing-loop/lease-authorises-the-branch.rego"), + ), + ], + verdicts: &[ + VendoredVerdict { + id: "head grade twice", + gloss: "the forge already judged this commit and a second run would re-ask it", + class: "A commit that has not changed cannot get a different verdict, so a second \ +run over it buys an answer that is already recorded and spends the metered tier to do it. \ +Measured on one consumer's landing bot over a half hour: 400 runs, 248 executed, against 5 \ +merges. Read the recorded verdict rather than asking for it again; if the intent was to \ +judge different work, the commit is what has to change.", + routes: &[ + read("source read first", "the forge record for this commit"), + // THE PRECONDITION IS THE WHOLE OF THIS ROUTE. A re-grade is + // legitimate when the recorded verdict is about the RUNNER rather + // than about the commit — a lost agent, an evicted node, an + // infrastructure fault — because that verdict answers a question + // nobody asked. It is not legitimate because the answer was + // unwelcome, which is the case this condition exists to exclude. + admit( + "path admit first", + "the recorded verdict is about a runner fault rather than about this commit", + ), + ], + }, + VendoredVerdict { + id: "lease grant other", + gloss: "a live landing lease names a different branch, and no reservation names this one", + class: "A landing lease is how a fleet keeps two branches from buying overlapping CI for \ +a trunk only one of them can fast-forward onto. This branch is neither the holder nor the \ +successor admitted behind it, so a matrix spent now is a matrix the holder's merge invalidates. \ +Wait for the lease to lapse or be released, or reserve the slot behind the holder — the loop that \ +does both lives outside the engine, which only reads the answer. Every reading this refusal \ +cannot take ALLOWS: an unreadable lease stops every job in the fleet, where waving one matrix \ +through costs one matrix.", + routes: &[ + read( + "lease read first", + "the lease grading recorded for this branch", + ), + // The wedged holder, and it is narrow on purpose. The lease grades + // LIVENESS rather than PROGRESS (CLOUD-499), so a holder that beats + // steadily while making none holds forever and starves the fleet. + // That is the case this admits, and it is not "the wait was + // inconvenient" — a holder that is merely slow is the mechanism + // working. + admit( + "lease admit first", + "the holder is wedged rather than slow — it is beating without advancing, so waiting for a lapse it keeps renewing starves the fleet indefinitely", + ), + ], + }, + VendoredVerdict { + id: "patch ship twice", + gloss: "the target already carries this branch's changes, so landing them again buys nothing", + class: "A landing attempt over work the target already has runs a matrix, holds the \ +fleet's landing slot while it does, and merges a no-op or a conflict. The answer is decided by \ +PATCH IDENTITY rather than by reachability, which is what makes it trustworthy here: a rebased, \ +squash-merged or cherry-picked branch leaves the same change on the target under a different \ +commit with no ancestry path back, and on a fast-forward trunk that is the ordinary way work \ +lands. Close the branch, or rebase onto the target and see what is genuinely left.", + routes: &[ + read("record read first", "the landing verdict for this target"), + // The one legitimate re-land, and it is narrow on purpose. Patch + // identity answers about CONTENT, so deliberately re-applying a + // change the target once carried and later reverted is + // indistinguishable from never having landed it — the same bytes, + // arriving for a different reason. That is the case this admits, and + // it is not "the answer was inconvenient". + admit( + "patch admit first", + "the change is being deliberately re-applied after the target reverted it, so identical content is the intent rather than a duplicate", + ), + ], + }, + ], + patterns: &[], + }, + Manifest { + name: "pinned-toolchain", + version: 1, + scope: RuleScope::MediatedCall, + modules: &[( + "/pinned-program-via-the-pin.rego", + include_str!("policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego"), + )], + verdicts: &[VendoredVerdict { + id: "pin reach loose", + gloss: "a program the project's pin provides was reached around the pin", + class: "The pinned toolchain is what makes one machine's run mean anything about \ +another's, and it supplies an ENVIRONMENT as well as a binary. A program reached around \ +it runs a different version, or the same version without the variables the project sets \ +— and the failure that produces looks like the failure being investigated rather than \ +like a wrong invocation. Measured on one consumer: sixty runs of a test suite died on an \ +unset variable instead of on the assertion, and the report that followed was published \ +as three claims about the tree, all false.", + routes: &[run( + "task run first", + "run the declared task, or invoke the program through the pin", + )], + }], + patterns: &[], + }, + Manifest { + name: "shell-hygiene", + version: 1, + scope: RuleScope::Tree, + modules: &[ + ( + "/shebang-names-its-language.rego", + include_str!("policy/presets/shell-hygiene/shebang-names-its-language.rego"), + ), + ( + "/sibling-resolves.rego", + include_str!("policy/presets/shell-hygiene/sibling-resolves.rego"), + ), + ], + verdicts: &[ + VendoredVerdict { + id: "program name unnamed", + gloss: "the file runs a shell and its name does not say so", + class: "Every instrument that selects by extension — a formatter, a linter, a \ +CI path filter — covers this file silently and exits 0. A green run over it therefore \ +means nothing was looked at rather than nothing was found, which is worse than a red \ +one. Name the language in the filename, or declare the file's coverage another way.", + routes: &[run("patch run first", "git mv")], + }, + VendoredVerdict { + id: "program resolve missing", + gloss: "a run-time sibling path is computed and the tree carries no such file", + class: "The shape resolves a path beside the running program and then guards it \ +with a test that exits 0, so the reference does not fail — it goes silent, and the \ +behaviour it was reaching for simply never happens. A path that must exist should be \ +asserted rather than tested.", + routes: &[read("source read first", "the computed path")], + }, + ], + patterns: &[], + }, + Manifest { + name: "trunk-based", + version: 1, + scope: RuleScope::MediatedCall, + modules: &[( + "/no-force-push.rego", + include_str!("policy/presets/trunk-based/no-force-push.rego"), + )], + verdicts: &[VendoredVerdict { + id: "trunk push forced", + gloss: "a force push rewrites a shared branch under whoever already fetched it", + class: "Rewriting a published branch invalidates every checkout of it that \ +already exists, and the holder finds out by having their next pull fail in a way that \ +looks like their own mistake. `--force-with-lease` refuses when the remote moved, which \ +is the same operation with the one check that makes it safe.", + routes: &[run("patch run first", "git push --force-with-lease")], + }], + patterns: &[], + }, +]; + +/// Every vendored preset's name, in a stable order. +#[must_use] +pub fn names() -> Vec<&'static str> { + MANIFESTS.iter().map(|manifest| manifest.name).collect() +} + +/// The manifest for a named preset, or `None` when nothing ships under it. +#[must_use] +pub fn find(name: &str) -> Option<&'static Manifest> { + MANIFESTS.iter().find(|manifest| manifest.name == name) +} + +/// Every verdict row the vendored presets declare, as the registry carries them. +/// +/// Chained onto the native rows by [`crate::verdict::vendored`]. Derived rather +/// than declared a second time: before this, adding a preset class meant editing +/// a table in another module under a comment, and nothing tied the two together. +#[must_use] +pub fn verdict_rows() -> Vec { + MANIFESTS + .iter() + .flat_map(|manifest| manifest.verdicts.iter()) + .map(crate::verdict::declared_from) + .collect() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::{MANIFESTS, names}; + + use std::collections::BTreeSet; + + /// Every class a preset's modules raise is declared by its own manifest. + /// + /// The direction a consumer module already gets from `check_verdicts_are_declared`, + /// now reachable for a preset because there is somewhere to declare it. + /// Before the manifest a preset's rows lived in another module's table under + /// a comment, so this question had no side to ask. + /// + /// Read from the module SOURCE, which is the only honest reading: the + /// alternative is to trust that the manifest and the modules agree, which is + /// the assumption the manifest exists to remove. + #[test] + fn every_class_a_preset_raises_is_declared_by_its_own_manifest() { + for manifest in MANIFESTS { + let declared: BTreeSet<&str> = manifest.verdicts.iter().map(|entry| entry.id).collect(); + for (pointer, source) in manifest.modules { + for token in raised_in(source) { + assert!( + declared.contains(token.as_str()), + "`{}` raises `{token}` in `{pointer}`, which its manifest does not \ + declare — the refusal would carry no gloss and no route", + manifest.name + ); + } + } + } + } + + /// And the mirror: a class no module raises fails. + /// + /// The anti-vacuity direction the `[[verdict]]` registry already enforces + /// in-tree and which a preset was exempt from, not because the rule did not + /// apply but because there was nowhere to write the declaration. A class no + /// gate reaches reads as coverage while its routes have never been walked. + #[test] + fn every_class_a_manifest_declares_is_raised_by_one_of_its_modules() { + for manifest in MANIFESTS { + let raised: BTreeSet = manifest + .modules + .iter() + .flat_map(|(_, source)| raised_in(source)) + .collect(); + for entry in manifest.verdicts { + assert!( + raised.contains(entry.id), + "`{}` declares `{}`, which none of its modules raise", + manifest.name, + entry.id + ); + } + } + } + + /// One name, one manifest. + #[test] + fn no_preset_is_declared_twice() { + let mut seen = names(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(before, seen.len(), "two manifests share a preset name"); + } + + /// A class belongs to exactly one preset. + /// + /// Two manifests declaring one token would collide in the registry, and + /// `policy::registry_for` refuses a collision between the vendored and + /// consumer halves rather than within the vendored half — so nothing else + /// asks this. + #[test] + fn no_class_is_declared_by_two_presets() { + let mut seen: Vec<&str> = MANIFESTS + .iter() + .flat_map(|manifest| manifest.verdicts.iter().map(|entry| entry.id)) + .collect(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!( + before, + seen.len(), + "a class is declared by two presets, so the registry cannot say which raises it" + ); + } + + /// Rule 1 reaches a manifest as it reaches a preset source. + /// + /// Asserted rather than assumed, per the row: this file is under `crates/**` + /// so `batten.toml`'s rule-1 `forbid` rows already scan it, and what is + /// checked here is that the file is where that glob can see it. + #[test] + fn manifests_are_inside_the_rule_one_glob() { + let here = std::path::Path::new(file!()); + assert!( + here.starts_with("crates"), + "the manifests must live where rule 1's glob reaches them, not at {}", + here.display() + ); + } + + /// The classes a module raises, read from its source. + /// + /// A refusal is `{rule, verdict, subjects}` and its class is a STRING + /// LITERAL under `verdict` (`.claude/rules/policy-modules.md`), so what is + /// matched is that SHAPE: a `"rule":` key, then the `"verdict":` that + /// follows it in the same object. + /// + /// AN EARLIER VERSION MATCHED `"verdict":` ANYWHERE AND CLAIMED OVER-READING + /// WAS THE SAFE DIRECTION. It is not, and the assertion above said so + /// immediately: `landing-loop`'s own test fixtures build landing records + /// whose rows carry a `verdict` COLUMN, so `landed` and three siblings read + /// as raised classes and the manifest was accused of not declaring them. + /// Over-reading makes the raised-set too big, which fails the first + /// assertion on inputs that raise nothing — the noisy direction, not the + /// safe one. + /// + /// It also declines to read a COMPOSED verdict (`"verdict": columns[1]`), + /// and that is correct rather than a gap: the ABI refuses a class composed + /// at runtime, because a class a reader cannot look up is not a class. + fn raised_in(source: &str) -> Vec { + let mut found = Vec::new(); + for rest in source.split("\"rule\":").skip(1) { + let Some(after) = rest.find("\"verdict\": \"") else { + continue; + }; + let tail = &rest[after + "\"verdict\": \"".len()..]; + // Only when the `verdict` key belongs to the SAME object: a later + // block's key would be past the object's close. + if rest[..after].contains("\n}") { + continue; + } + if let Some(end) = tail.find('"') { + found.push(tail[..end].to_owned()); + } + } + found + } +} diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 235888276..67f95e097 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -5246,6 +5246,34 @@ fn validate_composition(rules: &[Rule], at: Option>) -> anyhow::Resu pub struct Finding { /// The [`Rule::id`] that produced this finding. pub rule: String, + /// The ROW this finding belongs to, where that is not [`Finding::rule`] + /// (CLOUD-1087). + /// + /// `None` means "the rule id is the owner", which is true for every kind but + /// `policy` — a policy finding reports the PREDICATE's id (CLOUD-832), and a + /// module may carry many predicates under one row. + /// + /// # Why a field and not a lookup + /// + /// This replaces `Scan::attributed`, a side map keyed on the scope + /// fingerprint. Review of #721 raised the same objection twice in two + /// shapes — keyed on the predicate id, then keyed on the fingerprint — and + /// re-keying a third time answers the instance rather than the class. Both + /// rounds were saying that the owner was being INFERRED from a value that + /// means something else, when it can simply be RECORDED. A construction + /// where a `command` row's id equals a predicate id and its glob equals the + /// verdict token mints the same `scope_fingerprint`, so the map could hand + /// one row's findings to another; a field cannot collide, because nothing + /// is looked up. + /// + /// [`Finding::rule`] is deliberately untouched and keeps the predicate id: + /// reporting and [`crate::waiver::apply`] ask a different question and their + /// answer is already right. + /// + /// Engine-internal — it reaches no pointer and no record, so rule 4 needs no + /// new decision here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, /// The producing rule's [`Rule::severity`], for the exit-contract decision. pub severity: RuleSeverity, /// Where the violation is. A file-scoped kind reports the repo-relative @@ -5328,14 +5356,13 @@ impl Decidability { /// `finding.rule` would therefore classify every policy finding as /// unresolvable, and this bound would silently switch off the whole kind. #[must_use] -pub fn decidability_of( - finding: &Finding, - rules: &[Rule], - attributed: &BTreeMap, -) -> Decidability { - let owner = attributed - .get(&finding.identity.fingerprint.to_hex()) - .map_or(finding.rule.as_str(), String::as_str); +pub fn decidability_of(finding: &Finding, rules: &[Rule]) -> Decidability { + // THE FINDING SAYS WHO OWNS IT (CLOUD-1087). This used to consult + // `Scan::attributed`, keyed on the scope fingerprint — a value that means + // "this finding's identity", not "this finding's row", and one a `command` + // row can mint identically to a policy predicate's. Nothing is looked up + // now, so nothing can collide. + let owner = finding.owner.as_deref().unwrap_or(&finding.rule); rules .iter() .find(|rule| rule.id == owner) @@ -5368,34 +5395,6 @@ pub struct Scan { /// baseline a later run would ratchet against having never been measured — /// CLOUD-81's fail-closed reading, one surface further on. pub requested: Vec, - /// Which containing row a FINGERPRINT belongs to, for the findings whose - /// reported id is not their owner's (CLOUD-1083). Sparse: only `policy` - /// writes here, because only there do the two ids differ. - /// - /// **Keyed on the fingerprint rather than the predicate id**, which is not - /// a detail: a predicate id and a `Rule::id` are separate namespaces and - /// nothing checks one against the other, so a module may declare a predicate - /// whose id is also a row's. A map keyed by that id could not tell the two - /// apart. A fingerprint already names ONE finding, so the collision is - /// inexpressible here rather than something the lookup has to adjudicate. - /// - /// `Finding::rule` is deliberately the predicate a reader saw rather than the - /// row that registered it (CLOUD-832) — `waiver::apply` matches on it, so a - /// waiver names the gate rather than the bundle holding it. That is right and - /// stays. But [`requested_sinks`] has to answer a DIFFERENT question, "which - /// findings are this row's", and for one kind the two answers differ. - /// - /// **Without this the sink aggregation reads clean by construction.** A - /// tree-scoped `policy` row carrying `produces` recorded `count = 0` and the - /// sha256 of the empty string however many violations its module reported, so - /// a later run ratcheting against that record compared against nothing and - /// passed — CLOUD-845's vacuous pass arriving through the very mechanism - /// CLOUD-851 added to prevent it. - /// - /// A lookup that MISSES means "the finding's rule id is its owner", which is - /// true for every other kind — so the ordinary kinds cost nothing and this - /// map's size is the count of predicates a bundle actually attributed. - pub attributed: BTreeMap, /// Which declared class a FINGERPRINT was refused under (CLOUD-1120), for the /// findings that have one. Sparse, and written only by `policy`, for /// [`Scan::attributed`]'s reason: only there does the fact exist. @@ -6081,6 +6080,12 @@ fn run( } }; + // The union the engine DECIDES against, built once for the run (CLOUD-1220). + // `registry_for` is the one authority on it and refuses a consumer row that + // collides with a vendored class, so resolving a module's token against + // anything narrower would answer for half the classes a module may raise. + let registry = crate::policy::registry_for(vocabulary.verdicts)?; + let inputs = RunInputs { provisions, files: &files, @@ -6097,6 +6102,7 @@ fn run( tool_verdicts: tool_verdicts.as_ref(), captured: captured.as_ref(), bundles, + verdicts: ®istry, }; let mut scan = Scan::default(); @@ -6180,12 +6186,9 @@ fn evaluate_rules( let (files_before, bytes_before) = (files_read(), bytes_read()); let outcome = { let Scan { - findings, - attributed, - classes, - .. + findings, classes, .. } = &mut *scan; - isolate(|| run_rule(rule, root, inputs, findings, attributed, classes)) + isolate(|| run_rule(rule, root, inputs, findings, classes)) }; costs_lock().push(RuleCost { rule: rule.id.clone(), @@ -6246,18 +6249,20 @@ fn requested_sinks(rules: &[Rule], scan: &Scan) -> Vec { let sink = rule.produces.as_ref()?; let mut subject = String::new(); let mut count = 0usize; - // THE CONTAINING ROW, NOT THE PREDICATE (CLOUD-1083). `f.rule` is - // the predicate a reader saw, and for every kind but `policy` the two - // are the same string — which is exactly why a filter on `f.rule` - // looked right and was silently empty for the one kind where they - // differ. A miss in `attributed` means the finding's own id is its - // owner, which is true for every kind that never needed the map. - for finding in scan.findings.iter().filter(|f| { - scan.attributed - .get(&f.identity.fingerprint.to_hex()) - .map_or(f.rule.as_str(), String::as_str) - == rule.id - }) { + // THE CONTAINING ROW, NOT THE PREDICATE (CLOUD-1083, by the field + // since CLOUD-1087). `f.rule` is the predicate a reader saw, and for + // every kind but `policy` the two are the same string — which is why + // a filter on `f.rule` looked right and was silently empty for the + // one kind where they differ. + // + // The finding now SAYS whose it is instead of being looked up in a + // map keyed on its fingerprint. `None` means the rule id is the + // owner, true for every kind that never needed the map. + for finding in scan + .findings + .iter() + .filter(|f| f.owner.as_deref().unwrap_or(&f.rule) == rule.id) + { subject.push_str(&finding.identity.fingerprint.to_hex()); subject.push('\n'); count += 1; @@ -6403,6 +6408,17 @@ struct RunInputs<'a> { /// an environment variable at all. external: &'a BTreeMap, bundles: &'a [crate::policy::Bundle], + /// The verdict registry a `policy` row's finding takes its remedy from + /// (CLOUD-1220). + /// + /// The UNION — consumer rows plus this binary's vendored classes — because a + /// module may raise a preset's class as readily as a consumer's, and a + /// registry missing half of them would resolve one and not the other. + /// + /// Here rather than re-resolved per violation: it is config, fixed for the + /// life of the run, and this is the shape `Vocabulary` already threads for + /// exactly that reason. + verdicts: &'a [crate::verdict::DeclaredVerdict], } fn run_rule( @@ -6410,10 +6426,7 @@ fn run_rule( root: &Path, inputs: &RunInputs<'_>, findings: &mut Vec, - // See [`Scan::attributed`]. Threaded for the one kind whose findings do not - // carry their own row's id. - attributed: &mut BTreeMap, - // See [`Scan::classes`]. Threaded beside `attributed` because it is the same + // See [`Scan::classes`]. Threaded because it is the same // kind and the same one place the fact exists (CLOUD-1120). classes: &mut BTreeMap, ) -> anyhow::Result> { @@ -6438,7 +6451,7 @@ fn run_rule( // is what decides, and returning here would switch those off by a value // nobody aimed at them. if rule.kind == RuleKind::Policy { - return Ok(policy_rule(rule, inputs, findings, attributed, classes)); + return Ok(policy_rule(rule, inputs, findings, classes)); } let Some(glob) = rule.glob.as_deref() else { // Unreachable for a tree-scoped kind, whose census requires `glob`. @@ -8084,9 +8097,6 @@ fn policy_rule( rule: &Rule, inputs: &RunInputs<'_>, findings: &mut Vec, - // Written HERE rather than derived later because this is the only place that - // holds the predicate id and the containing row's id at once. - attributed: &mut BTreeMap, // And the class, for the same reason: `Violation` carries the token, and by // the time a `Finding` exists it is gone (CLOUD-1120). classes: &mut BTreeMap, @@ -8104,6 +8114,7 @@ fn policy_rule( tool_verdicts, captured, bundles, + verdicts: registry, .. } = inputs; let Some(bundle) = bundles.iter().find(|bundle| bundle.id() == rule.id) else { @@ -8242,20 +8253,12 @@ fn policy_rule( // and they move only when the finding does. identity::scope_fingerprint(id, &fingerprint_of(violation)), ); - // BEFORE the push, and for every violation this row raises: the sink - // aggregation asks "which findings are this row's" and the answer has to - // exist by the time anything reads it (CLOUD-1083). An `Allow` predicate - // is skipped above and records nothing, which is right — it produced no - // finding to attribute. - // - // KEYED ON THE FINGERPRINT, NOT THE PREDICATE ID (found on review of - // #721). A predicate id and a `Rule::id` are separate namespaces and - // nothing checks one against the other, so a module may declare a - // predicate whose id is also a row's — and a map keyed by that id cannot - // then tell the two apart, which would hand one row's findings to the - // other. A fingerprint already names ONE finding, so the lookup is exact - // and the collision is inexpressible rather than refused. - attributed.insert(identity.fingerprint.to_hex(), rule.id.clone()); + // THE OWNER IS ON THE FINDING NOW (CLOUD-1087), set at its construction + // below rather than recorded in a side map here. `Scan::attributed` was + // keyed on the scope fingerprint, and a `command` row whose id equals a + // predicate id and whose glob equals the verdict token mints the same + // one — so the map could hand this row's findings to that one. Nothing + // is looked up any more, so nothing can collide. // THE CLASS, beside the owner and for the same reason (CLOUD-1120): this // is the one place it exists. `Violation` carries the token the module // raised, `Finding` has nowhere to put it, and without it a spent @@ -8263,6 +8266,11 @@ fn policy_rule( // articulated, bound and consumed while the gate went on refusing. classes.insert(identity.fingerprint.to_hex(), violation.verdict.clone()); findings.push(Finding { + // THE ONE KIND WHOSE FINDING IS NOT ITS ROW'S (CLOUD-1087). + // `rule` above carries the PREDICATE id (CLOUD-832); the row that + // enabled the module is what a sink is asked about, and it is in hand + // right here rather than reconstructible from a fingerprint later. + owner: Some(rule.id.clone()), // THE PREDICATE'S ID, not the row's (CLOUD-832). `waiver::apply` // matches on this field, so a waiver names the gate a reader saw // rather than the bundle that happens to hold it. @@ -8288,13 +8296,79 @@ fn policy_rule( }), line, check: rule.settling_check().unwrap_or(Check::Reevaluate), - remediation: rule.remediation(), + // THE REMEDY COMES FROM THE CLASS, NOT THE ROW (CLOUD-1220). + // + // `rule.remediation()` reads the row's own `fix`/`no_fix_reason`, and a + // `policy` row carries neither: `RuleKind::Policy` requires only + // `severity`, where `RuleKind::Judge` requires `no_fix_reason` outright — + // with a comment saying exactly why, that a judge finding "reaches the + // store and CLOUD-81's ingest refuses one nothing can close". Policy rows + // never got that treatment, so every one of their findings was dropped + // before `findings::record`, and the whole findings subsystem — baseline, + // dedup, disposition, sink accounting — was blind to the one rule kind + // CLOUD-843's campaign is porting ~132 gates onto. + // + // The remedy was never missing, only unreachable: the `[[verdict]]` token + // this violation raises declares its routes, and this is the hop that was + // absent. No row's `fix` column is consulted for a policy finding — a + // second spelling of a remedy the registry already carries is the + // duplication that registry exists to remove. + remediation: policy_remediation(registry, &violation.verdict), identity, }); } None } +/// The remedy a raised class declares, as a [`Remediation`] (CLOUD-1220). +/// +/// # Why a policy finding cannot take the row's own remedy +/// +/// [`Rule::remediation`] reads the `fix`/`no_fix_reason` columns, and a `policy` +/// row carries neither — `RuleKind::Policy` requires only `severity`. That is +/// not an oversight in the row: a module's remedy is per PREDICATE, and one row +/// can carry many (CLOUD-832), so a single column on the row could not say which +/// violation it answered. The `[[verdict]]` registry is where it already lives. +/// +/// # The two shapes, and why the second is not "no remedy" +/// +/// A `command` route is a runnable fix and becomes [`Remediation::Fix`]. Every +/// other route — a document to read, an issue to file, an override to request — +/// is a remedy a human performs, so it becomes [`Remediation::NoFix`] carrying +/// the route ids and kinds. That is a real answer rather than an absence: +/// `verdict::validate` already refuses a class with no route at all, and one +/// whose only route is an override, so a resolved class always names somewhere +/// to go. +/// +/// **Pointer-only** (rule 4): route ids and kinds, never a route's target prose +/// and never the finding's content. The full text is one hop away through +/// `batten policy explain`, which is CLOUD-1286's settled position. +/// +/// `None` only where the token resolves to nothing — which +/// `check_verdicts_are_declared` refuses at load, so it is the residue of a +/// registry narrower than the one the module was compiled against rather than a +/// state a loaded config can reach. +fn policy_remediation( + registry: &[crate::verdict::DeclaredVerdict], + token: &str, +) -> Option { + if let Some(command) = crate::verdict::first_command_route(registry, token) { + return Some(Remediation::Fix( + command.split_whitespace().map(ToOwned::to_owned).collect(), + )); + } + let (entry, _) = crate::verdict::resolve(registry, token)?; + let routes: Vec = entry + .routes + .iter() + .map(|route| format!("{} ({})", route.id, route.kind.as_str())) + .collect(); + Some(Remediation::NoFix(format!( + "{token}: {} — `batten policy explain` carries the detail", + routes.join(", ") + ))) +} + /// The first subject a violation carries, as a finding's pointer (CLOUD-1050). /// /// FIRST rather than all of them, because a `Finding` carries one pointer and @@ -8779,6 +8853,7 @@ fn ratchet_finding( findings: &mut Vec, ) { findings.push(Finding { + owner: None, // The plain rule id, deliberately: `waiver::apply` matches on this // field, so decorating it would make a ratchet the one finding kind // no waiver could suppress — and the waiver is the designed hatch @@ -9537,6 +9612,7 @@ fn push_case_finding( return; }; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: path.to_owned(), @@ -9643,6 +9719,7 @@ fn unresolved_subject( return; }; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: path.to_owned(), @@ -10136,6 +10213,7 @@ fn run_once( let scope_key = rule.glob.as_deref().unwrap_or(&rule.id); let default = identity::scope_fingerprint(&rule.id, scope_key); findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), identity: identity_of(rule, identity::FindingKind::Scope, default), @@ -10175,14 +10253,9 @@ fn run_once( /// blocks an approximating rule" is a property of this function rather than a /// convention its callers keep. #[must_use] -pub fn any_blocking( - findings: &[Finding], - fail_on_warning: bool, - rules: &[Rule], - attributed: &BTreeMap, -) -> bool { +pub fn any_blocking(findings: &[Finding], fail_on_warning: bool, rules: &[Rule]) -> bool { findings.iter().any(|finding| { - decidability_of(finding, rules, attributed).may_block() + decidability_of(finding, rules).may_block() && severity::promote( severity::row_for_rule(finding.severity).report, fail_on_warning, @@ -10333,6 +10406,7 @@ fn forbid_in_files( // engine picks the same span. let default = identity::code_fingerprint(&rule.id, rel_path, line, mode)?; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: rel_path.to_owned(), @@ -10472,6 +10546,7 @@ fn document_in_file( identity::SpanNormalization::Verbatim, )?; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: rel_path.to_owned(), @@ -10497,6 +10572,7 @@ fn unreadable_document(rule: &Rule, rel_path: &str, node_path: &str) -> anyhow:: identity::SpanNormalization::Verbatim, )?; Ok(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: rel_path.to_owned(), @@ -12242,6 +12318,13 @@ mod tests { tool_verdicts: None, captured: None, bundles: &[], + // Empty, and that is honest for this harness: it builds no + // bundle, so no `policy` row runs and nothing here resolves a + // class. A fixture supplying a registry no consumer supplies is + // how a deny case passes for the wrong reason + // (`.claude/rules/policy-modules.md`), and the tier that DOES + // drive a module is the compiled-binary one. + verdicts: &[], } } } @@ -14608,12 +14691,7 @@ mod tests { for promote in [false, true] { assert!( - any_blocking( - &findings, - promote, - std::slice::from_ref(&deciding), - &BTreeMap::new() - ), + any_blocking(&findings, promote, std::slice::from_ref(&deciding)), "a deciding row blocks (fail_on_warning={promote})" ); // …and the identical findings under an approximating row do not, @@ -14621,7 +14699,7 @@ mod tests { // it promotes through `severity::promote`, so a bound applied // outside `any_blocking` would be reachable by one CLI argument. assert!( - !any_blocking(&findings, promote, approximating, &BTreeMap::new()), + !any_blocking(&findings, promote, approximating), "an approximating row must not block (fail_on_warning={promote})" ); } @@ -14646,14 +14724,14 @@ mod tests { // mean to exercise rather than over the severity rank it does. let table = std::slice::from_ref(&rule); assert!( - !any_blocking(&findings, false, table, &BTreeMap::new()), + !any_blocking(&findings, false, table), "a warn finding must not block" ); // …and the same finding, unchanged, blocks once the setting promotes it // (CLOUD-49). The finding itself is identical in both runs: promotion // acts on the exit decision, never on what was stored or reported. assert!( - any_blocking(&findings, true, table, &BTreeMap::new()), + any_blocking(&findings, true, table), "fail_on_warning must promote a warn finding" ); @@ -14662,11 +14740,11 @@ mod tests { let deny = run_static(deny_table, &dir).unwrap(); for promote in [false, true] { assert!( - any_blocking(&deny, promote, deny_table, &BTreeMap::new()), + any_blocking(&deny, promote, deny_table), "a deny finding must block either way" ); assert!( - !any_blocking(&[], promote, deny_table, &BTreeMap::new()), + !any_blocking(&[], promote, deny_table), "no findings, nothing blocks" ); } @@ -14678,7 +14756,7 @@ mod tests { // approximating disarmed three subsystems that never had a rule kind to // classify, which is what this arm now holds shut. assert!( - any_blocking(&deny, true, &[], &BTreeMap::new()), + any_blocking(&deny, true, &[]), "an engine-minted finding is not silenced by having no configured row" ); } diff --git a/crates/batten/src/secrets.rs b/crates/batten/src/secrets.rs index 91504106c..6db2f7dbf 100644 --- a/crates/batten/src/secrets.rs +++ b/crates/batten/src/secrets.rs @@ -1077,6 +1077,7 @@ pub fn scan( )?; } findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: hit.path, diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 917c3c4de..bc3734973 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -13,7 +13,7 @@ use clap::{Arg, ArgAction, Command}; use serde::Serialize; use crate::effect::Effect; -use crate::surface::effect_for; +use crate::surface::{data_channel_for, effect_for, id_for}; /// A single flag or positional argument in the emitted spec. #[derive(Debug, Serialize, PartialEq, Eq)] @@ -26,6 +26,14 @@ pub struct FlagSpec { pub long: Option, /// Whether the argument consumes a value (a bare boolean flag does not). pub takes_value: bool, + /// Whether the argument is POSITIONAL rather than a named flag (CLOUD-969). + /// + /// Without this a positional emits as `long: null, takes_value: true`, which + /// is byte-identical to a flag that lost its long form — so a consumer + /// reconstructing an invocation from this document writes `-- ` + /// for something that takes neither, produces a broken command line, and + /// gets no signal that it did. + pub positional: bool, /// The one-line human summary, if the command declares one. pub help: Option, } @@ -37,10 +45,22 @@ pub struct CommandSpec { /// The full, root-relative command path (`config show`); the bare program /// name for the root node. pub path: String, + /// The stable id declared on this command's `SURFACE` row (CLOUD-969). + /// + /// `None` only for the root program node, which declares no row of its own. + /// This is what a consumer pins against; `path` is the spelling and moves. + pub id: Option, /// The one-line human summary, if the command declares one. pub about: Option, /// The declared effect, resolved from the §5 table (`ask` when absent). pub effect: Effect, + /// Whether this command answers through the `-J` data channel (§6). + /// + /// Published since CLOUD-969. It was a build-time-only column, so a consumer + /// had to infer the channel by looking for a flag named `json` — a second + /// derivation of something the surface already declares, and one that reads + /// `spec` (whose switch is `--format`) wrong in both directions. + pub data_channel: bool, /// Flags and positionals, sorted by name for byte-stability. pub flags: Vec, /// Subcommands, sorted by path for byte-stability. @@ -63,6 +83,9 @@ fn flag_of(arg: &Arg) -> FlagSpec { ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count ), help: arg.get_help().map(ToString::to_string), + // clap's own answer, not a heuristic over the long/short pair: a flag + // may legitimately carry neither. + positional: arg.is_positional(), } } @@ -95,6 +118,8 @@ fn walk(command: &Command, prefix: &str) -> CommandSpec { CommandSpec { effect: effect_for(&path), + data_channel: data_channel_for(&path), + id: id_for(&path).map(ToOwned::to_owned), about: command.get_about().map(ToString::to_string), flags: flags_of(command), subcommands, @@ -115,8 +140,12 @@ pub fn describe(root: &Command) -> CommandSpec { CommandSpec { path: root.get_name().to_owned(), + // The root is the binary, which the release tag already identifies; an + // id here would be a second name for the same thing. + id: None, about: root.get_about().map(ToString::to_string), effect: Effect::Ask, + data_channel: false, flags: flags_of(root), subcommands, } @@ -130,6 +159,11 @@ pub fn describe(root: &Command) -> CommandSpec { /// were and a derivation can be added beside them without moving anything. #[derive(Debug, Serialize, PartialEq, Eq)] pub struct SpecDocument { + /// The shape this document is in (CLOUD-969). + /// + /// Emitted FIRST because it is what a consumer reads before deciding whether + /// it understands the rest. + pub spec_version: u32, /// The command tree itself, at the document root. #[serde(flatten)] pub command: CommandSpec, @@ -137,15 +171,52 @@ pub struct SpecDocument { /// Emitted rather than left to each consumer to re-derive: a second /// implementation of the `effect == read` filter is a second place for it /// to be wrong, and this one is wrong in the unsafe direction. - pub read_only_allowlist: Vec, + /// + /// Each entry carries the stable id ALONGSIDE the path since CLOUD-969, and + /// that reconciliation is the point rather than a convenience: this is §5's + /// safety-critical derivation, and keyed on the spelling alone a rename + /// silently stops a consumer's pinned allowlist from matching — in the + /// direction where a path it still trusts no longer means what it did. + pub read_only_allowlist: Vec, } +/// One row of the derived read-only allowlist: the stable identity, and the +/// spelling to invoke today. +/// +/// A struct rather than a bare path (CLOUD-969). Two keys rather than two +/// parallel lists, because two lists can disagree about their own ordering and +/// a consumer would have to zip them to find out. +#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct ReadOnlyEntry { + /// The stable id declared on the command's `SURFACE` row. Pin against this. + pub id: String, + /// The path to invoke today. Human-facing, and expected to move. + pub path: String, +} + +/// The shape of the emitted document. +/// +/// A SOURCE LITERAL, deliberately, and never `CARGO_PKG_VERSION`: a version that +/// moves with the crate says "the binary changed", which is what the release tag +/// already says, and tells a consumer nothing about whether the document it is +/// about to parse is one it understands. +/// +/// **When it moves:** on any change to the emitted shape a consumer could +/// notice — a key added, removed or renamed, or a value's type changed. It does +/// NOT move when a command row is added, removed or renamed: that is the +/// surface changing, not the document's shape, and it is exactly what the +/// per-row `id` exists to let a consumer track. Pre-`0.1.0` there is no +/// back-compatibility surface (house style §2), so this is a statement about the +/// document rather than a promise about old ones. +pub const SPEC_VERSION: u32 = 1; + /// Describe the whole surface as the emitted document: [`describe`] plus the /// derivations taken from that same walk. #[must_use] pub fn document(root: &Command) -> SpecDocument { let command = describe(root); SpecDocument { + spec_version: SPEC_VERSION, read_only_allowlist: read_only_allowlist(&command), command, } @@ -169,17 +240,30 @@ pub fn to_json(spec: &SpecDocument) -> anyhow::Result { /// what makes the derivation reachable by the agent that has to honour it /// rather than only by this crate's own tests. #[must_use] -pub fn read_only_allowlist(spec: &CommandSpec) -> Vec { - let mut paths = Vec::new(); - collect_read_only(spec, spec.path.as_str(), &mut paths); - paths.sort(); - paths +pub fn read_only_allowlist(spec: &CommandSpec) -> Vec { + let mut entries = Vec::new(); + collect_read_only(spec, spec.path.as_str(), &mut entries); + // By ID, not by path: the sort key has to be the stable half, or the + // document's byte order moves under a rename that changed nothing about + // which commands are read-only. + entries.sort(); + entries } -fn collect_read_only(node: &CommandSpec, root_name: &str, out: &mut Vec) { +fn collect_read_only(node: &CommandSpec, root_name: &str, out: &mut Vec) { // The bare root program declares no effect of its own; skip it. if node.path != root_name && node.effect.is_read_only() { - out.push(node.path.clone()); + // A read-only row with no declared id cannot be listed: the whole value + // of this list is that a consumer can pin it, and an entry it cannot pin + // is one it must re-derive by path — the second derivation this list + // exists to remove. `every_declared_path_has_an_id` is what makes the + // case unreachable rather than merely unlikely. + if let Some(id) = &node.id { + out.push(ReadOnlyEntry { + id: id.clone(), + path: node.path.clone(), + }); + } } for sub in &node.subcommands { collect_read_only(sub, root_name, out); @@ -257,9 +341,31 @@ mod tests { &mut expected, ); expected.retain(|path| effect_for(path).is_read_only()); + + // Compared as PATHS, sorted by id — because the emitted list is ordered + // by its stable half (CLOUD-969) and re-sorting the expectation by path + // would assert an order the document deliberately does not have. + let emitted: Vec = document + .read_only_allowlist + .iter() + .map(|entry| entry.path.clone()) + .collect(); + let mut emitted_sorted = emitted.clone(); + emitted_sorted.sort(); expected.sort(); + assert_eq!(emitted_sorted, expected); + + // And every entry's id is the one its own row declares, so the pair a + // consumer pins against cannot drift apart inside the document. + for entry in &document.read_only_allowlist { + assert_eq!( + crate::surface::id_for(&entry.path), + Some(entry.id.as_str()), + "the allowlist entry for `{}` must carry its declared id", + entry.path + ); + } - assert_eq!(document.read_only_allowlist, expected); assert_eq!( document.read_only_allowlist, read_only_allowlist(&document.command) @@ -283,8 +389,20 @@ mod tests { #[test] fn allowlist_is_exactly_the_read_commands() { // The derived allowlist is every read-effect command path, sorted. + // + // Compared as paths since CLOUD-969: the emitted entry is `{id, path}` + // and ordered by its stable half, so this literal is sorted by path and + // the emitted paths are sorted to meet it. What the list pins is WHICH + // commands are read-only, which is the safety-critical half; that each + // entry carries its declared id is pinned by + // `the_emitted_allowlist_is_exactly_the_read_effect_filter`. + let mut emitted: Vec = read_only_allowlist(&spec()) + .into_iter() + .map(|entry| entry.path) + .collect(); + emitted.sort(); assert_eq!( - read_only_allowlist(&spec()), + emitted, vec![ // The gate half of the attribution pair. It reads commit metadata // through git's read-only plumbing and matches configured patterns @@ -408,7 +526,7 @@ mod tests { // allowlist is the artifact an agent actually consumes. let allowlist = read_only_allowlist(&spec()); assert!( - !allowlist.contains(&"enforce".to_owned()), + !allowlist.iter().any(|entry| entry.path == "enforce"), "the process-spawning verb leaked into the read-only allowlist: {allowlist:?}" ); assert_eq!(effect_for("enforce"), Effect::Unclassified); @@ -427,7 +545,7 @@ mod tests { // write. Pinned here so the correction cannot be undone by a row edit. let allowlist = read_only_allowlist(&spec()); assert!( - !allowlist.contains(&"hook".to_owned()), + !allowlist.iter().any(|entry| entry.path == "hook"), "the mediation entrypoint leaked into the read-only allowlist: {allowlist:?}" ); assert_eq!(effect_for("hook"), Effect::Unclassified); @@ -668,6 +786,12 @@ mod tests { "state list".to_owned(), "state migrate".to_owned(), "state record".to_owned(), + // The findings store's ANSWER channel (CLOUD-587). §2 gains the + // row in the same change, which is what this assertion exists to + // prompt. A verb under the existing noun rather than a new one: + // the store has one noun and `record` is already a per-observation + // write, so a disposition is the same act against the same object. + "state settle".to_owned(), // The build-tree noun (CLOUD-1030), ported off // `mise-tasks/target-prune.sh` for `semver`'s reason above. Both // rows are `Effect::Destructive` and so are deliberately absent diff --git a/crates/batten/src/starter.toml b/crates/batten/src/starter.toml index de296cdb2..00389da3e 100644 --- a/crates/batten/src/starter.toml +++ b/crates/batten/src/starter.toml @@ -100,3 +100,33 @@ scope = "tree" # this list is config and a tracked path that cannot be read is unreadable config. [epoch] tracked = ["batten.toml"] + +# THE BATTERIES: policy presets, compiled into the binary. +# +# Every rule above is one predicate over one object. A `policy` rule is a Rego +# module deciding over the whole resolved fact set, which is what makes a +# predicate over the RELATIONSHIP between facts expressible — the engine's rule +# loop is flat, so no row can consume another's verdict. +# +# You do not have to write one to get value from them. Batten ships modules for +# common practices, and enabling one is a rule row naming it. `batten config show` +# lists what this binary carries; today that is `ci-hygiene`, `commit-hygiene`, +# `landing-loop`, `pinned-toolchain`, `shell-hygiene` and `trunk-based`. +# +# SCOPE IS THE FIELD TO GET RIGHT. A preset's modules read one surface's facts: +# `tree` for the working tree, `mediated_call` for the single command a hook is +# adjudicating. Enabling one at the wrong scope is refused at LOAD, naming both — +# it does not quietly evaluate and decide nothing. +# +# A preset is deny-only, like every module, so switching one on can only ADD +# refusals. There is no network and no registry: the bytes ship inside the binary +# you already trust, under the same checksum as the rest of it. +# +# Uncomment to enable one: +# +# [[rule]] +# id = "trunk-based" +# kind = "policy" +# scope = "mediated_call" +# preset = "trunk-based" +# severity = "deny" diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 24446dc8e..1ef799315 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -386,7 +386,27 @@ impl FlagDecl { #[non_exhaustive] pub struct CommandDecl { /// The full, root-relative path (`config show`, never `batten config show`). + /// + /// The HUMAN spelling, and the one thing about a row that is expected to + /// change. Pin a consumer against [`CommandDecl::id`] instead. pub path: &'static str, + /// The stable identity a third party pins against (CLOUD-969). + /// + /// # Why this is declared rather than derived + /// + /// An id computed from `path` is the path with extra steps: it re-breaks on + /// exactly the rename it exists to survive. This is a LITERAL, and the whole + /// of its contract is that **it is not edited when `path` changes** — a + /// rename moves the spelling and leaves the identity alone, which is what + /// lets a consumer's pinned read-only allowlist keep matching. + /// + /// The initial values were seeded from the paths as they stood when this + /// field landed, because a seed has to come from somewhere and an arbitrary + /// one would be unreadable in `spec.rs`'s committed row set. That seeding is + /// a one-time event and emphatically not a rule: the resemblance between an + /// id and its path today is history, not a derivation, and re-deriving one + /// later would undo the field. + pub id: &'static str, /// The one-line human summary, rendered as `clap`'s `about`. pub about: &'static str, /// The declared effect (§5). Self-declared, never inherited. @@ -1603,6 +1623,8 @@ fn shell_parser() -> ValueParser { /// diffing. pub const ROOT: CommandDecl = CommandDecl { path: "", + // The root is the binary; the release tag is its identity (CLOUD-969). + id: "", about: env!("CARGO_PKG_DESCRIPTION"), effect: Effect::Ask, // A bare invocation performs no default action, so there is no answer to @@ -1734,6 +1756,7 @@ pub const SURFACE: &[CommandDecl] = &[ // path off the process-spawning surface. CommandDecl { path: "check", + id: "check", about: "Run the applicable read-only gates against the repository", data_channel: true, effect: Effect::Read, @@ -1745,6 +1768,7 @@ pub const SURFACE: &[CommandDecl] = &[ // the derived read-only allowlist by construction. CommandDecl { path: "enforce", + id: "enforce", about: "Run every configured rule, including kinds that execute a configured command", data_channel: true, effect: Effect::Unclassified, @@ -1758,6 +1782,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `2` here, which is the property fail-open actually depends on. CommandDecl { path: "exec", + id: "exec", about: "Run a command — or a `:::` bundle — and report a pointer to what it wrote", // The child owns stdout, so Batten must not interleave a document of its // own with the child's bytes. The pointer surface over captured output is @@ -1803,6 +1828,7 @@ pub const SURFACE: &[CommandDecl] = &[ // entry as a prefix (CLOUD-121). CommandDecl { path: "capture", + id: "capture", about: "Captured command output: navigate what `exec` already ran, without running it again", data_channel: false, effect: Effect::Unclassified, @@ -1826,6 +1852,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `-J` ladder is byte-stable text by construction. CommandDecl { path: "capture show", + id: "capture.show", about: "Print a capture's pointer, or the lines a selection asks for, with no second run", data_channel: true, effect: Effect::Read, @@ -1850,6 +1877,7 @@ pub const SURFACE: &[CommandDecl] = &[ // alongside `--json`. CommandDecl { path: "capture find", + id: "capture.find", about: "Resolve a stored tool response by the key it carries, with no handle to look up first", data_channel: true, effect: Effect::Read, @@ -1864,6 +1892,7 @@ pub const SURFACE: &[CommandDecl] = &[ // Fixed reads of the store's own directory plus arithmetic over the entries. CommandDecl { path: "capture list", + id: "capture.list", about: "List this repository's captures as handles, in a fixed order", data_channel: true, effect: Effect::Read, @@ -1876,6 +1905,7 @@ pub const SURFACE: &[CommandDecl] = &[ // needs rather than prompted into the void. CommandDecl { path: "capture prune", + id: "capture.prune", about: "Remove this repository's captures — the one removal path; captures never expire on their own", data_channel: false, effect: Effect::Destructive, @@ -1894,6 +1924,7 @@ pub const SURFACE: &[CommandDecl] = &[ // as a prefix (CLOUD-121). CommandDecl { path: "mcp", + id: "mcp", about: "Dispatch a declared MCP call and hand back a reduction instead of the payload", data_channel: false, effect: Effect::Unclassified, @@ -1931,6 +1962,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `--json` because there is no second encoding to choose between. CommandDecl { path: "mcp call", + id: "mcp.call", about: "Dispatch one declared method, store the response, and print the declared reduction", data_channel: false, effect: Effect::Unclassified, @@ -1955,6 +1987,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `--dry-run` on it would be a flag over an action that does not exist. CommandDecl { path: "target", + id: "target", about: "Inspect and reclaim this repository's build tree", data_channel: false, effect: Effect::Unclassified, @@ -1970,6 +2003,7 @@ pub const SURFACE: &[CommandDecl] = &[ // program that must never be prompted into the void. CommandDecl { path: "target prune", + id: "target.prune", about: "Reclaim superseded build artifacts, and refuse below the measured disk floor for the build the next lap will run", data_channel: false, effect: Effect::Destructive, @@ -1977,6 +2011,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "config", + id: "config", about: "Inspect configuration", data_channel: false, effect: Effect::Read, @@ -1984,6 +2019,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "config show", + id: "config.show", about: "Print the effective configuration", data_channel: true, effect: Effect::Read, @@ -1998,6 +2034,7 @@ pub const SURFACE: &[CommandDecl] = &[ // CLOUD-133's, which defines the record it would be stamped on. CommandDecl { path: "config epoch", + id: "config.epoch", about: "Print the content hash of the governing config surface", data_channel: true, effect: Effect::Read, @@ -2012,6 +2049,7 @@ pub const SURFACE: &[CommandDecl] = &[ // rather than a flag on that one. CommandDecl { path: "config deprecations", + id: "config.deprecations", about: "Report schema keys removed since a published release with no deprecation window", data_channel: true, // Reads committed bytes at a ref and the schema this binary derives. @@ -2021,6 +2059,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "config lint", + id: "config.lint", about: "Report policy smells in batten.toml (any smell is a violation)", data_channel: true, // Still `read` with `--host-rules`: the flag names a file or `-` the @@ -2036,6 +2075,7 @@ pub const SURFACE: &[CommandDecl] = &[ // committed authority, which is a different subject, not a second kind. CommandDecl { path: "lint", + id: "lint", about: "Lint an artifact against a declared schema", data_channel: false, effect: Effect::Read, @@ -2046,6 +2086,7 @@ pub const SURFACE: &[CommandDecl] = &[ // text the caller names, nothing on disk changes, and no process is spawned. CommandDecl { path: "lint brief", + id: "lint.brief", about: "Check a delegation brief against the handoff schema (any missing section is a violation)", data_channel: true, effect: Effect::Read, @@ -2056,6 +2097,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "spec", + id: "spec", about: "Print the tool's own command spec", // Emits data, but through `--format`: an encoding selector, not a // channel toggle. `tests::spec_switches_format_rather_than_declaring_json` @@ -2082,6 +2124,7 @@ pub const SURFACE: &[CommandDecl] = &[ // lint` is not one of its diagnostics (CLOUD-66). CommandDecl { path: "doctor", + id: "doctor", about: "Diagnose whether Batten can run in this repository", data_channel: true, effect: Effect::Read, @@ -2104,6 +2147,7 @@ pub const SURFACE: &[CommandDecl] = &[ // row already classifies the verb this way. CommandDecl { path: "doctor hooks", + id: "doctor.hooks", about: "Diagnose whether batten is wired on every hook surface of every harness", // Per-harness detail is the whole reason this is a sub-verb rather than a // line in `doctor`'s summary, and `-J` is where that detail goes. @@ -2119,6 +2163,7 @@ pub const SURFACE: &[CommandDecl] = &[ // and per §5 does not lower the declared effect. CommandDecl { path: "init", + id: "init", about: "Write a starter batten.toml, refusing to overwrite an existing one", // The pointer it emits is one path; a JSON document of one field would // be a second shape for the same answer. @@ -2139,6 +2184,7 @@ pub const SURFACE: &[CommandDecl] = &[ // suppression that size. CommandDecl { path: "baseline", + id: "baseline", about: "Record the findings that already exist, so only new ones fail", // No `-J`, matching every other write row (`init`, `defects add`, // `provision apply`). The set a baseline holds is read back through @@ -2152,6 +2198,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate", + id: "generate", about: "Emit artifacts derived from the command spec, on stdout", data_channel: false, effect: Effect::Read, @@ -2159,6 +2206,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate completions", + id: "generate.completions", about: "Emit the shell completion script for one shell", // The artifact *is* the output; there is no human rendering to switch // away from, and a shell script is not JSON. @@ -2181,6 +2229,7 @@ pub const SURFACE: &[CommandDecl] = &[ // covers for completions and man pages. CommandDecl { path: "generate hooks", + id: "generate.hooks", about: "Emit one harness's hook registrations, on stdout", // The registrations ARE the output, and they are JSON because the hosts' // config files are — not because this is a batten document with a human @@ -2196,6 +2245,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate man", + id: "generate.man", about: "Emit the roff man page for one command, on stdout", // The page IS the output, and roff is not JSON. data_channel: false, @@ -2212,6 +2262,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate markdown", + id: "generate.markdown", about: "Emit the whole command surface as one markdown reference, on stdout", // One document, no human/machine split to toggle between. data_channel: false, @@ -2228,6 +2279,7 @@ pub const SURFACE: &[CommandDecl] = &[ // an `input` document the engine never emits. CommandDecl { path: "generate schema", + id: "generate.schema", about: "Emit the JSON Schema for a config or policy-input surface, derived from the types that define it", data_channel: false, effect: Effect::Read, @@ -2251,6 +2303,7 @@ pub const SURFACE: &[CommandDecl] = &[ // it belongs on the derived read-only allowlist. CommandDecl { path: "perf", + id: "perf", about: "Measure this repository's own invocation cost", data_channel: false, effect: Effect::Write, @@ -2271,6 +2324,7 @@ pub const SURFACE: &[CommandDecl] = &[ // so a `--json` here would be a second encoding of a contract, not a channel. CommandDecl { path: "perf pair", + id: "perf.pair", about: "Measure this branch and its merge base back to back on one machine, and print both arms as paired records", data_channel: false, effect: Effect::Write, @@ -2295,6 +2349,7 @@ pub const SURFACE: &[CommandDecl] = &[ // which it belongs on the derived read-only allowlist. CommandDecl { path: "mutate", + id: "mutate", about: "Decide whether this repository's gates discriminate, rather than merely parse", data_channel: false, effect: Effect::Write, @@ -2316,6 +2371,7 @@ pub const SURFACE: &[CommandDecl] = &[ // a contract rather than a channel. CommandDecl { path: "mutate sweep", + id: "mutate.sweep", about: "Apply every declared mutation to its source and report the ones its declared suite did not catch", data_channel: false, effect: Effect::Write, @@ -2334,6 +2390,7 @@ pub const SURFACE: &[CommandDecl] = &[ // first runs. CommandDecl { path: "mutate census", + id: "mutate.census", about: "Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption", data_channel: false, effect: Effect::Read, @@ -2347,6 +2404,7 @@ pub const SURFACE: &[CommandDecl] = &[ // subtree, and the day a mutating verb joins it, this row changes with it. CommandDecl { path: "policy", + id: "policy", about: "Inspect the thresholds and path sets this repository holds itself to", data_channel: false, effect: Effect::Read, @@ -2357,6 +2415,7 @@ pub const SURFACE: &[CommandDecl] = &[ // the `read` structural promise requires (CLOUD-50). CommandDecl { path: "policy budget", + id: "policy.budget", about: "Judge the always-loaded instruction set against its declared token budget", data_channel: true, effect: Effect::Read, @@ -2379,6 +2438,7 @@ pub const SURFACE: &[CommandDecl] = &[ // rule 4 keeps off every channel never reaches this verb at all. CommandDecl { path: "policy hooks", + id: "policy.hooks", about: "Judge this session's hook output against its declared per-session budget", data_channel: true, effect: Effect::Read, @@ -2394,6 +2454,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `filter(effect == read)` with no second list to maintain. CommandDecl { path: "policy test", + id: "policy.test", about: "Run each registered module's own `test_` rules and report the predicates none exercised", data_channel: true, effect: Effect::Read, @@ -2412,6 +2473,7 @@ pub const SURFACE: &[CommandDecl] = &[ // engine already holds. One read, one answer. CommandDecl { path: "policy tools", + id: "policy.tools", about: "Print the tool names the mediated-call rows decide, one per line", data_channel: true, effect: Effect::Read, @@ -2433,6 +2495,7 @@ pub const SURFACE: &[CommandDecl] = &[ // file. CommandDecl { path: "policy explain", + id: "policy.explain", about: "Resolve a verdict token to its class definition and the routes out of it", data_channel: true, effect: Effect::Read, @@ -2456,6 +2519,7 @@ pub const SURFACE: &[CommandDecl] = &[ // either (CLOUD-701). CommandDecl { path: "commit", + id: "commit", about: "The shape a commit must take here: what its subject may say", data_channel: false, effect: Effect::Read, @@ -2467,6 +2531,7 @@ pub const SURFACE: &[CommandDecl] = &[ // promise requires. CommandDecl { path: "commit check", + id: "commit.check", about: "Refuse a commit subject that does not follow the configured convention", data_channel: true, effect: Effect::Read, @@ -2484,6 +2549,7 @@ pub const SURFACE: &[CommandDecl] = &[ // CLOUD-1059 made editing a shell rule refusable. CommandDecl { path: "ready", + id: "ready", about: "Whether an issue's Ready block satisfies the checkable clauses of the gate", data_channel: false, effect: Effect::Unclassified, @@ -2498,6 +2564,7 @@ pub const SURFACE: &[CommandDecl] = &[ // lint that echoed them would leak it through CI logs. CommandDecl { path: "ready lint", + id: "ready.lint", about: "Refuse an issue whose Ready block fails a checkable clause of the Definition of Ready", data_channel: true, effect: Effect::Read, @@ -2506,6 +2573,7 @@ pub const SURFACE: &[CommandDecl] = &[ // The `checks` noun (CLOUD-1143), ported off `mise-tasks/checks-green.sh`. CommandDecl { path: "checks", + id: "checks", about: "Whether a commit's check runs answer the question a landing depends on", data_channel: false, effect: Effect::Unclassified, @@ -2533,6 +2601,7 @@ pub const SURFACE: &[CommandDecl] = &[ // meant to preserve it. CommandDecl { path: "checks green", + id: "checks.green", about: "Refuse a head whose required checks are red, still running, or not yet registered", data_channel: true, effect: Effect::Read, @@ -2557,6 +2626,7 @@ pub const SURFACE: &[CommandDecl] = &[ // declared family instead of stranding another namespace on one leaf. CommandDecl { path: "pr", + id: "pr", about: "The pull request a landing drives, and the answers it waits on", data_channel: false, effect: Effect::Unclassified, @@ -2577,6 +2647,7 @@ pub const SURFACE: &[CommandDecl] = &[ // and `checks green`. CommandDecl { path: "pr watch", + id: "pr.watch", about: "Poll a head's check runs until the required set answers, then report the verdict", data_channel: false, effect: Effect::Unclassified, @@ -2596,6 +2667,7 @@ pub const SURFACE: &[CommandDecl] = &[ // same terms. CommandDecl { path: "claim", + id: "claim", about: "Whether the issue you are about to pull is actually unclaimed", data_channel: false, effect: Effect::Unclassified, @@ -2608,6 +2680,7 @@ pub const SURFACE: &[CommandDecl] = &[ // a writing verb on the derived read-only allowlist. CommandDecl { path: "claim check", + id: "claim.check", about: "Refuse a pull of an issue somebody is already on, and mint the receipt when it is free", data_channel: true, effect: Effect::Write, @@ -2615,6 +2688,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "semver", + id: "semver", about: "Whether this branch's API delta is compatible with the bump it claims", data_channel: false, effect: Effect::Unclassified, @@ -2622,6 +2696,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "semver check", + id: "semver.check", about: "Refuse an API break this branch's commits do not declare", // NO DATA CHANNEL, declared rather than defaulted. The verdict is one // human line naming the route and the failing lint ids; there is no `-J` @@ -2634,6 +2709,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "attribution", + id: "attribution", about: "What produced commits may carry about the tooling that made them", data_channel: false, effect: Effect::Unclassified, @@ -2644,6 +2720,7 @@ pub const SURFACE: &[CommandDecl] = &[ // code is reachable, which is what the `read` structural promise requires. CommandDecl { path: "attribution check", + id: "attribution.check", about: "Refuse vendor authorship, branding or session links in commit metadata", data_channel: true, effect: Effect::Read, @@ -2654,6 +2731,7 @@ pub const SURFACE: &[CommandDecl] = &[ // a developer's own unrelated repositories. CommandDecl { path: "attribution identity", + id: "attribution.identity", about: "Set this clone's repo-local git identity when it is unset or denied", data_channel: false, effect: Effect::Write, @@ -2667,6 +2745,7 @@ pub const SURFACE: &[CommandDecl] = &[ // posture as `receipt`: listed with its reason, never guessed (CLOUD-51). CommandDecl { path: "worktree", + id: "worktree", about: "Worktrees and the work in them: what is at risk", data_channel: false, effect: Effect::Unclassified, @@ -2677,6 +2756,7 @@ pub const SURFACE: &[CommandDecl] = &[ // it must never reach is user-supplied code, which no path here does. CommandDecl { path: "worktree status", + id: "worktree.status", about: "Report work that is uncommitted, unpushed, or not landed on the configured target", data_channel: true, effect: Effect::Read, @@ -2688,6 +2768,7 @@ pub const SURFACE: &[CommandDecl] = &[ // consumer that treats an entry as a prefix (CLOUD-90). CommandDecl { path: "override", + id: "override", about: "Issued admissions: an override is a record, never a variable somebody knows", data_channel: false, effect: Effect::Unclassified, @@ -2712,6 +2793,7 @@ pub const SURFACE: &[CommandDecl] = &[ // stdout, which a JSON document of one field would not improve. CommandDecl { path: "override request", + id: "override.request", about: "Answer a class's declared precondition and receive an admission for one situation", data_channel: false, effect: Effect::Write, @@ -2738,6 +2820,7 @@ pub const SURFACE: &[CommandDecl] = &[ // exit code carries it. CommandDecl { path: "override spend", + id: "override.spend", about: "Spend an issued admission against the situation it was issued for", data_channel: false, effect: Effect::Write, @@ -2754,6 +2837,7 @@ pub const SURFACE: &[CommandDecl] = &[ // allowlist for any consumer that treats an entry as a prefix (CLOUD-90). CommandDecl { path: "provision", + id: "provision", about: "Pinned tools this repository provisions, cached out of tree", data_channel: false, effect: Effect::Unclassified, @@ -2766,6 +2850,7 @@ pub const SURFACE: &[CommandDecl] = &[ // executing an artifact fetched from the internet. CommandDecl { path: "provision status", + id: "provision.status", about: "Report which provisioned tools do not match the manifest", data_channel: true, effect: Effect::Read, @@ -2776,6 +2861,7 @@ pub const SURFACE: &[CommandDecl] = &[ // authored — the cache is out of tree and Batten's own. CommandDecl { path: "provision apply", + id: "provision.apply", about: "Fetch, verify against the pinned checksum, and install into the out-of-tree cache", data_channel: false, effect: Effect::Write, @@ -2795,6 +2881,7 @@ pub const SURFACE: &[CommandDecl] = &[ // is listed with a stated reason rather than guessed; this is that reason. CommandDecl { path: "hook", + id: "hook", about: "Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)", // Excluded deliberately: `hook`'s stdout is already a harness-shaped // decision document that the host parses. A second JSON shape on the @@ -2819,6 +2906,7 @@ pub const SURFACE: &[CommandDecl] = &[ // unenforced for every mediated call. CommandDecl { path: "payload", + id: "payload", about: "Read a hook payload from stdin", data_channel: false, effect: Effect::Read, @@ -2842,6 +2930,7 @@ pub const SURFACE: &[CommandDecl] = &[ // into a silent fail-open, which is worse than the latency. CommandDecl { path: "payload field", + id: "payload.field", about: "Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq", data_channel: false, effect: Effect::Read, @@ -2867,6 +2956,7 @@ pub const SURFACE: &[CommandDecl] = &[ // (CLOUD-203). CommandDecl { path: "receipt", + id: "receipt", about: "Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts", data_channel: false, effect: Effect::Unclassified, @@ -2875,6 +2965,7 @@ pub const SURFACE: &[CommandDecl] = &[ // Creates state the caller can recreate by re-running the check. CommandDecl { path: "receipt record", + id: "receipt.record", about: "Record that the named check concluded pass against the current HEAD", // Records state and reports nothing; there is no document to emit. data_channel: false, @@ -2899,6 +2990,7 @@ pub const SURFACE: &[CommandDecl] = &[ // byte-identical: the SHA keying was the only keying this verb had. CommandDecl { path: "receipt status", + id: "receipt.status", about: "Judge the named check's recorded receipt against HEAD and origin/main", data_channel: true, effect: Effect::Read, @@ -2920,6 +3012,7 @@ pub const SURFACE: &[CommandDecl] = &[ // already take. CommandDecl { path: "defects", + id: "defects", about: "The append-only defect ledger: the lessons this repository has already paid for", data_channel: false, effect: Effect::Unclassified, @@ -2929,6 +3022,7 @@ pub const SURFACE: &[CommandDecl] = &[ // the derived read-only allowlist. CommandDecl { path: "defects query", + id: "defects.query", about: "List recorded defects, as pointers", data_channel: true, effect: Effect::Read, @@ -2939,6 +3033,7 @@ pub const SURFACE: &[CommandDecl] = &[ // construction — and `-n` previews without touching the tree. CommandDecl { path: "defects add", + id: "defects.add", about: "Append defect records read as JSONL on stdin", // Reports counts on stderr under -n; there is no document to emit. data_channel: false, @@ -2952,6 +3047,7 @@ pub const SURFACE: &[CommandDecl] = &[ // prefixes (CLOUD-170). CommandDecl { path: "design", + id: "design", about: "Design-evidence claims: the integrity of the record behind a decision", data_channel: false, effect: Effect::Unclassified, @@ -2962,6 +3058,7 @@ pub const SURFACE: &[CommandDecl] = &[ // possible `read`, so it joins the derived read-only allowlist. CommandDecl { path: "design audit", + id: "design.audit", about: "Audit a JSONL design-evidence claim stream on stdin for record integrity", data_channel: true, effect: Effect::Read, @@ -2972,6 +3069,7 @@ pub const SURFACE: &[CommandDecl] = &[ // advertise a write-bearing prefix on the derived allowlist (CLOUD-170). CommandDecl { path: "state", + id: "state", about: "The out-of-tree findings store: which store belongs to this checkout", data_channel: false, effect: Effect::Unclassified, @@ -2983,6 +3081,7 @@ pub const SURFACE: &[CommandDecl] = &[ // existed. CommandDecl { path: "state adopt", + id: "state.adopt", about: "Bind this checkout to its findings store, minting one only if none exists", // Reports what it bound on stderr; there is no document to emit. data_channel: false, @@ -2998,6 +3097,7 @@ pub const SURFACE: &[CommandDecl] = &[ // agent allowlist, for a side effect nobody asked that invocation for. CommandDecl { path: "state record", + id: "state.record", about: "Record this ref's findings into the store, and GC instances whose ref is gone", data_channel: false, effect: Effect::Write, @@ -3010,17 +3110,61 @@ pub const SURFACE: &[CommandDecl] = &[ // worktree (CLOUD-78's no-implicit-upgrade rule). CommandDecl { path: "state migrate", + id: "state.migrate", about: "Upgrade the findings store to this binary's record version", // Reports counts on stderr; there is no document to emit. data_channel: false, effect: Effect::Write, flags: &[], }, + // The ANSWER channel for a finding the condition cannot clear (CLOUD-587). + // + // A verb under `state` rather than a new noun: the findings store has one + // noun, and `record` is already a per-observation write into the journal, so + // a disposition is the same kind of act against the same object. A second + // noun would give one store two entry points and every later reader would + // have to work out which owns settlement. + // + // `write`, declared rather than smuggled into a read verb — it appends to + // the journal. The append is the one that already exists, so no new write + // path and no new lock arrives with it. + // + // WHY THIS IS NEEDED AT ALL: an EVENT-anchored finding cannot self-clear. A + // bypass that happened, happened, so re-evaluation keeps finding it and the + // observation never resolves to zero — CLOUD-98's own assumption says such a + // finding "clears by disposition in the store, not by the condition + // vanishing", which was correct as a design and unreachable as a mechanism: + // `stop.rs` READS `disposition`, `journal::merge` FOLDS it, and nothing + // outside a unit test ever wrote one. + CommandDecl { + path: "state settle", + id: "state.settle", + about: "Record what was decided about a stored finding", + // Reports the identity and the token on stderr; there is no document. + data_channel: false, + effect: Effect::Write, + // Both REQUIRED, unlike `adopt`'s optional store: there is no defensible + // default for either. An omitted identity would have to mean "every + // finding", and an omitted disposition would have to guess what an agent + // decided — and a guessed disposition is exactly the un-auditable + // settlement this verb exists to make explicit. + flags: &[ + FlagDecl::positional( + "identity", + "The stored finding's identity, as `state list` prints it", + ), + FlagDecl::positional( + "disposition", + "What was decided: acted, rejected-by-design or rejected-wrong", + ), + ], + }, // Store reads plus fixed read-only git plumbing. A `read` verb may run a // fixed VCS query; what it must never reach is user-supplied code, and no // configured command is reachable from this path (CLOUD-170). CommandDecl { path: "state list", + id: "state.list", about: "List stored findings and the refs they were observed in", data_channel: true, effect: Effect::Read, @@ -3055,6 +3199,7 @@ pub const SURFACE: &[CommandDecl] = &[ // as a prefix (CLOUD-170). CommandDecl { path: "record", + id: "record", about: "Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer", data_channel: false, effect: Effect::Unclassified, @@ -3081,6 +3226,7 @@ pub const SURFACE: &[CommandDecl] = &[ // declared is unspellable. CommandDecl { path: "record tool", + id: "record.tool", about: "Record a declared tool row's verdict, read as ` ` lines on stdin", // Records state and reports nothing; there is no document to emit. data_channel: false, @@ -3096,6 +3242,7 @@ pub const SURFACE: &[CommandDecl] = &[ // and building both is what wakes `forge-verdict-required` up. CommandDecl { path: "record forge", + id: "record.forge", about: "Record the forge's check verdicts for one commit, read as ` ` lines on stdin", data_channel: false, effect: Effect::Write, @@ -3120,6 +3267,7 @@ pub const SURFACE: &[CommandDecl] = &[ // entry as a prefix (CLOUD-90). CommandDecl { path: "wiring", + id: "wiring", about: "Repair a host's hook registrations", data_channel: false, effect: Effect::Unclassified, @@ -3140,6 +3288,7 @@ pub const SURFACE: &[CommandDecl] = &[ // rule that never prompts cannot hang. CommandDecl { path: "wiring reclaim", + id: "wiring.reclaim", about: "Remove non-batten hook registrations from this host's merged surfaces", data_channel: false, effect: Effect::Destructive, @@ -3186,6 +3335,37 @@ pub fn effect_for(path: &str) -> Effect { .map_or(Effect::Ask, |decl| decl.effect) } +/// Resolve the declared stable id for a full command path (CLOUD-969). +/// +/// `None` for a path [`SURFACE`] does not declare — the root program node is the +/// only one in practice, and it is deliberately not given an identity of its +/// own: it is the binary, which the tag already names. +#[must_use] +pub fn id_for(path: &str) -> Option<&'static str> { + SURFACE + .iter() + .find(|decl| decl.path == path) + .map(|decl| decl.id) +} + +/// Whether a command answers through the `-J` data channel (§6). +/// +/// A path absent from [`SURFACE`] is `false`, which is the same conservative +/// reading [`effect_for`] takes: an unrecognised command is not asserted to +/// carry a machine channel it may not have. +/// +/// Published since CLOUD-969, because it was a build-time-only column before — +/// so a spec consumer could only INFER the data channel by scanning a row's +/// flags for one named `json`, which is a second derivation of a fact the +/// surface already declares. +#[must_use] +pub fn data_channel_for(path: &str) -> bool { + SURFACE + .iter() + .find(|decl| decl.path == path) + .is_some_and(|decl| decl.data_channel) +} + /// The parent path of a command path: `"config show"` → `"config"`, and a /// top-level verb → `""` (the root). fn parent_of(path: &str) -> &str { @@ -3334,6 +3514,74 @@ pub fn command() -> Command { attach(root, "") } +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod identity_tests { + use super::{ROOT, SURFACE}; + + /// Every declared command carries a stable id (CLOUD-969). + /// + /// The root is exempt and is the ONLY exemption: it is the binary, which the + /// release tag already identifies. Naming it here rather than testing + /// `path != ""` keeps the exemption a decision a reader can see. + #[test] + fn every_declared_path_has_an_id() { + let unidentified: Vec<&str> = SURFACE + .iter() + .filter(|decl| decl.id.is_empty()) + .map(|decl| decl.path) + .collect(); + assert!( + unidentified.is_empty(), + "commands with no stable id, so nothing a consumer can pin: {unidentified:?}" + ); + assert!( + ROOT.id.is_empty(), + "the root is the binary and takes no identity of its own" + ); + } + + /// No two commands share an id. + /// + /// A duplicate is worse than a missing id: two rows answer to one handle, so + /// a consumer pinning it gets whichever the lookup reaches first — and + /// `id_for` finds the first, which makes the second silently unreachable. + #[test] + fn no_id_is_declared_twice() { + let mut duplicated: Vec<(&str, &str)> = Vec::new(); + for (at, decl) in SURFACE.iter().enumerate() { + if let Some(other) = SURFACE[..at].iter().find(|prior| prior.id == decl.id) { + duplicated.push((other.path, decl.path)); + } + } + assert!( + duplicated.is_empty(), + "two commands share a stable id, so one of them is unreachable through it: \ + {duplicated:?}" + ); + } + + /// An id is not the path, and this is what stops it drifting back into one. + /// + /// The seeds resemble their paths because they were seeded from them once + /// (see [`super::CommandDecl::id`]), and the failure mode is a later author + /// reading that resemblance as a RULE and "fixing" an id during a rename — + /// which undoes the whole field. There is no honest exit code over intent, + /// so what is asserted is the reachable half: the lookup answers by path and + /// returns the declared literal, never a computed one. + #[test] + fn the_lookup_returns_the_declared_literal_rather_than_a_computed_one() { + for decl in SURFACE { + assert_eq!( + super::id_for(decl.path), + Some(decl.id), + "`{}` must resolve to the id its own row declares", + decl.path + ); + } + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 83deda090..6a3c0db91 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -953,6 +953,51 @@ pub enum Native { ContentRefused, /// The work this call publishes names no tracker key. KeyMissing, + // ─── the config loader's own classes (CLOUD-1313) ──────────────────────── + // + // A load-time refusal was a bare `UsageError(String)` across ~172 sites, so + // a config fault was the ONE refusal class in this engine that + // `batten policy explain` could not resolve and no gate held to the + // registry -- CLOUD-1050's defect, one surface over. + // + // ONE CLASS PER TABLE, NOT PER SITE. Only ~30 of those raises sit at a + // top-level `validate`; the rest are in per-entry helpers, so a class per + // site is unbuildable. The class says WHICH TABLE would not load and the + // message carries the row and key, which is the same division `[[verdict]]` + // draws between a class and its subjects. + // + // They are `Native` rather than consumer rows for the reason the enum's own + // doc gives: the loader raises them BEFORE any config exists to declare + // them, so a declared-row spelling would be unsatisfiable at exactly the + // moment it fires. That also makes them resolvable from `vendored()` with + // no config load, which is what keeps `policy explain` usable over a config + // that will not parse. + /// The `[[verb]]` table would not load. + VerbTableRefused, + /// The `[[pattern]]` table would not load. + PatternTableRefused, + /// The `[[verdict]]` table would not load. + VerdictTableRefused, + /// The `[[redirect]]` table would not load. + RedirectTableRefused, + /// A declared remedy names no command that exists (CLOUD-1189's class). + RemedyUnresolved, + /// The `[[marker]]` table would not load. + MarkerTableRefused, + /// The `[[rule]]` table would not load. + RuleTableRefused, + /// The `[[exec_pattern]]` table would not load. + OutputTableRefused, + /// The `[[waiver]]` table would not load. + WaiverTableRefused, + /// The `[[fact]]` table would not load. + FactTableRefused, + /// The `[[mint]]` table would not load. + MintTableRefused, + /// The `[[recorder]]` table would not load. + RecorderTableRefused, + /// The `[[provision]]` table would not load. + ProvisionTableRefused, } impl Native { @@ -982,6 +1027,47 @@ impl Native { Native::ShapeRefused, Native::ContentRefused, Native::KeyMissing, + Native::VerbTableRefused, + Native::PatternTableRefused, + Native::VerdictTableRefused, + Native::RedirectTableRefused, + Native::RemedyUnresolved, + Native::MarkerTableRefused, + Native::RuleTableRefused, + Native::OutputTableRefused, + Native::WaiverTableRefused, + Native::FactTableRefused, + Native::MintTableRefused, + Native::RecorderTableRefused, + Native::ProvisionTableRefused, + ]; + + /// The classes the CONFIG LOADER raises, in `parse_ungated` order. + /// + /// One authority with three readers, which is what stops the set drifting + /// where it is used (CLOUD-1313): `config.rs`'s census holds its own + /// per-table list equal to this one in both directions, and the + /// compiled-binary tier holds its fixture set to it — so a fourteenth table + /// cannot arrive wrapped-but-untested, and a class cannot be dropped from + /// the loader while a fixture still claims to reach it. + /// + /// A subset of [`Native::ALL`] rather than a separate enum, because these + /// are resolved from the same vendored table by the same `explain` — the + /// only thing that distinguishes them is who raises them. + pub const CONFIG_FAULTS: &'static [Native] = &[ + Native::VerbTableRefused, + Native::PatternTableRefused, + Native::VerdictTableRefused, + Native::RedirectTableRefused, + Native::RemedyUnresolved, + Native::MarkerTableRefused, + Native::RuleTableRefused, + Native::OutputTableRefused, + Native::WaiverTableRefused, + Native::FactTableRefused, + Native::MintTableRefused, + Native::RecorderTableRefused, + Native::ProvisionTableRefused, ]; /// The token this class is declared and rendered under. @@ -1008,6 +1094,19 @@ impl Native { Native::ShapeRefused => "call name refused", Native::ContentRefused => "input write refused", Native::KeyMissing => "issue name missing", + Native::VerbTableRefused => "verb declare refused", + Native::PatternTableRefused => "pattern declare refused", + Native::VerdictTableRefused => "verdict declare refused", + Native::RedirectTableRefused => "redirect declare refused", + Native::RemedyUnresolved => "remedy resolve missing", + Native::MarkerTableRefused => "marker declare refused", + Native::RuleTableRefused => "rule declare refused", + Native::OutputTableRefused => "output declare refused", + Native::WaiverTableRefused => "waiver declare refused", + Native::FactTableRefused => "fact declare refused", + Native::MintTableRefused => "mint declare refused", + Native::RecorderTableRefused => "recorder declare refused", + Native::ProvisionTableRefused => "provision declare refused", } } } @@ -1028,23 +1127,34 @@ pub fn native_tokens() -> BTreeSet<&'static str> { /// `String`s and cannot be a `const`. [`vendored`] converts. The alternative — /// building the table at runtime from literals — puts the same data one /// indirection further from the reader for nothing. -struct VendoredVerdict { - id: &'static str, - gloss: &'static str, - class: &'static str, - routes: &'static [VendoredRoute], +#[derive(Debug)] +pub struct VendoredVerdict { + /// The three-word class token. + pub id: &'static str, + /// The one-line summary. + pub gloss: &'static str, + /// The prose a reader dereferences through `batten policy explain`. + pub class: &'static str, + /// The declared remedies. + pub routes: &'static [VendoredRoute], } /// One vendored route. See [`VendoredVerdict`]. -struct VendoredRoute { - id: &'static str, - kind: RouteKind, - target: &'static str, - precondition: Option<&'static str>, +#[derive(Debug)] +pub struct VendoredRoute { + /// The route's own three-word id. + pub id: &'static str, + /// Which kind of remedy it is. + pub kind: RouteKind, + /// What it points at. + pub target: &'static str, + /// The condition an override route states. + pub precondition: Option<&'static str>, } /// A `command`-kind route, which is most of them. -const fn run(id: &'static str, target: &'static str) -> VendoredRoute { +#[must_use] +pub const fn run(id: &'static str, target: &'static str) -> VendoredRoute { VendoredRoute { id, kind: RouteKind::Command, @@ -1054,7 +1164,8 @@ const fn run(id: &'static str, target: &'static str) -> VendoredRoute { } /// A `document`-kind route. -const fn read(id: &'static str, target: &'static str) -> VendoredRoute { +#[must_use] +pub const fn read(id: &'static str, target: &'static str) -> VendoredRoute { VendoredRoute { id, kind: RouteKind::Document, @@ -1070,7 +1181,8 @@ const fn read(id: &'static str, target: &'static str) -> VendoredRoute { /// unread, and the precondition is the whole payload — it is what /// [`crate::admission::questions_for`] renders the first question from. A helper /// that took a target would invite one to be written and then silently ignored. -const fn admit(id: &'static str, precondition: &'static str) -> VendoredRoute { +#[must_use] +pub const fn admit(id: &'static str, precondition: &'static str) -> VendoredRoute { VendoredRoute { id, kind: RouteKind::Override, @@ -1334,264 +1446,128 @@ range the row declares. None carried a key, so nothing on the published work say it serves.", routes: &[read("config read first", "batten.toml")], }, - // ── vendored presets ──────────────────────────────────────────────────── - VendoredVerdict { - id: "commit ship empty", - gloss: "an empty commit records that somebody wanted a new SHA", - class: "A commit records a change. The reachable use of an empty one is kicking a \ -pipeline, which spends a run to re-ask a question the previous run already answered and \ -leaves a commit in the history no reader can act on. If the goal is a fresh run, re-run \ -the pipeline.", - routes: &[run("task run first", "re-run the pipeline")], - }, - VendoredVerdict { - id: "trunk push forced", - gloss: "a force push rewrites a shared branch under whoever already fetched it", - class: "Rewriting a published branch invalidates every checkout of it that \ -already exists, and the holder finds out by having their next pull fail in a way that \ -looks like their own mistake. `--force-with-lease` refuses when the remote moved, which \ -is the same operation with the one check that makes it safe.", - routes: &[run("patch run first", "git push --force-with-lease")], - }, - VendoredVerdict { - id: "pin reach loose", - gloss: "a program the project's pin provides was reached around the pin", - class: "The pinned toolchain is what makes one machine's run mean anything about \ -another's, and it supplies an ENVIRONMENT as well as a binary. A program reached around \ -it runs a different version, or the same version without the variables the project sets \ -— and the failure that produces looks like the failure being investigated rather than \ -like a wrong invocation. Measured on one consumer: sixty runs of a test suite died on an \ -unset variable instead of on the assertion, and the report that followed was published \ -as three claims about the tree, all false.", - routes: &[run( - "task run first", - "run the declared task, or invoke the program through the pin", - )], - }, - VendoredVerdict { - id: "program name unnamed", - gloss: "the file runs a shell and its name does not say so", - class: "Every instrument that selects by extension — a formatter, a linter, a \ -CI path filter — covers this file silently and exits 0. A green run over it therefore \ -means nothing was looked at rather than nothing was found, which is worse than a red \ -one. Name the language in the filename, or declare the file's coverage another way.", - routes: &[run("patch run first", "git mv")], - }, - VendoredVerdict { - id: "program resolve missing", - gloss: "a run-time sibling path is computed and the tree carries no such file", - class: "The shape resolves a path beside the running program and then guards it \ -with a test that exits 0, so the reference does not fail — it goes silent, and the \ -behaviour it was reaching for simply never happens. A path that must exist should be \ -asserted rather than tested.", - routes: &[read("source read first", "the computed path")], - }, - VendoredVerdict { - id: "job run early", - gloss: "a job spends a runner on a pull request still being verified locally", - class: "A draft says the author is still verifying locally, and it is also the lever a \ -red run pulls: a lander that re-drafts stops further spend while the failure is diagnosed. A \ -single job missing the guard defeats both, and the run it buys is one nobody reads. Measured on \ -one repository: a workflow triggered by any pull request touching a workflow file spent a runner \ -on every push to a draft for its whole life, and re-drafting did not close the tap.", - routes: &[read("source read first", "the job's condition")], - }, - VendoredVerdict { - id: "workflow run twice", - gloss: "a pull-request workflow pays out a run its own next push made obsolete", - class: "A landing lap rebases and pushes. Without `cancel-in-progress` the superseded \ -commit's run is billed in full for a verdict nobody will read, and a lander loses the ability to \ -cancel a doomed run by simply pushing the next one. Declaring the group is not enough — the \ -value is what does the work, and it is a boolean rather than the string `true`.", - routes: &[read( - "source read first", - "the workflow's concurrency block", - )], - }, - VendoredVerdict { - id: "workflow declare missing", - gloss: "a workflow can have two runs racing at all", - class: "Superseding is the pull-request half of this and is not the whole of it: a \ -comment- or schedule-triggered workflow never reaches that guard, so the property that matters \ -off the landing path — that a workflow cannot race itself — reaches none of them. Measured: N \ -concurrent comment invocations ran N concurrent attempts to advance a trunk branch, at 245 \ -refusals against 6 merges in half an hour. A scheduled workflow must NOT cancel its own previous \ -tick, so declaring a group is all this asks.", - routes: &[read("source read first", "the workflow")], - }, + // ── the config loader's classes (CLOUD-1313) ──────────────────────────── + // + // One per `VALIDATED_AT_LOAD` table plus the remedy resolver. Each `class` + // says what the table is FOR and what a refusal from it therefore means, + // because the message the loader already carries says which row and key + // failed and repeating that here would be the payload rule 4 refuses. + // + // Every route is the config itself, which is not a placeholder: a config + // fault is edited in exactly one file, and a `command` route would have to + // name a task that can run over a config that does not load. VendoredVerdict { - id: "review watch missing", - gloss: "a draft-gated workflow can never be superseded once it skips", - class: "Omitting `types:` defaults to `[opened, synchronize, reopened]`. Where the jobs \ -are draft-gated, a pull request created as a draft mints a skipped run on `opened`, and with no \ -`ready_for_review` there is no event left that could replace it — a waiter correctly refuses to \ -read a skip as an answer and polls forever. Measured as a deadlock across two pull requests at \ -once, both fully green but for one such name.", - routes: &[read( - "source read first", - "the pull_request trigger's types", - )], + id: "verb declare refused", + gloss: "the verb table would not load", + class: "`[[verb]]` is how a consumer names the commands their harness mediates and \ +what effect each carries. A row that is inert -- declared twice, or read-effect in a table \ +named for mutation -- reads as covered while matching nothing, so the table is proven at \ +load rather than at the call it would have decided.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "workflow run loose", - gloss: "a branch scope written where filtering is already too late", - class: "A job condition is evaluated AFTER the run exists, so a branch scope expressed \ -only there creates a run and then skips it. Measured on one lane: 1131 inserted-and-skipped runs \ -in 25 hours — no runner minutes, which is why it survived, but 46% of every run in the \ -repository, enough that paginating the run list stops being stable. The filter belongs on the \ -trigger, where it is free.", - routes: &[read("source read first", "the workflow_run trigger")], + id: "pattern declare refused", + gloss: "the named-pattern registry would not load", + class: "`[[pattern]]` gives one concept one spelling, so a module cannot inline a \ +regex and duplication becomes unwritable rather than merely detectable. A malformed \ +expression here is a config fault, and refusing it at load is what stops a mediated call \ +discovering it at adjudication -- the worst moment and the wrong exit class.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "event bind loose", - gloss: "a comment predicate fires from anywhere in a body anyone can write", - class: "An unanchored substring test fires from mid-sentence, from inside backticks, from \ -a quoted block. That makes the repository's own writing ABOUT a trigger an invocation of it, and \ -every artifact that has to name the token in order to be about it a live round. The class is the \ -unanchored read of a body anyone can write, not the one token read that way.", - routes: &[read("source read first", "the job condition")], + id: "verdict declare refused", + gloss: "the refusal vocabulary would not load", + class: "`[[verdict]]` is the registry every other class in this table belongs to: a \ +token's arity, a gloss that is one line, a route list that is not an override alone, a \ +tombstone chain that terminates. Each clause is a property of the table, so it is knowable \ +without a tree and belongs where a config fault is reported.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "merge run early", - gloss: "a comment-triggered merge delegates the draft question to the ruleset", - class: "A draft head grades no checks where every pull-request workflow is draft-gated, \ -and a branch ruleset admits that empty set as satisfying required-checks-green. So a merge path \ -that never reads the draft state has no draft check at all, and can advance the trunk to a commit \ -CI never ran on. Deciding not to ask is not the same as asking.", - routes: &[read("source read first", "the merge job")], + id: "redirect declare refused", + gloss: "the redirect table would not load", + class: "`[[redirect]]` changes what a refusal SAYS for a class of path, never \ +whether it fires -- which is why it needs no raise-only clamp and why a redefinition is \ +refused for coherence with the other append-only tables rather than because it lowers a \ +bar.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "event reach dead", - gloss: "a declared trigger starts a run in which every job skips", - class: "The trigger exists and does nothing: the run list shows a run, and only the job's \ -conclusion says it did not happen. Measured on one lane where a manual trigger was added so it \ -could be exercised without waiting on a late cron, and every job's condition still admitted only \ -the two original events. Judged only where a condition MENTIONS the event name at all, since a \ -workflow that does not discriminate by event answers for every trigger it declares.", - routes: &[read("source read first", "the job conditions")], + id: "remedy resolve missing", + gloss: "a declared remedy names a command that does not exist", + class: "A refusal whose remedy points at a verb that was renamed away is worse than \ +one carrying no remedy: the reader spends the round finding out. This is the one clause \ +needing a THIRD table -- the rule ids -- so it lives at the load rather than inside either \ +remedy table's own validator, where a checker reaching past its argument would quietly \ +become the config's.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "job start same", - gloss: "two scheduled workflows contend for the same runners at the same minute", - class: "Every scheduled workflow's header tends to claim a staggered slot and nothing \ -checks it, so two pairs drifted onto the same minute and the second pair landed after the first \ -was found. Compared as LITERAL expressions rather than firing times: an every-30-minutes \ -schedule genuinely overlaps every hourly slot, and flagging that would make the class fire \ -forever on a workflow doing nothing wrong.", - routes: &[read("source read first", "the schedule trigger")], + id: "marker declare refused", + gloss: "the marker table would not load", + class: "`[[marker]]` declares the tokens a scan treats as significant. An empty \ +`token` matches every line of every file, which loads clean and reads as coverage, so the \ +table is proven at load.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "job require unseen", - gloss: "a fan-in enumerates its own dependencies and has gone stale", - class: "Branch protection points at one aggregating job so that adding a leg never needs \ -a ruleset change — which only holds if that job's assertion follows its dependency list by \ -itself. Measured: a fan-in enumerated three of its four dependencies, so a red fourth left green \ -the one check the host requires. A set-wide predicate cannot go stale, because it names nothing.", - routes: &[read("source read first", "the fan-in job")], + id: "rule declare refused", + gloss: "the rule table would not load", + class: "`[[rule]]` is the policy surface itself, and it used to be validated only by \ +whichever runner happened to evaluate it. That was defensible with one runner; with a tree \ +engine and a mediation boundary, a malformed mediated-call row validated only by the tree \ +engine is a row that loads, matches nothing at the mediation channel, and reads as \ +coverage.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "cache build loose", - gloss: "a cache-warming build recompiles and writes nothing on every run", - class: "A build that compiles to fill a cache and runs nothing judges nothing, which is \ -why it is exempt from parity rules — and that exemption is what makes it easy to leave running \ -for nothing. Measured: two cache entries carrying the same key across five merges, each cycle \ -compiling for ~145s and saving nothing, because the restore skips saving when the key already \ -exists. One condition reading the restore's hit flag is the whole fix.", - routes: &[read("source read first", "the compile step")], + id: "output declare refused", + gloss: "the exec output-predicate table would not load", + class: "`[[exec_pattern]]` is how a wrapped command's OUTPUT becomes a decidable \ +object rather than something a reader skims. A duplicate id makes two predicates \ +indistinguishable in the record they write.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "cache name unknown", - gloss: "the cache guard names a step that does not exist, so it admits every run", - class: "The other direction of the same defect, and it has the same symptom with no \ -signal. If the action stops emitting the hit flag the expression is empty, the guard holds, and \ -the compile runs — wasteful, but visible in the bill. If the step id is dropped or renamed while \ -the guard keeps naming it, the expression is ALSO empty and the build silently reverts to \ -compiling every time. So the class names both halves: the guard must be present, and the step it \ -reads must exist.", - routes: &[read("source read first", "the restore step")], + id: "waiver declare refused", + gloss: "the waiver table would not load", + class: "The stakes here are inverted from every other table: a malformed rule fails \ +to gate, but a malformed WAIVER is a hatch whose expiry nobody could read. Refusing at load \ +is what makes \"every waiver carries an expiry\" true of the resolved config rather than \ +aspirational.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "input render dropped", - gloss: "an unquoted comment truncates a value before it ever reaches the forge", - class: "YAML opens a comment at an unquoted space-hash, so a value carrying an \ -interpolation after one parses to the bare text before it and the rest is discarded. Measured: \ -one workflow carried exactly that for a day and 30 consecutive runs reported a title equal to the \ -workflow name, so a caller keying on the interpolated value could never match. Linters pass over \ -the line because a comment is legal YAML, and review reads it as the thing it was meant to be. \ -Read pre-parse, because the parse is what destroys the evidence. Quoting the value is the fix.", - routes: &[read("source read first", "the truncated line")], + id: "fact declare refused", + gloss: "the fact table would not load", + class: "`[[fact]]` declares what the boundary resolves about a call before any rule \ +reads it. A row naming a fact the engine cannot produce is a gate that evaluates, reads \ +undefined, and refuses nothing -- the silent dead gate, decided at load instead.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "head grade twice", - gloss: "the forge already judged this commit and a second run would re-ask it", - class: "A commit that has not changed cannot get a different verdict, so a second \ -run over it buys an answer that is already recorded and spends the metered tier to do it. \ -Measured on one consumer's landing bot over a half hour: 400 runs, 248 executed, against 5 \ -merges. Read the recorded verdict rather than asking for it again; if the intent was to \ -judge different work, the commit is what has to change.", - routes: &[ - read("source read first", "the forge record for this commit"), - // THE PRECONDITION IS THE WHOLE OF THIS ROUTE. A re-grade is - // legitimate when the recorded verdict is about the RUNNER rather - // than about the commit — a lost agent, an evicted node, an - // infrastructure fault — because that verdict answers a question - // nobody asked. It is not legitimate because the answer was - // unwelcome, which is the case this condition exists to exclude. - admit( - "path admit first", - "the recorded verdict is about a runner fault rather than about this commit", - ), - ], + id: "mint declare refused", + gloss: "the mint table would not load", + class: "`[[mint]]` declares what a receipt records and how long it answers for. A \ +malformed row is a receipt nothing can satisfy or one that answers forever, and both are \ +decidable from the table alone.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "patch ship twice", - gloss: "the target already carries this branch's changes, so landing them again buys nothing", - class: "A landing attempt over work the target already has runs a matrix, holds the \ -fleet's landing slot while it does, and merges a no-op or a conflict. The answer is decided by \ -PATCH IDENTITY rather than by reachability, which is what makes it trustworthy here: a rebased, \ -squash-merged or cherry-picked branch leaves the same change on the target under a different \ -commit with no ancestry path back, and on a fast-forward trunk that is the ordinary way work \ -lands. Close the branch, or rebase onto the target and see what is genuinely left.", - routes: &[ - read("record read first", "the landing verdict for this target"), - // The one legitimate re-land, and it is narrow on purpose. Patch - // identity answers about CONTENT, so deliberately re-applying a - // change the target once carried and later reverted is - // indistinguishable from never having landed it — the same bytes, - // arriving for a different reason. That is the case this admits, and - // it is not "the answer was inconvenient". - admit( - "patch admit first", - "the change is being deliberately re-applied after the target reverted it, so identical content is the intent rather than a duplicate", - ), - ], + id: "recorder declare refused", + gloss: "the recorder table would not load", + class: "`[[recorder]]` binds a captured section to a named pattern. It is validated \ +AFTER the pattern registry, because a refusal for a missing pattern id is only honest once \ +the ids are known to be well formed themselves.", + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "lease grant other", - gloss: "a live landing lease names a different branch, and no reservation names this one", - class: "A landing lease is how a fleet keeps two branches from buying overlapping CI for \ -a trunk only one of them can fast-forward onto. This branch is neither the holder nor the \ -successor admitted behind it, so a matrix spent now is a matrix the holder's merge invalidates. \ -Wait for the lease to lapse or be released, or reserve the slot behind the holder — the loop that \ -does both lives outside the engine, which only reads the answer. Every reading this refusal \ -cannot take ALLOWS: an unreadable lease stops every job in the fleet, where waving one matrix \ -through costs one matrix.", - routes: &[ - read( - "lease read first", - "the lease grading recorded for this branch", - ), - // The wedged holder, and it is narrow on purpose. The lease grades - // LIVENESS rather than PROGRESS (CLOUD-499), so a holder that beats - // steadily while making none holds forever and starves the fleet. - // That is the case this admits, and it is not "the wait was - // inconvenient" — a holder that is merely slow is the mechanism - // working. - admit( - "lease admit first", - "the holder is wedged rather than slow — it is beating without advancing, so waiting for a lapse it keeps renewing starves the fleet indefinitely", - ), - ], + id: "provision declare refused", + gloss: "the provision table would not load", + class: "`[[provision]]` is how a pinned tool reaches the cache a rule will look for \ +it in. A row that cannot resolve is a rule that will report a missing scanner at the moment \ +it was supposed to decide something.", + routes: &[read("config read first", "batten.toml")], }, ]; @@ -1602,28 +1578,43 @@ through costs one matrix.", /// a question about the pair, and this function knows only one of them. #[must_use] pub fn vendored() -> Vec { + // Native rows here, preset rows from their own manifests (CLOUD-1181). The + // presets' half used to sit in this same table under a comment, so a preset + // class and the preset that raises it were declared in different modules + // with nothing tying them together. VENDORED .iter() - .map(|entry| DeclaredVerdict { - id: entry.id.to_owned(), - gloss: entry.gloss.to_owned(), - class: entry.class.to_owned(), - routes: entry - .routes - .iter() - .map(|route| Route { - id: route.id.to_owned(), - kind: route.kind, - target: route.target.to_owned(), - precondition: route.precondition.map(str::to_owned), - }) - .collect(), - successor: None, - withdrawn: None, - }) + .map(declared_from) + .chain(crate::preset::verdict_rows()) .collect() } +/// One vendored row, as the registry carries it. +/// +/// Shared with [`crate::preset`] so the two halves of the vendored registry +/// cannot be projected differently — which is the same "one authority per fact" +/// reason the manifests exist at all. +#[must_use] +pub fn declared_from(entry: &VendoredVerdict) -> DeclaredVerdict { + DeclaredVerdict { + id: entry.id.to_owned(), + gloss: entry.gloss.to_owned(), + class: entry.class.to_owned(), + routes: entry + .routes + .iter() + .map(|route| Route { + id: route.id.to_owned(), + kind: route.kind, + target: route.target.to_owned(), + precondition: route.precondition.map(str::to_owned), + }) + .collect(), + successor: None, + withdrawn: None, + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -1893,7 +1884,20 @@ mod tests { | Native::CeilingExceeded | Native::ShapeRefused | Native::ContentRefused - | Native::KeyMissing => native.id(), + | Native::KeyMissing + | Native::VerbTableRefused + | Native::PatternTableRefused + | Native::VerdictTableRefused + | Native::RedirectTableRefused + | Native::RemedyUnresolved + | Native::MarkerTableRefused + | Native::RuleTableRefused + | Native::OutputTableRefused + | Native::WaiverTableRefused + | Native::FactTableRefused + | Native::MintTableRefused + | Native::RecorderTableRefused + | Native::ProvisionTableRefused => native.id(), }; // The prefix is gone (CLOUD-1284), so what makes this a token is the // ARITY: exactly three words. Asserting that here rather than a diff --git a/crates/batten/src/waiver.rs b/crates/batten/src/waiver.rs index 523630a63..d63a46aba 100644 --- a/crates/batten/src/waiver.rs +++ b/crates/batten/src/waiver.rs @@ -619,6 +619,7 @@ mod tests { fn finding(rule: &str, path: &str) -> Finding { Finding { + owner: None, rule: rule.to_owned(), severity: RuleSeverity::Deny, path: path.to_owned(), diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 76d51de2b..a8880119f 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -1276,11 +1276,24 @@ fn spec_emits_the_derived_read_only_allowlist() { let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("spec stdout is valid JSON"); - let allowlist: Vec<&str> = value["read_only_allowlist"] + // An entry is `{id, path}` since CLOUD-969, and reading BOTH halves here is + // the point of the reconciliation: a consumer that pinned paths alone loses + // its allowlist to a rename, in the direction where a path it still trusts + // no longer means what it did. + let entries = value["read_only_allowlist"] .as_array() - .expect("the emitted document carries the derived allowlist") + .expect("the emitted document carries the derived allowlist"); + let ids: Vec<&str> = entries .iter() - .map(|path| path.as_str().expect("an allowlist entry is a string")) + .map(|entry| entry["id"].as_str().expect("every entry carries its id")) + .collect(); + let allowlist: Vec<&str> = entries + .iter() + .map(|entry| { + entry["path"] + .as_str() + .expect("every entry carries its path") + }) .collect(); assert!(allowlist.contains(&"check"), "{allowlist:?}"); @@ -1290,9 +1303,17 @@ fn spec_emits_the_derived_read_only_allowlist() { assert!(!allowlist.contains(&"enforce"), "{allowlist:?}"); assert!(!allowlist.contains(&"hook"), "{allowlist:?}"); - let mut sorted = allowlist.clone(); + // Sorted by the STABLE half (§6). Asserting the path order instead would + // pin an order the document does not have, and would move under a rename + // that changed nothing about which commands are read-only. + let mut sorted = ids.clone(); sorted.sort_unstable(); - assert_eq!(allowlist, sorted, "the emitted allowlist is sorted (§6)"); + assert_eq!(ids, sorted, "the emitted allowlist is sorted by id (§6)"); + assert_eq!( + ids.len(), + allowlist.len(), + "every entry carries both halves: {ids:?} {allowlist:?}" + ); } #[test] @@ -1906,6 +1927,63 @@ effect = "destructive" redirect = "restore it with git" "#; +/// **The remedy channel survives a config that will not load** (CLOUD-1313). +/// +/// Measured before the fix, on a repository whose `batten.toml` carries one +/// malformed table: +/// +/// ```text +/// $ batten policy explain "path write refused" +/// batten: invalid config ./batten.toml: TOML parse error at line 3 +/// ``` +/// +/// `explain` opened with `resolve::resolve(..)?`, so a load failure killed it +/// before any registry was consulted — including for a VENDORED class that needs +/// no consumer config at all. `path write refused` is what the mediated boundary +/// raises dozens of times a session, and its remedy was unreachable in exactly +/// the repository state where a reader is most likely to be stuck. +/// +/// This is a precondition of CLOUD-1313 rather than a nicety beside it: twelve +/// config-fault classes shipped onto a surface that goes dark when a config +/// breaks would be twelve dead remedies, which is the shape that row is about. +#[test] +fn a_class_still_explains_when_the_config_cannot_be_read() { + let dir = scratch("explain-over-a-broken-config"); + write( + &dir, + "batten.toml", + // Well-formed enough to be found and malformed enough to refuse: `verb` + // is a table array whose row omits every required key. + "version = 1\n\n[[verb]]\nverb = \"x\"\n", + ); + + let explained = batten_with(&dir, &["policy", "explain", "path write refused"], &[]); + assert_eq!( + explained.status.code(), + Some(0), + "a vendored class needs no consumer config: {}", + String::from_utf8_lossy(&explained.stderr) + ); + let out = String::from_utf8_lossy(&explained.stdout); + assert!(out.contains("path write refused"), "the class: {out}"); + assert!( + out.contains("config read first"), + "AND ITS ROUTES — the one that says to read `batten.toml` is exactly the \ + advice a reader with a broken config needs: {out}" + ); + + // THE CONSUMER'S OWN TABLES STILL SAY SO rather than answering emptily. A + // rule id cannot resolve from a config nobody could read, and an empty + // redirect table would read as "nothing to do instead". + let missing = batten_with(&dir, &["policy", "explain", "some-rule-id"], &[]); + assert_eq!(missing.status.code(), Some(1), "no config, no rule table"); + let said = String::from_utf8_lossy(&missing.stderr); + assert!( + said.contains("could not be read"), + "and it names WHY rather than reporting zero rows: {said}" + ); +} + #[test] fn a_deny_names_the_path_classs_own_mutation_over_the_verbs() { // The three tiers over the compiled binary, because a refusal is a contract diff --git a/crates/batten/tests/it/config_fault_class.rs b/crates/batten/tests/it/config_fault_class.rs new file mode 100644 index 000000000..e5d86ed7e --- /dev/null +++ b/crates/batten/tests/it/config_fault_class.rs @@ -0,0 +1,267 @@ +//! End-to-end tests over the compiled binary: a config fault names a declared +//! class, and that class stays reachable while the config is broken (CLOUD-1313). +//! +//! # Why this tier, and why a unit test cannot stand in for it +//! +//! `config.rs`'s own census proves the wrapping is written. It reads source +//! text, so it cannot tell a class that is attached from one that is attached +//! and then discarded somewhere between `parse_ungated` and the process's exit +//! code — which is exactly the shape of the defect CLOUD-1049 recorded one +//! surface over, where a correct projection was thrown away by a guard one line +//! later and every predicate in the module went quiet at exit 0. +//! +//! So the assertion here is over stderr and the exit code, which is what a +//! consumer actually gets. +//! +//! # The second arm is the load-bearing one +//! +//! `batten policy explain` loads the config to answer. A config-fault class is +//! therefore the one class whose remedy channel is dark at precisely the moment +//! it fires, unless the class resolves from the vendored table with no config +//! load. `explain_over` asserts that with the broken config still in place. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use common::Fixture; + +/// A config whose ONLY fault is in the named table. +/// +/// Every row deserializes cleanly and fails its table's validator, which is the +/// distinction that makes these cases reach the class at all: a row that fails +/// `toml::from_str` is refused before any validator runs and carries no class by +/// design. +const FAULTS: &[(&str, &str, &str)] = &[ + ( + "verb declare refused", + "verb", + "version = 1\n\ + [[verb]]\nverb = \"frobnicate\"\neffect = \"write\"\n\ + [[verb]]\nverb = \"frobnicate\"\neffect = \"write\"\n", + ), + ( + "pattern declare refused", + "pattern", + "version = 1\n[[pattern]]\nid = \"unclosed\"\nregex = \"[\"\n", + ), + ( + "verdict declare refused", + "verdict", + "version = 1\n\ + [[verdict]]\nid = \"notthreewords\"\ngloss = \"a gloss\"\nclass = \"a class\"\n", + ), + ( + "redirect declare refused", + "redirect", + "version = 1\n\ + [[redirect]]\nglob = \"*.frob\"\nmutation = \"mise run fmt\"\n\ + [[redirect]]\nglob = \"*.frob\"\nmutation = \"mise run fmt\"\n", + ), + ( + "marker declare refused", + "marker", + "version = 1\n[[marker]]\nid = \"blank\"\ntoken = \"\"\n", + ), + ( + "rule declare refused", + "rule", + "version = 1\n[[rule]]\nid = \"unsevered\"\nkind = \"policy\"\n", + ), + ( + "output declare refused", + "exec_pattern", + "version = 1\n\ + [[exec_pattern]]\nid = \"twice\"\npattern = \"x\"\nreason = \"r\"\n\ + [[exec_pattern]]\nid = \"twice\"\npattern = \"y\"\nreason = \"r\"\n", + ), + ( + "waiver declare refused", + "waiver", + "version = 1\n\ + [[waiver]]\nrule = \"absent\"\nreason = \"r\"\nexpires = \"2999-01-01\"\n\ + [[waiver]]\nrule = \"absent\"\nreason = \"r\"\nexpires = \"2999-01-02\"\n", + ), + ( + "fact declare refused", + "fact", + "version = 1\n\ + [[fact]]\nname = \"twice\"\nreturns = \"opaque\"\n\ + [[fact]]\nname = \"twice\"\nreturns = \"opaque\"\n", + ), + ( + "mint declare refused", + "mint", + "version = 1\n\ + [[mint]]\nname = \"twice\"\ntool = \"Bash\"\nkey = \"branch\"\n\ + mode = \"replace\"\nbody = \"b\"\n\ + [[mint]]\nname = \"twice\"\ntool = \"Bash\"\nkey = \"branch\"\n\ + mode = \"replace\"\nbody = \"b\"\n", + ), + ( + "recorder declare refused", + "recorder", + "version = 1\n\ + [[recorder]]\nname = \"twice\"\nrecord = \"notes.md\"\ntool = \"Bash\"\n\ + key = \"branch\"\ncolumns = []\n\ + [[recorder]]\nname = \"twice\"\nrecord = \"notes.md\"\ntool = \"Bash\"\n\ + key = \"branch\"\ncolumns = []\n", + ), + ( + "provision declare refused", + "provision", + "version = 1\n\ + [[provision]]\nname = \"twice\"\nversion = \"1.0.0\"\nbinary = \"b\"\n\ + [[provision]]\nname = \"twice\"\nversion = \"1.0.0\"\nbinary = \"b\"\n", + ), + // NOT a `Config` table: a remedy is resolved across the redirect, verb and + // rule tables at once, which is why it is a call at the load rather than a + // validator over one field — and why CLOUD-1189 could not declare a class + // for it, which is the case that produced CLOUD-1313. + // + // The glob is unique, so `redirect::validate` passes and the refusal comes + // from the resolver rather than from the table it is written in. + // + // Two things about this remedy are load-bearing and both were got wrong + // before they were read (`redirect::invocation`): + // + // It names a `batten` verb, because the resolver decides invocations of the + // crate's OWN surface and deliberately leaves `mise run …` or `git …` to the + // operator's PATH — a second authority. A fixture naming a mise task exits 0. + // + // And it is written as a CODE SPAN, because the resolver reads backtick + // spans and deliberately under-denies a command named in bare prose. A + // fixture without the backticks also exits 0. Either mistake ships a case + // that asserts a refusal it never reaches. + ( + "remedy resolve missing", + "redirect.mutation", + "version = 1\n\ + [[redirect]]\nglob = \"*.frob\"\nmutation = \"run `batten frobnicate the thing`\"\n", + ), +]; + +fn repo_with(name: &str, config: &str) -> std::path::PathBuf { + Fixture::new(name).config(config).build() +} + +/// The `batten: ` prefix `output::error` writes, then the class, then the prose. +fn refusal_names(stderr: &str, class: &str) -> bool { + stderr.contains(&format!("batten: {class}: ")) +} + +#[test] +fn every_config_fault_names_its_table_s_declared_class() { + for (class, table, config) in FAULTS { + let dir = repo_with(&format!("fault-{table}"), config); + let output = common::run(&dir, &["check"]); + let stderr = String::from_utf8_lossy(&output.stderr); + // Exit 1 and not 2: a config fault is a statement about the invocation, + // never a policy verdict about the repository (house style §7). + assert_eq!( + output.status.code(), + Some(1), + "`[[{table}]]` fault should be a usage error, got: {stderr}" + ); + assert!( + refusal_names(&stderr, class), + "a `[[{table}]]` fault must name `{class}`, got: {stderr}" + ); + } +} + +/// The remedy channel stays open over the config that broke. +/// +/// This is the constraint the row was filed without: `explain` resolves through +/// the config loader, so before CLOUD-1313's first half a class raised BY a +/// malformed config could not be looked up WHILE that config was malformed. +#[test] +fn a_config_fault_s_class_explains_while_the_config_is_still_broken() { + for (class, table, config) in FAULTS { + let dir = repo_with(&format!("explain-over-fault-{table}"), config); + let output = common::run(&dir, &["policy", "explain", class]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + output.status.code(), + Some(0), + "`{class}` must explain over the config that raises it, got: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains(class), + "the explanation must name the class it answers for, got: {stdout}" + ); + } +} + +/// Every class the loader can raise has a fixture that actually reaches it. +/// +/// The reachability clause, and it is the one a reader would otherwise take on +/// trust: a class declared, wrapped and never raised is the dead gate this +/// repository exists to refuse, and it looks identical to a live one from every +/// angle except a case that fires it. `Native::CONFIG_FAULTS` is the authority +/// both this suite and `config.rs`'s census are held to, so a fourteenth table +/// cannot arrive wrapped-but-untested. +#[test] +fn every_class_the_loader_raises_has_a_case_above() { + let mut covered: Vec<&str> = FAULTS.iter().map(|(class, _, _)| *class).collect(); + let mut declared: Vec<&str> = batten::verdict::Native::CONFIG_FAULTS + .iter() + .map(|native| native.id()) + .collect(); + covered.sort_unstable(); + declared.sort_unstable(); + assert_eq!( + covered, declared, + "a config-fault class with no case above is reachable only in principle" + ); +} + +/// The anti-vacuity mirror: a well-formed config still loads and says nothing. +/// +/// Without this the suite above is satisfied by a loader that refuses every +/// config, which would name the right class every time and be useless. +#[test] +fn a_config_with_no_fault_raises_no_class() { + let dir = repo_with("fault-none", "version = 1\n"); + let output = common::run(&dir, &["check"]); + assert_eq!(output.status.code(), Some(0)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("declare refused"), + "a clean config raises no table class, got: {stderr}" + ); +} + +/// A fault the loader cannot attribute to a table still refuses, classless. +/// +/// `None` on `UsageError::verdict` is a decision rather than a gap: a file that +/// will not parse as TOML failed before any validator ran, so naming a table +/// would be inventing an attribution the loader does not have. +#[test] +fn an_unparseable_config_refuses_without_inventing_a_table() { + let dir = repo_with("fault-unparseable", "version = 1\n[[verb]]\nverb = \n"); + let output = common::run(&dir, &["check"]); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("invalid config") && !stderr.contains("declare refused"), + "a parse failure predates every validator, so it names no table: {stderr}" + ); +} + +/// This repository's own committed authority still loads. +/// +/// The other half of the mirror, over the real tree rather than a fixture — +/// thirteen new classes are thirteen new ways to refuse a config that was fine. +#[test] +fn the_committed_authority_still_loads() { + let output = common::run(&common::at_root("."), &["config", "show"]); + assert_eq!( + output.status.code(), + Some(0), + "this repository's own config must still load: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/batten/tests/it/enforce_journal.rs b/crates/batten/tests/it/enforce_journal.rs index 9fba4a7b8..2082d5745 100644 --- a/crates/batten/tests/it/enforce_journal.rs +++ b/crates/batten/tests/it/enforce_journal.rs @@ -203,6 +203,509 @@ fn with_command(check: &str) -> String { ) } +/// A repository whose ONLY violation comes from a `policy/*.rego` module +/// (CLOUD-1220). +/// +/// `no_fix_reason` is deliberately ABSENT from the row: `RuleKind::Policy` +/// requires only `severity`, and a module's remedy is per PREDICATE rather than +/// per row — one row can carry many (CLOUD-832) — so the registry is the only +/// place it can live. A fixture that put a remedy on the row would test a +/// column no real policy row carries and pass over the defect. +fn policy_only(route: &str) -> String { + format!( + "version = 1\n\n\ + [[rule]]\n\ + id = \"probe\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + module = \"policy/probe.rego\"\n\ + severity = \"deny\"\n\n\ + [[verdict]]\n\ + id = \"probe read refused\"\n\ + gloss = \"the probe module refused this tree\"\n\ + class = \"A fixture class, raised only by this suite's probe module.\"\n\n\ + {route}" + ) +} + +/// A module that always refuses, so the case is about the FINDING rather than +/// about a predicate. +const PROBE_MODULE: &str = r#"package batten.probe + +import rego.v1 + +rules contains "probe" + +violation contains { + "rule": "probe", + "verdict": "probe read refused", + "subjects": [{"path": "README.md"}], +} if { + true +} +"#; + +/// **The end-to-end arm CLOUD-1220's §7 names and I skipped**: the `unrecordable` +/// partition reports zero on this repository's own tree. +/// +/// Running `enforce` here by hand is NOT this assertion, and confusing the two is +/// how the row nearly shipped unverified: the committed tree is clean, so zero +/// findings fire and "zero unrecordable" is vacuously true. This drives a tree +/// that DOES produce a policy-module finding and asserts the count is still zero, +/// which is the only form of the claim that discriminates. +#[test] +fn no_finding_is_dropped_as_unrecordable_when_a_module_reports_one() { + let env = Env::new("enforce-journal-none-unrecordable"); + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + &policy_only( + "[[verdict.route]]\n\ + id = \"probe read first\"\n\ + kind = \"document\"\n\ + target = \"README.md\"\n", + ), + ); + let run = env.run(&["enforce"]); + assert_eq!(run.status.code(), Some(2), "{}", common::stderr(&run)); + assert!( + !common::stderr(&run).contains("carry no remediation"), + "the partition reports zero over a tree that actually produces one: {}", + common::stderr(&run) + ); +} + +/// **A module-only finding is BASELINEABLE** — CLOUD-1220's fourth acceptance +/// clause, "asserted rather than assumed", and I had assumed it. +/// +/// Reaching the store is necessary and not sufficient: `baseline.rs` is the +/// persisted set of identities that already existed, and a finding the baseline +/// cannot take is still invisible to every ratchet built on one. The row lists +/// baseline first among what was blind to policy findings, so this is the arm +/// that shows the blindness actually lifted. +#[test] +fn a_module_only_finding_can_be_baselined() { + let env = Env::new("enforce-journal-policy-baseline"); + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + // A TOP-LEVEL KEY, so it goes before the first table header. Appended + // after `policy_only`'s output it landed inside `[[verdict.route]]`, an + // unknown field there, and the config refused with exit 1 — a fixture + // reporting a config fault while claiming to report about a baseline. + &policy_only( + "[[verdict.route]]\n\ + id = \"probe read first\"\n\ + kind = \"document\"\n\ + target = \"README.md\"\n", + ) + .replace( + "version = 1\n", + "version = 1\nmust_land_on = \"refs/remotes/origin/main\"\n", + ), + ); + // COMMITTED FIRST, because `baseline` refuses uncommitted state outright — + // "only landed, committed state may be baselined". That is a precondition of + // the verb rather than anything about policy findings, and a fixture that + // tripped it would report a refusal about the tree while claiming to say + // something about the finding. + git_in(&env.repo, &["add", "-A"]); + git_in(&env.repo, &["commit", "-q", "-m", "the fixture"]); + // AND THE LANDING TARGET, which `Fixture::base_commit` mints and `Env` does + // not. `baseline` refuses a tree it cannot call landed, and it takes THREE + // things to call it that: committed paths, the ref itself, and a declared + // `must_land_on` — without the last, "unlanded" is not-computable rather + // than false, which refuses just as hard. `baseline.rs`'s own fixtures + // declare the same key for the same reason. + // + // All three are preconditions of the VERB and none says anything about + // policy findings; a fixture tripping one would report about the tree while + // claiming to report about the finding. Guessed twice before reading + // `worktree.rs`, which is what settled it. + git_in( + &env.repo, + &["update-ref", "refs/remotes/origin/main", "HEAD"], + ); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + + let baselined = env.run(&["baseline"]); + assert_eq!( + baselined.status.code(), + Some(0), + "the module's finding is baselineable: {}", + common::stderr(&baselined) + ); + // AND THE BASELINE TOOK IT. A `baseline` that exits 0 having recorded + // nothing is the vacuous pass this arm exists to rule out. + let after = env.run(&["enforce"]); + assert_eq!( + after.status.code(), + Some(0), + "a baselined finding no longer fails the run: {}", + common::stderr(&after) + ); +} + +// --- (a3) an answered finding is no longer undischarged (CLOUD-587) ---------- + +/// **Red before this row: no verb could mint a `Disposition` at all.** +/// +/// CLOUD-78 gave every finding the three-valued field, `journal::merge` folds +/// it, `merge_disposition` joins two by precedence and `stop.rs` reads it — +/// undischarged means `disposition == None`. The only writers anywhere were unit +/// tests, so the field was read by a gate, joined by a merge rule, persisted by +/// a journal, and unreachable from any caller. +#[test] +fn a_stored_finding_can_be_answered_and_stops_being_undischarged() { + let env = Env::new("state-settle-answers"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + + let before = env + .record("no-todo") + .expect("the finding reached the store"); + assert!( + before["disposition"].is_null(), + "a fresh finding is undischarged: {before}" + ); + let identity = before["identity"]["fingerprint"] + .as_str() + .expect("a stored finding carries its identity") + .to_owned(); + + let settled = env.run(&["state", "settle", &identity, "acted"]); + assert_eq!( + settled.status.code(), + Some(0), + "recording a disposition is bookkeeping, never a verdict: {}", + common::stderr(&settled) + ); + // POINTER-ONLY (rule 4): the identity and the token, never the finding's + // content — these are drawn from a transcript. + let said = common::stderr(&settled); + assert!(said.contains(&identity) && said.contains("acted"), "{said}"); + assert!( + !said.contains("TODO"), + "the flagged content must not travel: {said}" + ); + + // The merge has to run for the shard to fold into the record, and `enforce` + // is what runs it — the same path a real session takes. + env.run(&["enforce"]); + let after = env.record("no-todo").expect("still stored"); + assert_eq!( + after["disposition"], "acted", + "after answering it is no longer undischarged: {after}" + ); +} + +/// THE DISCRIMINATOR: two worktrees answering one finding differently converge +/// to the same record whichever order the shards merge in. +/// +/// A last-writer-wins implementation passes the single-answer case above and +/// silently loses one answer here. `Disposition` is declared weakest-first so the +/// derived `Ord` IS the precedence and `merge` is `max` — commutative, +/// associative and idempotent — which is what makes this decidable rather than a +/// policy each call site could get subtly wrong. +#[test] +fn two_answers_converge_the_same_way_in_either_order() { + let mut settled = Vec::new(); + for (name, order) in [ + ("state-settle-order-weak-first", ["rejected-wrong", "acted"]), + ( + "state-settle-order-strong-first", + ["acted", "rejected-wrong"], + ), + ] { + let env = Env::new(name); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + env.run(&["enforce"]); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + for disposition in order { + let run = env.run(&["state", "settle", &identity, disposition]); + assert_eq!(run.status.code(), Some(0), "{}", common::stderr(&run)); + } + env.run(&["enforce"]); + settled.push( + env.record("no-todo").expect("stored")["disposition"] + .as_str() + .expect("settled") + .to_owned(), + ); + } + assert_eq!( + settled[0], settled[1], + "the join is commutative, so order cannot change the answer" + ); + assert_eq!( + settled[0], "acted", + "and it is the STRONGER of the two, not the last one written" + ); +} + +/// **`stop.rs` ACTUALLY OBSERVES IT** — CLOUD-587's other §7 clause, which I had +/// only half-covered by asserting the stored record. +/// +/// The row requires that `stop.rs`'s undischarged-denial predicate and CLOUD-79's +/// drain both OBSERVE the field "without either re-typing what settled means". +/// Asserting the record's `disposition` shows the store changed; it does not show +/// the reader changed its answer. `stop::facts` is that reader — `deny-stop` is +/// at-risk work OR an undischarged denial, and undischarged is `disposition == +/// None` — so this drives it directly and watches the pending list empty. +#[test] +fn the_stop_reader_stops_calling_an_answered_finding_pending() { + let env = Env::new("state-settle-stop-observes"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + let store = env.segment(); + let before = batten::stop::facts(None, None, Some(&store)).expect("stop facts"); + assert!( + before.pending.iter().any(|entry| entry.rule == "no-todo"), + "the reader calls an unanswered finding pending: {:?}", + before.pending + ); + + let settled = env.run(&["state", "settle", &identity, "acted"]); + assert_eq!( + settled.status.code(), + Some(0), + "{}", + common::stderr(&settled) + ); + env.run(&["enforce"]); + + let after = batten::stop::facts(None, None, Some(&store)).expect("stop facts"); + assert!( + !after.pending.iter().any(|entry| entry.rule == "no-todo"), + "and stops once it is answered: {:?}", + after.pending + ); +} + +/// **THE DIRECTION A CARELESS FIX BREAKS** (CLOUD-587's §7, and I skipped it). +/// +/// A STATE-anchored finding clears by the condition vanishing — CLOUD-97's is the +/// example, and landing the work clears it with no acknowledgement. Settling one +/// would be a bypass of the work itself rather than an answer to a finding, so a +/// settle must not make the condition-backed finding go away. +/// +/// The distinction is why CLOUD-587 exists at all: the gap is specific to the +/// EVENT-anchored class, where re-evaluation keeps finding an immutable fact. A +/// verb that cleared both would have dissolved that boundary while passing every +/// case above. +#[test] +fn settling_does_not_clear_a_finding_whose_condition_still_holds() { + let env = Env::new("state-settle-state-anchored"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + let settled = env.run(&["state", "settle", &identity, "rejected-by-design"]); + assert_eq!( + settled.status.code(), + Some(0), + "{}", + common::stderr(&settled) + ); + + // THE CONDITION STILL HOLDS, so the finding still fires. A settle records + // what was decided; it does not edit the tree and must not read as though it + // had. + let after = env.run(&["enforce"]); + assert_eq!( + after.status.code(), + Some(2), + "the marker is still in the file, so the finding still fires: {}", + common::stderr(&after) + ); + + // And the honest converse: removing the condition IS what clears it, with no + // acknowledgement needed. + env.file("src/a.rs", "fn main() {}\n"); + assert_eq!( + env.run(&["enforce"]).status.code(), + Some(0), + "a state-anchored finding clears by the condition vanishing" + ); +} + +/// Neither argument may be guessed, and an identity nothing stores is refused +/// rather than appended. +/// +/// `journal::merge` deliberately KEEPS an entry whose record it cannot find, so +/// an append for an unknown identity would succeed, settle nothing, and be +/// invisible forever — a vacuous pass one surface over from where CLOUD-845 +/// found it. +#[test] +fn an_unanswerable_settle_is_refused_rather_than_silently_appended() { + let env = Env::new("state-settle-refusals"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + env.run(&["enforce"]); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + let unknown = env.run(&["state", "settle", &"0".repeat(64), "acted"]); + assert_eq!( + unknown.status.code(), + Some(1), + "an identity nothing stores is a usage error, never a silent append" + ); + + let malformed = env.run(&["state", "settle", "not-a-fingerprint", "acted"]); + assert_eq!(malformed.status.code(), Some(1)); + + let guessed = env.run(&["state", "settle", &identity, "probably-fine"]); + assert_eq!( + guessed.status.code(), + Some(1), + "an undeclared token is refused rather than folded to a default" + ); + let said = common::stderr(&guessed); + assert!( + said.contains("acted") && said.contains("rejected-by-design"), + "the refusal names what IS declared: {said}" + ); + + // AND NONE OF THE THREE MOVED THE RECORD. A refusal that still appended + // would be the defect this case is really about. + env.run(&["enforce"]); + assert!( + env.record("no-todo").expect("stored")["disposition"].is_null(), + "a refused settle leaves the finding undischarged" + ); +} + +// --- (a2) a policy-module finding reaches the store, with its class's remedy --- + +/// **The case CLOUD-1220 was found by, and it was red before the fix.** +/// +/// Measured on `main`: `enforce` printed `2 finding(s) carry no remediation: +/// persisted:false` and both were `kind = "policy"` rows whose `[[verdict]]` +/// tokens declared routes. `policy_rule` took `rule.remediation()` — the ROW's +/// `fix`/`no_fix_reason` — and a policy row carries neither, so every module +/// finding was dropped before `findings::record` and the whole findings +/// subsystem was blind to the one rule kind CLOUD-843's campaign ports onto. +/// +/// Asserting the store rather than the exit code is the whole point: `enforce` +/// exited 2 and printed the class correctly the entire time. What was lost was +/// persistence, so only a store read can see it. +#[test] +fn a_policy_module_finding_reaches_the_store_carrying_its_classs_remedy() { + let env = Env::new("enforce-journal-policy-remedy"); + // BEFORE the config, because `bind_store` writes its own `batten.toml` + // and would otherwise clobber this fixture's — which it did, and the case + // then measured a tree with no policy row at all. + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + &policy_only( + "[[verdict.route]]\n\ + id = \"probe run first\"\n\ + kind = \"command\"\n\ + target = \"mise run probe-fix\"\n", + ), + ); + + let run = env.run(&["enforce"]); + assert_eq!( + run.status.code(), + Some(2), + "the module refuses, which it always did: {}", + common::stderr(&run) + ); + assert!( + !common::stderr(&run).contains("carry no remediation"), + "no finding may be dropped as unrecordable: {}", + common::stderr(&run) + ); + + let found = env + .record("probe") + .expect("the policy module's finding reached the store"); + assert_eq!( + found["remediation"]["fix"], + serde_json::json!(["mise", "run", "probe-fix"]), + "a `command` route becomes the runnable fix, taken from the class rather \ + than from the row: {found}" + ); +} + +/// The other route shape, and it is NOT "no remedy". +/// +/// A document, issue or override route is a remedy a human performs, so it +/// records as a pointer naming the route ids and kinds rather than being dropped. +/// Without this arm, a fix that only handled `command` routes would leave every +/// class whose remedy is a read or an override exactly as broken as before — +/// which is most of this repository's own registry. +#[test] +fn a_class_whose_only_route_is_a_read_still_records_a_remedy() { + let env = Env::new("enforce-journal-policy-read-route"); + // BEFORE the config, because `bind_store` writes its own `batten.toml` + // and would otherwise clobber this fixture's — which it did, and the case + // then measured a tree with no policy row at all. + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + &policy_only( + "[[verdict.route]]\n\ + id = \"probe read first\"\n\ + kind = \"document\"\n\ + target = \"README.md\"\n", + ), + ); + + let run = env.run(&["enforce"]); + assert_eq!(run.status.code(), Some(2), "{}", common::stderr(&run)); + let found = env + .record("probe") + .expect("a class with no command route still records"); + let remedy = found["remediation"]["no-fix"] + .as_str() + .expect("a non-command route records as `no-fix` (serde kebab-case)"); + assert!( + remedy.contains("probe read first") && remedy.contains("document"), + "the remedy names the route id and its kind: {remedy}" + ); + // POINTER-ONLY (rule 4): the route's target is one hop away through + // `policy explain`, and must not be copied into the record. + assert!( + !remedy.contains("README.md"), + "a route's target is not a pointer this record carries: {remedy}" + ); +} + // --- (a) an enforce-only kind reaches the store, idempotently ----------------- #[test] diff --git a/crates/batten/tests/it/extension_surfaces.rs b/crates/batten/tests/it/extension_surfaces.rs index 629ff68b0..03acf9305 100644 --- a/crates/batten/tests/it/extension_surfaces.rs +++ b/crates/batten/tests/it/extension_surfaces.rs @@ -29,7 +29,7 @@ use std::path::PathBuf; use common::{Fixture, StateHome, at_root, batten, scratch}; /// The README section these examples are drawn from. -const SECTION: &str = "## Extending Batten: three surfaces, and which to reach for"; +const SECTION: &str = "## Extending Batten: ten rule kinds, and which to reach for"; fn readme() -> String { fs::read_to_string(at_root("README.md")).expect("read README.md") @@ -341,3 +341,148 @@ fn only_exec_claims_a_channel_carrying_codes_batten_did_not_choose() { "the section must keep the property fail-open rests on: no `2` is minted here" ); } + +// --- the guide is COMPLETE, not merely correct (CLOUD-936) ------------------ +// +// The cases above execute the examples the section carries. These ask whether +// the section carries an example for everything the engine has — a different +// question, and the one that was silently wrong: the heading said "three +// surfaces" over a table of three while `RuleKind` had ten variants, so every +// test here passed over a guide that omitted a whole rule kind and the entire +// preset mechanism. +// +// A CENSUS OVER THE ENUM, NOT A LIST. A list is precisely what went stale, and +// `facts.rs` records the same lesson in its own words: "a list is what was +// already wrong here: an eighth variant would join the enum and go unasserted in +// silence." + +/// The extension section, bounded at the next top-level heading. +/// +/// Bounded so a kind named elsewhere in the README cannot satisfy a claim about +/// the extension GUIDE, which is the surface a consumer is sent to. +fn extension_section() -> String { + let readme = readme(); + let start = readme + .find("## Extending Batten") + .expect("the README carries an extension section"); + let rest = &readme[start..]; + let end = rest[3..] + .find("\n## ") + .map_or(rest.len(), |offset| offset + 3); + rest[..end].to_owned() +} + +/// Every kind has a row in the WHICH-TO-REACH-FOR TABLE, not merely a mention. +/// +/// THE FIRST VERSION OF THIS ASSERTION DID NOT DISCRIMINATE, AND THE PROBE IS +/// WHAT SAID SO. It searched the whole section for `` `policy` ``; deleting the +/// `policy` row from the table left the word in the subsection heading below, so +/// the test passed over the exact defect this row was filed for — a consumer +/// scanning the table to pick a kind would not find it. Measured, not reasoned: +/// the probe went green and the assertion was rewritten. +/// +/// The table is the surface, so the table is what is asserted. +#[test] +fn the_extension_guide_gives_every_rule_kind_a_row_in_the_table() { + let section = extension_section(); + let rows: Vec<&str> = section + .lines() + .filter(|line| line.starts_with('|')) + .collect(); + let missing: Vec<&str> = batten::rules::RuleKind::ALL + .iter() + .map(|kind| kind.as_str()) + .filter(|token| !rows.iter().any(|row| row.contains(&format!("`{token}`")))) + .collect(); + assert!( + missing.is_empty(), + "README.md's which-to-reach-for table has no row for {missing:?} — a shipped \ + rule kind a consumer scanning the table to pick one cannot find" + ); +} + +/// The heading counts the kinds it goes on to list. +/// +/// The heading was the first thing that was wrong and the last thing anyone +/// would check. A heading naming a number is a claim; this holds it to the enum. +#[test] +fn the_extension_headings_count_matches_the_enum() { + let section = extension_section(); + let heading = section.lines().next().expect("the section has a heading"); + let spelled = [ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", + "twelve", + ]; + let expected = spelled + .get(batten::rules::RuleKind::ALL.len() - 1) + .expect("the spelled-out numbers cover the enum"); + assert!( + heading.contains(expected), + "the heading says `{heading}` over {} rule kinds — it should say `{expected}`", + batten::rules::RuleKind::ALL.len() + ); +} + +/// Every shipped preset is named in the config a new repository starts from. +/// +/// CLOUD-836 vendored the presets so a consumer would not author every predicate +/// from scratch. That argument was unrealised while the starter mentioned none of +/// them: the feature shipped and the documented path did not reach it. +#[test] +fn the_starter_config_names_every_shipped_preset() { + for file in ["crates/batten/src/starter.toml", "batten.example.toml"] { + let text = + fs::read_to_string(at_root(file)).unwrap_or_else(|_| panic!("{file} is readable")); + let missing: Vec<&str> = batten::preset::MANIFESTS + .iter() + .map(|manifest| manifest.name) + .filter(|name| !text.contains(name)) + .collect(); + assert!( + missing.is_empty(), + "{file} names no {missing:?} — a preset the binary ships that the config a \ + consumer starts from does not mention" + ); + } +} + +/// And the guide shows how to switch one on, not merely that they exist. +/// +/// Separate from the name census: listing six names satisfies that one while +/// leaving a reader with no idea how to reach any of them. +#[test] +fn the_extension_guide_shows_how_to_enable_a_preset() { + let section = extension_section(); + assert!( + section.contains("preset = \""), + "README.md's extension section shows no `preset =` row, so a reader is told \ + presets exist and not how to reach one" + ); +} + +/// The documented preset row is EXECUTED, like every other example here. +/// +/// This suite's whole discipline: a worked example is a claim about what the +/// binary does. A preset example that does not load would be the documentation +/// defect this row is about, one layer down. +#[test] +fn the_documented_preset_example_loads() { + let (repo, home) = repo_with( + "readme-preset", + "version = 1\n\n\ + [[rule]]\n\ + id = \"trunk-based\"\n\ + kind = \"policy\"\n\ + scope = \"mediated_call\"\n\ + preset = \"trunk-based\"\n\ + severity = \"deny\"\n", + ); + let (code, _, stderr) = run(&repo, &home, &["check"]); + assert_eq!(code, 0, "the documented preset row must load: {stderr}"); + // And the README carries the row this just ran, so deleting the example + // cannot leave this passing. + assert!( + extension_section().contains("preset = \"trunk-based\""), + "the README no longer carries the preset example this case executes" + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index fd82b548e..fd8230725 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -73,6 +73,7 @@ mod config_authority_boundary; mod config_base_ref_reading; mod config_deprecations; mod config_epoch; +mod config_fault_class; mod config_in_directory; mod config_lint; mod config_provenance; @@ -143,6 +144,7 @@ mod policy_tree; mod policy_whole_set; mod pr_watch; mod prebuilt_lint; +mod preset_manifest; mod preset_segments; mod primitives; mod privileged_lane; diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index fe9a3fe28..373c144c2 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -1121,6 +1121,23 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // CLOUD-587. Pointer-only, and here it is load-bearing rather than routine: + // the findings this verb answers are drawn from a session transcript, so the + // content is exactly what must not travel. It emits the identity and the + // disposition token and nothing else. + // + // The args are a real identity's shape and a declared token, because both + // positionals are required — an omitted identity would have to mean "every + // finding" and an omitted disposition would have to guess what was decided. + Verb { + path: "state settle", + args: &[ + "0000000000000000000000000000000000000000000000000000000000000000", + "acted", + ], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { path: "state list", args: &[], diff --git a/crates/batten/tests/it/preset_manifest.rs b/crates/batten/tests/it/preset_manifest.rs new file mode 100644 index 000000000..1433a99a8 --- /dev/null +++ b/crates/batten/tests/it/preset_manifest.rs @@ -0,0 +1,119 @@ +//! End-to-end tests over the compiled binary for the preset manifests +//! (CLOUD-1181). +//! +//! The manifest's own `#[cfg(test)]` tier holds the two registry directions over +//! the tables. These drive the ENGINE, which is what proves a manifest field is +//! read at load rather than merely declared — the distinction +//! `.claude/rules/policy-modules.md` opens on. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use common::Fixture; + +/// Enabling a preset at the wrong scope is refused at load, naming the preset. +/// +/// # What this does and does not claim +/// +/// It does NOT claim to close a silent dead gate. Measured with the check +/// disabled and the binary rebuilt, this same config already failed to load: +/// the module-level input-key check catches `trunk-based` reading `input.call` +/// on the tree surface. What the manifest buys is that the refusal precedes +/// compilation and names the PRESET a consumer enabled, rather than a module +/// inside the binary they never wrote and cannot open. +#[test] +fn a_preset_enabled_at_the_wrong_scope_is_refused_naming_the_preset() { + let root = Fixture::new("preset-wrong-scope") + .config( + "version = 1\n\n\ + [[rule]]\n\ + id = \"wrong-scope\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + sources = [\"**/*.md\"]\n\ + preset = \"trunk-based\"\n\ + severity = \"deny\"\n", + ) + .build(); + let output = common::run(&root, &["check"]); + let stderr = String::from_utf8_lossy(&output.stderr); + // Exit 1: a config that will not load is a statement about the invocation, + // never a verdict about the repository. + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "a scope mismatch is a config fault: {stderr}" + ); + assert!( + stderr.contains("trunk-based") && stderr.contains("mediated_call"), + "the refusal names the preset and the scope its modules decide: {stderr}" + ); +} + +/// The anti-vacuity mirror: the same preset at its own scope loads. +/// +/// Without this the case above is satisfied by a build that refuses every +/// preset, which would name the right one every time and prove nothing. +#[test] +fn the_same_preset_at_its_declared_scope_loads() { + let root = Fixture::new("preset-right-scope") + .config( + "version = 1\n\n\ + [[rule]]\n\ + id = \"right-scope\"\n\ + kind = \"policy\"\n\ + scope = \"mediated_call\"\n\ + preset = \"trunk-based\"\n\ + severity = \"deny\"\n", + ) + .build(); + let output = common::run(&root, &["check"]); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Success.code()), + "the preset must load at the scope its manifest declares: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Every preset a manifest declares can actually be enabled. +/// +/// The reachability arm. A manifest naming a preset the loader cannot resolve +/// would be a declaration with nothing behind it, and `preset_names()` is now +/// derived from these — so the published schema would offer a consumer a name +/// that fails at load. +#[test] +fn every_declared_preset_can_be_enabled_at_its_own_scope() { + for manifest in batten::preset::MANIFESTS { + let root = Fixture::new(&format!("preset-enable-{}", manifest.name)) + .config(&format!( + "version = 1\n\n\ + [[rule]]\n\ + id = \"enable\"\n\ + kind = \"policy\"\n\ + scope = \"{}\"\n\ + {}\ + preset = \"{}\"\n\ + severity = \"deny\"\n", + manifest.scope.as_str(), + if manifest.scope == batten::rules::RuleScope::Tree { + "sources = [\"**/*.md\"]\n" + } else { + "" + }, + manifest.name, + )) + .build(); + let output = common::run(&root, &["check"]); + assert_ne!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "`{}` is declared but cannot be enabled: {}", + manifest.name, + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/batten/tests/it/privileged_lane.rs b/crates/batten/tests/it/privileged_lane.rs index 4b86eff96..6d328e788 100644 --- a/crates/batten/tests/it/privileged_lane.rs +++ b/crates/batten/tests/it/privileged_lane.rs @@ -72,6 +72,22 @@ fn fixture(name: &str, workflow: &str, body: &str) -> PathBuf { "id = \"workflow read first\"\n", "kind = \"document\"\n", "target = \".github/workflows\"\n\n", + // The split's second class (CLOUD-1317). Declaring it is not + // optional here and the registry pushes both ways: the module can + // raise it, so a fixture omitting the row fails to load with an + // undeclared token — and a row nothing raises fails the load too, so + // it cannot be declared anywhere the module does not emit it. + "[[verdict]]\n", + "id = \"lane resolve missing\"\n", + "gloss = \"a job that looks up an outside head holds contents:write and tests no origin\"\n", + "class = \"\"\"\n", + "No trigger carries a head, so the job resolved one through the pulls API and the \\\n", + "test belongs on what the lookup returned.\n", + "\"\"\"\n\n", + "[[verdict.route]]\n", + "id = \"workflow read first\"\n", + "kind = \"document\"\n", + "target = \".github/workflows\"\n\n", "[[verdict]]\n", "id = \"workflow parse broken\"\n", "gloss = \"a workflow could not be parsed, so its lanes were never judged\"\n", @@ -231,3 +247,73 @@ fn a_read_only_lane_is_not_a_subject() { // module's own `test_an_unparseable_workflow_denies_rather_than_passing` is // GREEN, because `with input as` hands itself the populated `missing` the engine // never builds (CLOUD-845). + +// --- the class split, over the compiled binary (CLOUD-1317) ----------------- +// +// THE OBVIOUS ASSERTION DOES NOT WORK, AND FINDING THAT OUT IS THE POINT OF THIS +// TIER. `check`'s line is ` ` and its `--json` finding carries +// `rule`, `path`, `severity`, `report` and `identity` — the verdict class is on +// NEITHER. Two cases asserting the class appears in `check` output fail against a +// correctly split module, which is a test asserting its own premise rather than +// its conclusion (`.claude/rules/rust.md`, CLOUD-249). Measured, not reasoned: +// both were written that way first and both went red for that reason. +// +// What the engine DOES carry through is the registry, and its two directions are +// exactly a statement about how many classes the module raises. So the split is +// asserted where the engine actually decides it. + +/// Both classes declared: the module loads and the lane is denied. +/// +/// The anti-vacuity half. Without it the case below is satisfied by a fixture +/// that refuses every config, which would name the missing class every time and +/// prove nothing. +#[test] +fn a_resolver_lane_is_denied_where_both_classes_are_declared() { + let root = fixture( + "resolve-class", + "auto-bot-land.yml", + "on:\n issue_comment:\n types: [created]\njobs:\n land:\n permissions:\n \ + contents: write\n steps:\n - run: gh api repos/$REPO/pulls?state=open\n", + ); + denied(&root); +} + +/// Dropping the resolve class refuses the LOAD, naming the token nothing declares. +/// +/// This is the split, asserted over the compiled binary: an unsplit module raises +/// one token, so a config declaring only `lane guard missing` would load clean and +/// this case would go green for the wrong reason. It is red on a collapse and red +/// on a rename, which is what a class the reporting surface never prints needs. +/// +/// Exit `1`, not `2`: a config that will not load is a statement about the +/// invocation, never a verdict about the repository. +#[test] +fn dropping_the_resolve_class_refuses_the_load_rather_than_reporting_one_class() { + let root = fixture( + "resolve-class-undeclared", + "auto-bot-land.yml", + "on:\n issue_comment:\n types: [created]\njobs:\n land:\n permissions:\n \ + contents: write\n steps:\n - run: gh api repos/$REPO/pulls?state=open\n", + ); + let config = root.join("batten.toml"); + let text = fs::read_to_string(&config).expect("the fixture authority is readable"); + let without = text + .split("[[verdict]]\n") + .filter(|block| !block.starts_with("id = \"lane resolve missing\"")) + .collect::>() + .join("[[verdict]]\n"); + assert_ne!(without, text, "the fixture must actually declare the class"); + fs::write(&config, without).expect("rewrite the fixture authority"); + + let output = common::run(&root, &["check"]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "an undeclared class is a config fault, not a verdict: {stderr}" + ); + assert!( + stderr.contains("lane resolve missing"), + "the refusal names the token nothing declares: {stderr}" + ); +} diff --git a/crates/batten/tests/it/sinks.rs b/crates/batten/tests/it/sinks.rs index 8c82eedae..cb7314f08 100644 --- a/crates/batten/tests/it/sinks.rs +++ b/crates/batten/tests/it/sinks.rs @@ -818,6 +818,187 @@ fn a_policy_rows_sink_counts_the_violations_its_module_reported() { ); } +/// **CLOUD-1087's third acceptance clause, asserted rather than argued** — +/// `Finding::rule` still carries the predicate id, so waiver matching is +/// byte-identical. +/// +/// This is the arm I first left to "the suite still passes", which is evidence +/// of absence rather than the assertion the row asks for. It matters because the +/// `owner` field's whole design is that `rule` is UNTOUCHED: `waiver::covers` +/// keys on `self.rule != finding.rule`, so a waiver names the GATE a reader saw +/// rather than the bundle holding it (CLOUD-832). Had `owner` been folded into +/// `rule` instead of sitting beside it, every waiver written against a module +/// predicate would have silently stopped matching. +#[test] +fn a_waiver_on_a_predicate_id_still_covers_a_module_finding() { + let module = "package batten\n\ + \n\ + rules contains \"the-predicate\"\n\ + \n\ + violation contains {\n\ + \t\"rule\": \"the-predicate\",\n\ + \t\"verdict\": \"something to say\",\n\ + \t\"subjects\": [{\"path\": \"src/lib.rs\"}],\n\ + }\n"; + let dir = Fixture::new("sink-waiver-names-the-predicate") + .config( + "version = 1\n\ + \n\ + [[rule]]\n\ + id = \"the-policy-row\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + module = \"policy/says.rego\"\n\ + severity = \"deny\"\n\ + \n\ + [[verdict]]\n\ + id = \"something to say\"\n\ + gloss = \"this tree has something to say\"\n\ + class = \"What the fixture asserts, at the length explain answers with.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"nothing to do\"\n\ + kind = \"command\"\n\ + target = \"batten check\"\n\ + \n\ + # THE PREDICATE ID, NOT THE ROW ID. That is the whole assertion.\n\ + [[waiver]]\n\ + rule = \"the-predicate\"\n\ + reason = \"the fixture says so\"\n\ + expires = \"2099-01-01\"\n", + ) + .file("src/lib.rs", "// anything\n") + .file("policy/says.rego", module) + .git() + .base_commit() + .build(); + + let output = run(&dir, &["check"]); + assert_eq!( + output.status.code(), + Some(0), + "a waiver naming the predicate id still covers the module's finding: {}", + stdout(&output) + ); + + // AND IT WAIVED RATHER THAN NEVER FIRING. A fixture whose module reported + // nothing would exit 0 too, which is the vacuous pass this arm must not be. + let unwaived = Fixture::new("sink-waiver-names-the-row") + .config( + &std::fs::read_to_string(dir.join("batten.toml")) + .expect("the fixture config") + .replace("rule = \"the-predicate\"", "rule = \"the-policy-row\""), + ) + .file("src/lib.rs", "// anything\n") + .file("policy/says.rego", module) + .git() + .base_commit() + .build(); + assert_eq!( + run(&unwaived, &["check"]).status.code(), + Some(2), + "a waiver naming the ROW id does not cover it — which is what shows the \ + first fixture's exit 0 was the waiver working, not the module staying quiet" + ); +} + +#[test] +fn a_fingerprint_collision_cannot_move_a_finding_to_another_row() { + // THE ARM #721 DID NOT CARRY (CLOUD-1087), and the reason it is here now. + // + // `a_predicate_named_after_a_row_leaves_that_rows_sink_alone` above covers + // the collision review found FIRST — a predicate id equal to a `Rule::id` — + // which #721 fixed by re-keying `attributed` onto the scope fingerprint. + // Review then raised the SAME objection in a second shape: a `command` row + // builds `scope_fingerprint(&rule.id, glob)` and `policy_rule` builds + // `scope_fingerprint(id, &fingerprint_of(violation))`, so a command row + // whose id equals the predicate id and whose glob equals the verdict token + // mints the identical key. + // + // Two rounds converging on one shape is the finding: the owner was being + // INFERRED from a value that means "this finding's identity" rather than + // "this finding's row". Re-keying a third time would answer the instance. + // The owner is a field now, so there is no key and nothing to collide — + // which is why this case cannot be written as a lookup failure any more, + // only as the property that survives it. + let module = "package batten\n\ + \n\ + rules contains \"colliding-id\"\n\ + \n\ + violation contains {\n\ + \t\"rule\": \"colliding-id\",\n\ + \t\"verdict\": \"something to say\",\n\ + }\n"; + let dir = Fixture::new("sink-fingerprint-collision") + .config( + "version = 1\n\ + \n\ + [[rule]]\n\ + id = \"colliding-id\"\n\ + kind = \"forbid\"\n\ + glob = \"**/*.rs\"\n\ + pattern = \"blessed-by\"\n\ + severity = \"warn\"\n\ + scope = \"tree\"\n\ + no_fix_reason = \"say who decided, not who blessed it\"\n\ + \n\ + [rule.produces]\n\ + kind = \"baseline\"\n\ + key = \"rule\"\n\ + \n\ + [[rule]]\n\ + id = \"the-policy-row\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + module = \"policy/says.rego\"\n\ + severity = \"warn\"\n\ + no_fix_reason = \"nothing to fix; this row exists to record a count\"\n\ + \n\ + [rule.produces]\n\ + kind = \"baseline\"\n\ + key = \"rule\"\n\ + \n\ + [[verdict]]\n\ + id = \"something to say\"\n\ + gloss = \"this tree has something to say\"\n\ + class = \"What the fixture asserts, at the length explain answers with.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"nothing to do\"\n\ + kind = \"command\"\n\ + target = \"batten check\"\n", + ) + .file("src/lib.rs", "// blessed-by the architect\n") + .file("policy/says.rego", module) + .git() + .base_commit() + .build(); + + let output = run(&dir, &["enforce"]); + assert_eq!( + output.status.code(), + Some(0), + "both rows are warn severity: {}", + stdout(&output) + ); + + // EACH ROW COUNTS ITS OWN. The forbid row found one line; the policy row's + // module reported one violation. Neither may claim the other's, whatever + // their fingerprints do. + for row in ["colliding-id", "the-policy-row"] { + let written = + record(&dir, "baseline", row, "rule").unwrap_or_else(|| panic!("{row} recorded")); + let count = written + .split_whitespace() + .next_back() + .unwrap_or_else(|| panic!("a rendered record has three fields: {written:?}")); + assert_eq!( + count, "1", + "{row} counts its own finding and only its own: {written:?}" + ); + } +} + #[test] fn a_predicate_named_after_a_row_leaves_that_rows_sink_alone() { // THE COLLISION ARM (CLOUD-1083, found on review of #721). `attributed` is diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 60816cde2..280772349 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -3,15 +3,19 @@ source: crates/batten/tests/it/snapshots.rs expression: stdout_of(&output) --- { + "spec_version": 1, "path": "batten", + "id": null, "about": "Agent-era completion gate: repo-state conformance checks enforced at the agent's tool call.", "effect": "ask", + "data_channel": false, "flags": [ { "name": "config_from", "short": null, "long": "config-from", "takes_value": true, + "positional": false, "help": "Read the committed config from a git ref (e.g. origin/main) instead of the working tree" }, { @@ -19,6 +23,7 @@ expression: stdout_of(&output) "short": null, "long": "config-in", "takes_value": true, + "positional": false, "help": "Read the committed config from this directory instead of the directory being judged" }, { @@ -26,6 +31,7 @@ expression: stdout_of(&output) "short": null, "long": "debug", "takes_value": false, + "positional": false, "help": "Add resolution detail" }, { @@ -33,6 +39,7 @@ expression: stdout_of(&output) "short": null, "long": "fail-on-warning", "takes_value": false, + "positional": false, "help": "Promote a warn-severity finding to a violation (an override may only turn this on)" }, { @@ -40,6 +47,7 @@ expression: stdout_of(&output) "short": null, "long": "log-level", "takes_value": true, + "positional": false, "help": "Set the verbosity rung by name" }, { @@ -47,6 +55,7 @@ expression: stdout_of(&output) "short": null, "long": "no-color", "takes_value": false, + "positional": false, "help": "Never colour stderr, whatever it is attached to" }, { @@ -54,6 +63,7 @@ expression: stdout_of(&output) "short": null, "long": "no-input", "takes_value": false, + "positional": false, "help": "Never prompt; treat the run as unattended" }, { @@ -61,6 +71,7 @@ expression: stdout_of(&output) "short": "q", "long": "quiet", "takes_value": false, + "positional": false, "help": "Suppress ordinary progress (repeatable: -qq is silent)" }, { @@ -68,6 +79,7 @@ expression: stdout_of(&output) "short": null, "long": "silent", "takes_value": false, + "positional": false, "help": "Say nothing but a verdict or a usage error" }, { @@ -75,6 +87,7 @@ expression: stdout_of(&output) "short": null, "long": "strictness", "takes_value": true, + "positional": false, "help": "Raise how strictly gates apply (an override may only tighten policy)" }, { @@ -82,6 +95,7 @@ expression: stdout_of(&output) "short": null, "long": "trace", "takes_value": false, + "positional": false, "help": "Add everything" }, { @@ -89,6 +103,7 @@ expression: stdout_of(&output) "short": "v", "long": "verbose", "takes_value": false, + "positional": false, "help": "Explain what is being checked (repeatable: -vv is debug)" }, { @@ -96,26 +111,32 @@ expression: stdout_of(&output) "short": "y", "long": "yes", "takes_value": false, + "positional": false, "help": "Confirm a destructive operation that would otherwise refuse" } ], "subcommands": [ { "path": "attribution", + "id": "attribution", "about": "What produced commits may carry about the tooling that made them", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "attribution check", + "id": "attribution.check", "about": "Refuse vendor authorship, branding or session links in commit metadata", "effect": "read", + "data_channel": true, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "Report the attribution capabilities this host declares, and capture at that fidelity" }, { @@ -123,6 +144,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -130,6 +152,7 @@ expression: stdout_of(&output) "short": null, "long": "message", "takes_value": true, + "positional": false, "help": "Judge one pending commit message file, before the commit exists" }, { @@ -137,6 +160,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "Judge every non-merge commit in this range (..)" } ], @@ -144,8 +168,10 @@ expression: stdout_of(&output) }, { "path": "attribution identity", + "id": "attribution.identity", "about": "Set this clone's repo-local git identity when it is unset or denied", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] } @@ -153,14 +179,17 @@ expression: stdout_of(&output) }, { "path": "baseline", + "id": "baseline", "about": "Record the findings that already exist, so only new ones fail", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" }, { @@ -168,6 +197,7 @@ expression: stdout_of(&output) "short": null, "long": "prune", "takes_value": false, + "positional": false, "help": "Drop baseline entries whose finding no longer exists, and ratchet reduced counts down" } ], @@ -175,20 +205,25 @@ expression: stdout_of(&output) }, { "path": "capture", + "id": "capture", "about": "Captured command output: navigate what `exec` already ran, without running it again", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "capture find", + "id": "capture.find", "about": "Resolve a stored tool response by the key it carries, with no handle to look up first", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -196,6 +231,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The key the response must carry, e.g. an issue id" }, { @@ -203,6 +239,7 @@ expression: stdout_of(&output) "short": null, "long": "key-at", "takes_value": true, + "positional": false, "help": "The dotted path the key sits at in the response" }, { @@ -210,6 +247,7 @@ expression: stdout_of(&output) "short": null, "long": "raw", "takes_value": false, + "positional": false, "help": "Write the selected bytes to stdout verbatim, with no decode and no added newline" }, { @@ -217,6 +255,7 @@ expression: stdout_of(&output) "short": null, "long": "tool", "takes_value": true, + "positional": false, "help": "The tool whose response to resolve, matched whole or as a `__`-delimited final segment; repeatable" } ], @@ -224,14 +263,17 @@ expression: stdout_of(&output) }, { "path": "capture list", + "id": "capture.list", "about": "List this repository's captures as handles, in a fixed order", "effect": "read", + "data_channel": true, "flags": [ { "name": "calls", "short": null, "long": "calls", "takes_value": false, + "positional": false, "help": "List recorded calls instead of stored captures, in a byte-stable order" }, { @@ -239,6 +281,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -246,6 +289,7 @@ expression: stdout_of(&output) "short": null, "long": "stream", "takes_value": true, + "positional": false, "help": "Only captures of this stream" } ], @@ -253,14 +297,17 @@ expression: stdout_of(&output) }, { "path": "capture prune", + "id": "capture.prune", "about": "Remove this repository's captures — the one removal path; captures never expire on their own", "effect": "destructive", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -268,14 +315,17 @@ expression: stdout_of(&output) }, { "path": "capture show", + "id": "capture.show", "about": "Print a capture's pointer, or the lines a selection asks for, with no second run", "effect": "read", + "data_channel": true, "flags": [ { "name": "bytes", "short": null, "long": "bytes", "takes_value": true, + "positional": false, "help": "A 0-indexed half-open byte range, `FROM:TO`, either side omittable, clamped to the capture" }, { @@ -283,6 +333,7 @@ expression: stdout_of(&output) "short": null, "long": "grep", "takes_value": true, + "positional": false, "help": "Only lines containing this literal substring" }, { @@ -290,6 +341,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The `:` handle to read" }, { @@ -297,6 +349,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -304,6 +357,7 @@ expression: stdout_of(&output) "short": null, "long": "lines", "takes_value": true, + "positional": false, "help": "A 1-indexed inclusive line range, `FROM:TO`, clamped to the capture" }, { @@ -311,6 +365,7 @@ expression: stdout_of(&output) "short": null, "long": "raw", "takes_value": false, + "positional": false, "help": "Write the selected bytes to stdout verbatim, with no decode and no added newline" } ], @@ -320,14 +375,17 @@ expression: stdout_of(&output) }, { "path": "check", + "id": "check", "about": "Run the applicable read-only gates against the repository", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -335,6 +393,7 @@ expression: stdout_of(&output) "short": null, "long": "rule", "takes_value": true, + "positional": false, "help": "Run only the declared rule with this id" }, { @@ -342,6 +401,7 @@ expression: stdout_of(&output) "short": null, "long": "since", "takes_value": true, + "positional": false, "help": "Judge only the paths changed against this rev" }, { @@ -349,6 +409,7 @@ expression: stdout_of(&output) "short": null, "long": "staged", "takes_value": false, + "positional": false, "help": "Judge only the paths staged in the git index" } ], @@ -356,20 +417,25 @@ expression: stdout_of(&output) }, { "path": "checks", + "id": "checks", "about": "Whether a commit's check runs answer the question a landing depends on", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "checks green", + "id": "checks.green", "about": "Refuse a head whose required checks are red, still running, or not yet registered", "effect": "read", + "data_channel": true, "flags": [ { "name": "absent_ok", "short": null, "long": "absent-ok", "takes_value": true, + "positional": false, "help": "Comma-separated check names for which having no run at all is a legitimate reading" }, { @@ -377,6 +443,7 @@ expression: stdout_of(&output) "short": null, "long": "answered", "takes_value": true, + "positional": false, "help": "Comma-separated conclusions that constitute an answer; anything else is not yet one" }, { @@ -384,6 +451,7 @@ expression: stdout_of(&output) "short": null, "long": "fanin", "takes_value": true, + "positional": false, "help": "The fan-in check whose failure a cancelled sibling can manufacture" }, { @@ -391,6 +459,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -398,6 +467,7 @@ expression: stdout_of(&output) "short": null, "long": "required", "takes_value": true, + "positional": false, "help": "Comma-separated check names that carry a verdict about this repository" } ], @@ -407,20 +477,25 @@ expression: stdout_of(&output) }, { "path": "claim", + "id": "claim", "about": "Whether the issue you are about to pull is actually unclaimed", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "claim check", + "id": "claim.check", "about": "Refuse a pull of an issue somebody is already on, and mint the receipt when it is free", "effect": "write", + "data_channel": true, "flags": [ { "name": "adopt", "short": null, "long": "adopt", "takes_value": false, + "positional": false, "help": "Re-key an orphaned claim receipt onto this branch instead of judging a payload" }, { @@ -428,6 +503,7 @@ expression: stdout_of(&output) "short": null, "long": "adopt-from", "takes_value": true, + "positional": false, "help": "The branch name the receipt being adopted was minted under" }, { @@ -435,6 +511,7 @@ expression: stdout_of(&output) "short": null, "long": "bypass-sequence", "takes_value": false, + "positional": false, "help": "Skip the refinement-sequence rules, recorded in the receipt as a bypass" }, { @@ -442,6 +519,7 @@ expression: stdout_of(&output) "short": null, "long": "issue", "takes_value": true, + "positional": false, "help": "Resolve the payload from the capture store by this issue key instead of reading stdin" }, { @@ -449,6 +527,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -456,6 +535,7 @@ expression: stdout_of(&output) "short": null, "long": "takeover", "takes_value": false, + "positional": false, "help": "Claim over the competitor refusals, recording in the receipt which ones were overridden" } ], @@ -465,20 +545,25 @@ expression: stdout_of(&output) }, { "path": "commit", + "id": "commit", "about": "The shape a commit must take here: what its subject may say", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "commit check", + "id": "commit.check", "about": "Refuse a commit subject that does not follow the configured convention", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -486,6 +571,7 @@ expression: stdout_of(&output) "short": null, "long": "message", "takes_value": true, + "positional": false, "help": "Judge one pending commit message file, before the commit exists" }, { @@ -493,6 +579,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "Judge every non-merge commit in this range (..)" } ], @@ -502,20 +589,25 @@ expression: stdout_of(&output) }, { "path": "config", + "id": "config", "about": "Inspect configuration", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "config deprecations", + "id": "config.deprecations", "about": "Report schema keys removed since a published release with no deprecation window", "effect": "read", + "data_channel": true, "flags": [ { "name": "against", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The git ref whose published schema is the baseline (e.g. v0.0.111)" }, { @@ -523,6 +615,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -530,14 +623,17 @@ expression: stdout_of(&output) }, { "path": "config epoch", + "id": "config.epoch", "about": "Print the content hash of the governing config surface", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -545,6 +641,7 @@ expression: stdout_of(&output) "short": null, "long": "no-cache", "takes_value": false, + "positional": false, "help": "Recompute the epoch from the tracked files' bytes, ignoring the cached value" } ], @@ -552,14 +649,17 @@ expression: stdout_of(&output) }, { "path": "config lint", + "id": "config.lint", "about": "Report policy smells in batten.toml (any smell is a violation)", "effect": "read", + "data_channel": true, "flags": [ { "name": "host_rules", "short": null, "long": "host-rules", "takes_value": true, + "positional": false, "help": "Compare the committed [ci] table against a host ruleset payload (path, or - for stdin)" }, { @@ -567,6 +667,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -574,14 +675,17 @@ expression: stdout_of(&output) }, { "path": "config show", + "id": "config.show", "about": "Print the effective configuration", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -591,20 +695,25 @@ expression: stdout_of(&output) }, { "path": "defects", + "id": "defects", "about": "The append-only defect ledger: the lessons this repository has already paid for", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "defects add", + "id": "defects.add", "about": "Append defect records read as JSONL on stdin", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -612,14 +721,17 @@ expression: stdout_of(&output) }, { "path": "defects query", + "id": "defects.query", "about": "List recorded defects, as pointers", "effect": "read", + "data_channel": true, "flags": [ { "name": "class", "short": null, "long": "class", "takes_value": true, + "positional": false, "help": "Only records in this taxonomy class" }, { @@ -627,6 +739,7 @@ expression: stdout_of(&output) "short": null, "long": "id", "takes_value": true, + "positional": false, "help": "Only the record with this id" }, { @@ -634,6 +747,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -641,6 +755,7 @@ expression: stdout_of(&output) "short": null, "long": "ungated", "takes_value": false, + "positional": false, "help": "Only records no rule or gate discharges yet" } ], @@ -650,20 +765,25 @@ expression: stdout_of(&output) }, { "path": "design", + "id": "design", "about": "Design-evidence claims: the integrity of the record behind a decision", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "design audit", + "id": "design.audit", "about": "Audit a JSONL design-evidence claim stream on stdin for record integrity", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -673,28 +793,34 @@ expression: stdout_of(&output) }, { "path": "doctor", + "id": "doctor", "about": "Diagnose whether Batten can run in this repository", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], "subcommands": [ { "path": "doctor hooks", + "id": "doctor.hooks", "about": "Diagnose whether batten is wired on every hook surface of every harness", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -704,14 +830,17 @@ expression: stdout_of(&output) }, { "path": "enforce", + "id": "enforce", "about": "Run every configured rule, including kinds that execute a configured command", "effect": "unclassified", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -719,14 +848,17 @@ expression: stdout_of(&output) }, { "path": "exec", + "id": "exec", "about": "Run a command — or a `:::` bundle — and report a pointer to what it wrote", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "capture_only", "short": null, "long": "capture-only", "takes_value": false, + "positional": false, "help": "Store the child's streams and report their handles instead of passing the bytes through" }, { @@ -734,6 +866,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The command to run, after `--`, with its own arguments intact" }, { @@ -741,6 +874,7 @@ expression: stdout_of(&output) "short": null, "long": "continue-on-error", "takes_value": false, + "positional": false, "help": "Run the rest of a `:::` bundle after a command fails" }, { @@ -748,6 +882,7 @@ expression: stdout_of(&output) "short": null, "long": "format", "takes_value": true, + "positional": false, "help": "How Batten's own record is encoded (hk's axis)" }, { @@ -755,6 +890,7 @@ expression: stdout_of(&output) "short": null, "long": "jobs", "takes_value": true, + "positional": false, "help": "How many of a `:::` bundle's commands run at once" }, { @@ -762,6 +898,7 @@ expression: stdout_of(&output) "short": null, "long": "style", "takes_value": true, + "positional": false, "help": "How a teed child's bytes are presented, and whose output is suppressed (mise's axis)" }, { @@ -769,6 +906,7 @@ expression: stdout_of(&output) "short": null, "long": "tee", "takes_value": false, + "positional": false, "help": "Copy the child's streams onto Batten's own, as well as capturing them" } ], @@ -776,20 +914,25 @@ expression: stdout_of(&output) }, { "path": "generate", + "id": "generate", "about": "Emit artifacts derived from the command spec, on stdout", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "generate completions", + "id": "generate.completions", "about": "Emit the shell completion script for one shell", "effect": "read", + "data_channel": false, "flags": [ { "name": "shell", "short": null, "long": "shell", "takes_value": true, + "positional": false, "help": "The shell whose completion script to emit" } ], @@ -797,14 +940,17 @@ expression: stdout_of(&output) }, { "path": "generate hooks", + "id": "generate.hooks", "about": "Emit one harness's hook registrations, on stdout", "effect": "read", + "data_channel": false, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "The harness whose hook registrations to emit" } ], @@ -812,14 +958,17 @@ expression: stdout_of(&output) }, { "path": "generate man", + "id": "generate.man", "about": "Emit the roff man page for one command, on stdout", "effect": "read", + "data_channel": false, "flags": [ { "name": "command", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The root-relative command path to document ('config show'); omit for the root page" } ], @@ -827,21 +976,26 @@ expression: stdout_of(&output) }, { "path": "generate markdown", + "id": "generate.markdown", "about": "Emit the whole command surface as one markdown reference, on stdout", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [] }, { "path": "generate schema", + "id": "generate.schema", "about": "Emit the JSON Schema for a config or policy-input surface, derived from the types that define it", "effect": "read", + "data_channel": false, "flags": [ { "name": "surface", "short": null, "long": "surface", "takes_value": true, + "positional": false, "help": "Which surface to describe: the committed authority, the override layer, or a policy-input document" } ], @@ -851,14 +1005,17 @@ expression: stdout_of(&output) }, { "path": "hook", + "id": "hook", "about": "Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "The harness whose payload to decode and whose decision channel to answer in" } ], @@ -866,14 +1023,17 @@ expression: stdout_of(&output) }, { "path": "init", + "id": "init", "about": "Write a starter batten.toml, refusing to overwrite an existing one", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -881,20 +1041,25 @@ expression: stdout_of(&output) }, { "path": "lint", + "id": "lint", "about": "Lint an artifact against a declared schema", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "lint brief", + "id": "lint.brief", "about": "Check a delegation brief against the handoff schema (any missing section is a violation)", "effect": "read", + "data_channel": true, "flags": [ { "name": "brief", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The brief to read; omitted or `-` reads stdin" }, { @@ -902,6 +1067,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -911,20 +1077,25 @@ expression: stdout_of(&output) }, { "path": "mcp", + "id": "mcp", "about": "Dispatch a declared MCP call and hand back a reduction instead of the payload", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "mcp call", + "id": "mcp.call", "about": "Dispatch one declared method, store the response, and print the declared reduction", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "method", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The method to call" }, { @@ -932,6 +1103,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The method's arguments, as a JSON object; omitted is `{}`" }, { @@ -939,6 +1111,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The server to dispatch to, as a `[[mcp.source]]` names it" } ], @@ -948,21 +1121,27 @@ expression: stdout_of(&output) }, { "path": "mutate", + "id": "mutate", "about": "Decide whether this repository's gates discriminate, rather than merely parse", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [ { "path": "mutate census", + "id": "mutate.census", "about": "Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [] }, { "path": "mutate sweep", + "id": "mutate.sweep", "about": "Apply every declared mutation to its source and report the ones its declared suite did not catch", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] } @@ -970,20 +1149,25 @@ expression: stdout_of(&output) }, { "path": "override", + "id": "override", "about": "Issued admissions: an override is a record, never a variable somebody knows", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "override request", + "id": "override.request", "about": "Answer a class's declared precondition and receive an admission for one situation", "effect": "write", + "data_channel": false, "flags": [ { "name": "rule", "short": null, "long": "rule", "takes_value": true, + "positional": false, "help": "The rule whose refusal is being overridden" }, { @@ -991,6 +1175,7 @@ expression: stdout_of(&output) "short": null, "long": "subject", "takes_value": true, + "positional": false, "help": "The gate's canonical subject, exactly as its refusal names it" }, { @@ -998,6 +1183,7 @@ expression: stdout_of(&output) "short": null, "long": "verdict", "takes_value": true, + "positional": false, "help": "The verdict token that refusal carries, e.g. diff ship early" } ], @@ -1005,14 +1191,17 @@ expression: stdout_of(&output) }, { "path": "override spend", + "id": "override.spend", "about": "Spend an issued admission against the situation it was issued for", "effect": "write", + "data_channel": false, "flags": [ { "name": "admission", "short": null, "long": "admission", "takes_value": true, + "positional": false, "help": "The admission address to spend" }, { @@ -1020,6 +1209,7 @@ expression: stdout_of(&output) "short": null, "long": "rule", "takes_value": true, + "positional": false, "help": "The rule whose refusal is being overridden" }, { @@ -1027,6 +1217,7 @@ expression: stdout_of(&output) "short": null, "long": "subject", "takes_value": true, + "positional": false, "help": "The gate's canonical subject, exactly as its refusal names it" }, { @@ -1034,6 +1225,7 @@ expression: stdout_of(&output) "short": null, "long": "verdict", "takes_value": true, + "positional": false, "help": "The verdict token that refusal carries, e.g. diff ship early" } ], @@ -1043,20 +1235,25 @@ expression: stdout_of(&output) }, { "path": "payload", + "id": "payload", "about": "Read a hook payload from stdin", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "payload field", + "id": "payload.field", "about": "Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq", "effect": "read", + "data_channel": false, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "The harness whose payload dialect to decode" }, { @@ -1064,6 +1261,7 @@ expression: stdout_of(&output) "short": null, "long": "name", "takes_value": true, + "positional": false, "help": "Which payload field to print; an allowlist, never a JSON path" } ], @@ -1073,20 +1271,25 @@ expression: stdout_of(&output) }, { "path": "perf", + "id": "perf", "about": "Measure this repository's own invocation cost", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [ { "path": "perf pair", + "id": "perf.pair", "about": "Measure this branch and its merge base back to back on one machine, and print both arms as paired records", "effect": "write", + "data_channel": false, "flags": [ { "name": "null", "short": null, "long": "null", "takes_value": false, + "positional": false, "help": "Measure HEAD against itself, so the ratio is the noise floor rather than a comparison" } ], @@ -1096,20 +1299,25 @@ expression: stdout_of(&output) }, { "path": "policy", + "id": "policy", "about": "Inspect the thresholds and path sets this repository holds itself to", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "policy budget", + "id": "policy.budget", "about": "Judge the always-loaded instruction set against its declared token budget", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1117,14 +1325,17 @@ expression: stdout_of(&output) }, { "path": "policy explain", + "id": "policy.explain", "about": "Resolve a verdict token to its class definition and the routes out of it", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -1132,6 +1343,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The verdict token to resolve, e.g. task name undefined" } ], @@ -1139,14 +1351,17 @@ expression: stdout_of(&output) }, { "path": "policy hooks", + "id": "policy.hooks", "about": "Judge this session's hook output against its declared per-session budget", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1154,14 +1369,17 @@ expression: stdout_of(&output) }, { "path": "policy test", + "id": "policy.test", "about": "Run each registered module's own `test_` rules and report the predicates none exercised", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1169,14 +1387,17 @@ expression: stdout_of(&output) }, { "path": "policy tools", + "id": "policy.tools", "about": "Print the tool names the mediated-call rows decide, one per line", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1186,20 +1407,25 @@ expression: stdout_of(&output) }, { "path": "pr", + "id": "pr", "about": "The pull request a landing drives, and the answers it waits on", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "pr watch", + "id": "pr.watch", "about": "Poll a head's check runs until the required set answers, then report the verdict", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "absent_ok", "short": null, "long": "absent-ok", "takes_value": true, + "positional": false, "help": "Comma-separated check names for which having no run at all is a legitimate reading" }, { @@ -1207,6 +1433,7 @@ expression: stdout_of(&output) "short": null, "long": "answered", "takes_value": true, + "positional": false, "help": "Comma-separated conclusions that constitute an answer; anything else is not yet one" }, { @@ -1214,6 +1441,7 @@ expression: stdout_of(&output) "short": null, "long": "fanin", "takes_value": true, + "positional": false, "help": "The fan-in check whose failure a cancelled sibling can manufacture" }, { @@ -1221,6 +1449,7 @@ expression: stdout_of(&output) "short": null, "long": "interval", "takes_value": true, + "positional": false, "help": "Seconds between requests; a server-requested floor raises it and nothing lowers it" }, { @@ -1228,6 +1457,7 @@ expression: stdout_of(&output) "short": null, "long": "progress", "takes_value": true, + "positional": false, "help": "Program to record the poll's tick and reading-change signals" }, { @@ -1235,6 +1465,7 @@ expression: stdout_of(&output) "short": null, "long": "progress-id", "takes_value": true, + "positional": false, "help": "The identity the progress recorder keys its entries on" }, { @@ -1242,6 +1473,7 @@ expression: stdout_of(&output) "short": null, "long": "repo", "takes_value": true, + "positional": false, "help": "The repository to read, in the forge client's own spelling" }, { @@ -1249,6 +1481,7 @@ expression: stdout_of(&output) "short": null, "long": "required", "takes_value": true, + "positional": false, "help": "Comma-separated check names that carry a verdict about this repository" }, { @@ -1256,6 +1489,7 @@ expression: stdout_of(&output) "short": null, "long": "sha", "takes_value": true, + "positional": false, "help": "The commit whose check runs to read" } ], @@ -1265,20 +1499,25 @@ expression: stdout_of(&output) }, { "path": "provision", + "id": "provision", "about": "Pinned tools this repository provisions, cached out of tree", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "provision apply", + "id": "provision.apply", "about": "Fetch, verify against the pinned checksum, and install into the out-of-tree cache", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -1286,14 +1525,17 @@ expression: stdout_of(&output) }, { "path": "provision status", + "id": "provision.status", "about": "Report which provisioned tools do not match the manifest", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1303,20 +1545,25 @@ expression: stdout_of(&output) }, { "path": "ready", + "id": "ready", "about": "Whether an issue's Ready block satisfies the checkable clauses of the gate", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "ready lint", + "id": "ready.lint", "about": "Refuse an issue whose Ready block fails a checkable clause of the Definition of Ready", "effect": "read", + "data_channel": true, "flags": [ { "name": "issue", "short": null, "long": "issue", "takes_value": true, + "positional": false, "help": "Resolve the payload from the capture store by this issue key instead of reading stdin" }, { @@ -1324,6 +1571,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1333,20 +1581,25 @@ expression: stdout_of(&output) }, { "path": "receipt", + "id": "receipt", "about": "Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "receipt record", + "id": "receipt.record", "about": "Record that the named check concluded pass against the current HEAD", "effect": "write", + "data_channel": false, "flags": [ { "name": "check", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The check whose conclusion is being recorded" } ], @@ -1354,14 +1607,17 @@ expression: stdout_of(&output) }, { "path": "receipt status", + "id": "receipt.status", "about": "Judge the named check's recorded receipt against HEAD and origin/main", "effect": "read", + "data_channel": true, "flags": [ { "name": "check", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The check whose receipt is judged" }, { @@ -1369,6 +1625,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -1376,6 +1633,7 @@ expression: stdout_of(&output) "short": null, "long": "key", "takes_value": true, + "positional": false, "help": "Which git fact the receipt is judged against: the exact commit, or the branch" } ], @@ -1385,20 +1643,25 @@ expression: stdout_of(&output) }, { "path": "record", + "id": "record", "about": "Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "record forge", + "id": "record.forge", "about": "Record the forge's check verdicts for one commit, read as ` ` lines on stdin", "effect": "write", + "data_channel": false, "flags": [ { "name": "ref", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The ref or sha the verdict was taken against" } ], @@ -1406,14 +1669,17 @@ expression: stdout_of(&output) }, { "path": "record tool", + "id": "record.tool", "about": "Record a declared tool row's verdict, read as ` ` lines on stdin", "effect": "write", + "data_channel": false, "flags": [ { "name": "id", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The `[[rule.tools]]` id whose verdict is being recorded" } ], @@ -1423,20 +1689,25 @@ expression: stdout_of(&output) }, { "path": "semver", + "id": "semver", "about": "Whether this branch's API delta is compatible with the bump it claims", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "semver check", + "id": "semver.check", "about": "Refuse an API break this branch's commits do not declare", "effect": "write", + "data_channel": false, "flags": [ { "name": "baseline", "short": null, "long": "baseline", "takes_value": true, + "positional": false, "help": "The rev to measure the API delta against (default: origin/main)" }, { @@ -1444,6 +1715,7 @@ expression: stdout_of(&output) "short": null, "long": "package", "takes_value": true, + "positional": false, "help": "The package whose public API is compared (default: batten)" }, { @@ -1451,6 +1723,7 @@ expression: stdout_of(&output) "short": null, "long": "release-type", "takes_value": true, + "positional": false, "help": "The bump being claimed, which is what the delta is judged against" } ], @@ -1460,14 +1733,17 @@ expression: stdout_of(&output) }, { "path": "spec", + "id": "spec", "about": "Print the tool's own command spec", "effect": "read", + "data_channel": false, "flags": [ { "name": "format", "short": null, "long": "format", "takes_value": true, + "positional": false, "help": "The output format for the spec" } ], @@ -1475,20 +1751,25 @@ expression: stdout_of(&output) }, { "path": "state", + "id": "state", "about": "The out-of-tree findings store: which store belongs to this checkout", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "state adopt", + "id": "state.adopt", "about": "Bind this checkout to its findings store, minting one only if none exists", "effect": "write", + "data_channel": false, "flags": [ { "name": "store", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The store id to bind, when resolution cannot decide for itself" } ], @@ -1496,14 +1777,17 @@ expression: stdout_of(&output) }, { "path": "state list", + "id": "state.list", "about": "List stored findings and the refs they were observed in", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1511,36 +1795,71 @@ expression: stdout_of(&output) }, { "path": "state migrate", + "id": "state.migrate", "about": "Upgrade the findings store to this binary's record version", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] }, { "path": "state record", + "id": "state.record", "about": "Record this ref's findings into the store, and GC instances whose ref is gone", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] + }, + { + "path": "state settle", + "id": "state.settle", + "about": "Record what was decided about a stored finding", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "disposition", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "What was decided: acted, rejected-by-design or rejected-wrong" + }, + { + "name": "identity", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The stored finding's identity, as `state list` prints it" + } + ], + "subcommands": [] } ] }, { "path": "target", + "id": "target", "about": "Inspect and reclaim this repository's build tree", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "target prune", + "id": "target.prune", "about": "Reclaim superseded build artifacts, and refuse below the measured disk floor for the build the next lap will run", "effect": "destructive", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" }, { @@ -1548,6 +1867,7 @@ expression: stdout_of(&output) "short": null, "long": "root", "takes_value": true, + "positional": false, "help": "The build directory to prune, instead of the configured one" } ], @@ -1557,20 +1877,25 @@ expression: stdout_of(&output) }, { "path": "wiring", + "id": "wiring", "about": "Repair a host's hook registrations", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "wiring reclaim", + "id": "wiring.reclaim", "about": "Remove non-batten hook registrations from this host's merged surfaces", "effect": "destructive", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -1580,20 +1905,25 @@ expression: stdout_of(&output) }, { "path": "worktree", + "id": "worktree", "about": "Worktrees and the work in them: what is at risk", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "worktree status", + "id": "worktree.status", "about": "Report work that is uncommitted, unpushed, or not landed on the configured target", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1603,45 +1933,165 @@ expression: stdout_of(&output) } ], "read_only_allowlist": [ - "attribution check", - "capture find", - "capture list", - "capture show", - "check", - "checks green", - "commit", - "commit check", - "config", - "config deprecations", - "config epoch", - "config lint", - "config show", - "defects query", - "design audit", - "doctor", - "doctor hooks", - "generate", - "generate completions", - "generate hooks", - "generate man", - "generate markdown", - "generate schema", - "lint", - "lint brief", - "mutate census", - "payload", - "payload field", - "policy", - "policy budget", - "policy explain", - "policy hooks", - "policy test", - "policy tools", - "provision status", - "ready lint", - "receipt status", - "spec", - "state list", - "worktree status" + { + "id": "attribution.check", + "path": "attribution check" + }, + { + "id": "capture.find", + "path": "capture find" + }, + { + "id": "capture.list", + "path": "capture list" + }, + { + "id": "capture.show", + "path": "capture show" + }, + { + "id": "check", + "path": "check" + }, + { + "id": "checks.green", + "path": "checks green" + }, + { + "id": "commit", + "path": "commit" + }, + { + "id": "commit.check", + "path": "commit check" + }, + { + "id": "config", + "path": "config" + }, + { + "id": "config.deprecations", + "path": "config deprecations" + }, + { + "id": "config.epoch", + "path": "config epoch" + }, + { + "id": "config.lint", + "path": "config lint" + }, + { + "id": "config.show", + "path": "config show" + }, + { + "id": "defects.query", + "path": "defects query" + }, + { + "id": "design.audit", + "path": "design audit" + }, + { + "id": "doctor", + "path": "doctor" + }, + { + "id": "doctor.hooks", + "path": "doctor hooks" + }, + { + "id": "generate", + "path": "generate" + }, + { + "id": "generate.completions", + "path": "generate completions" + }, + { + "id": "generate.hooks", + "path": "generate hooks" + }, + { + "id": "generate.man", + "path": "generate man" + }, + { + "id": "generate.markdown", + "path": "generate markdown" + }, + { + "id": "generate.schema", + "path": "generate schema" + }, + { + "id": "lint", + "path": "lint" + }, + { + "id": "lint.brief", + "path": "lint brief" + }, + { + "id": "mutate.census", + "path": "mutate census" + }, + { + "id": "payload", + "path": "payload" + }, + { + "id": "payload.field", + "path": "payload field" + }, + { + "id": "policy", + "path": "policy" + }, + { + "id": "policy.budget", + "path": "policy budget" + }, + { + "id": "policy.explain", + "path": "policy explain" + }, + { + "id": "policy.hooks", + "path": "policy hooks" + }, + { + "id": "policy.test", + "path": "policy test" + }, + { + "id": "policy.tools", + "path": "policy tools" + }, + { + "id": "provision.status", + "path": "provision status" + }, + { + "id": "ready.lint", + "path": "ready lint" + }, + { + "id": "receipt.status", + "path": "receipt status" + }, + { + "id": "spec", + "path": "spec" + }, + { + "id": "state.list", + "path": "state list" + }, + { + "id": "worktree.status", + "path": "worktree status" + } ] } diff --git a/crates/batten/tests/it/verdict_registry.rs b/crates/batten/tests/it/verdict_registry.rs index 940feeaf4..e5b86eb71 100644 --- a/crates/batten/tests/it/verdict_registry.rs +++ b/crates/batten/tests/it/verdict_registry.rs @@ -500,3 +500,109 @@ fn a_command_route_naming_a_defined_task_is_clean_over_the_engine() { .is_empty() ); } + +/// **Every `Native` class is actually RAISED somewhere in production code** +/// (CLOUD-1313). +/// +/// # The hole this closes +/// +/// The registry's two directions make it honest for MODULE classes: +/// `check_verdicts_are_declared` refuses a raised token no row declares, and +/// `check_registry_is_exhausted` refuses a declared row nothing raises. But the +/// second one *exempts* `native_tokens()` rather than proving them — +/// deliberately, since a native class is raised from Rust and there is no AST to +/// read. So a `Native` variant could be declared, carry a `VENDORED` row with a +/// gloss and routes, resolve through `policy explain`, and be raised by nothing +/// at all. Every one of those signals says the class is live; none of them +/// checks it. +/// +/// `every_native_class_is_listed` does not close this. It matches without a +/// wildcard, so a new variant fails to compile until it is LISTED — which is a +/// statement about the table, not about any call site. +/// +/// # Measured before writing this: the existing twenty are clean +/// +/// All 20 variants declared at the time were raised in production code, so this +/// is a ratchet over the classes CLOUD-1313 adds rather than a repair of +/// something already broken. Saying which it is matters: a gate introduced +/// alongside a finding reads as having caught one. +/// +/// # Why a source scan rather than name resolution +/// +/// `.claude/rules/scanning.md` routes "which type does this name resolve to" to +/// rust-analyzer, and `Native::Foo` is not that question — it is "does this +/// token appear in a raising position", which is a text question about a closed, +/// unambiguous spelling. There is exactly one `Native` type in this crate and no +/// import can alias a variant path, which is what makes the scan sound here where +/// `spawn_census`'s `Command::new` scan was not. +#[test] +fn every_native_class_is_raised_by_production_code() { + let verdict_rs = common::at_root("crates/batten/src/verdict.rs"); + let declared = declared_native_variants(&verdict_rs); + assert!( + declared.len() >= 20, + "the scan found {} variants, too few to be the enum", + declared.len() + ); + + let mut raised: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for path in common::rust_sources() { + // `verdict.rs` DECLARES them; naming a variant there is not raising it, + // and counting the enum and `id()` as call sites is what would make this + // gate vacuous. + if path.ends_with("verdict.rs") || !path.starts_with(common::at_root("crates/batten/src")) { + continue; + } + let body = std::fs::read_to_string(&path).expect("a source file"); + // A `#[cfg(test)]` module is not production code. A class raised only by + // its own unit test is exactly the dead class this asserts against. + let production = body + .find("#[cfg(test)]") + .map_or(&body[..], |at| &body[..at]); + for found in production.match_indices("Native::") { + let rest = &production[found.0 + "Native::".len()..]; + let name: String = rest + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + if !name.is_empty() { + raised.insert(name); + } + } + } + + let dead: Vec<&String> = declared + .iter() + .filter(|name| !raised.contains(*name)) + .collect(); + assert!( + dead.is_empty(), + "these classes are declared and raised by nothing outside tests, so they \ + resolve through `policy explain` and can never fire: {dead:?}" + ); +} + +/// The variants `Native::ALL` lists, read off the const rather than re-typed. +/// +/// Re-typing the list here would be a second authority on it, and the two would +/// drift in exactly the direction that makes the assertion above pass +/// vacuously — a name missing from this copy is a class the gate stops checking. +fn declared_native_variants(verdict_rs: &std::path::Path) -> Vec { + let src = std::fs::read_to_string(verdict_rs).expect("verdict.rs is committed"); + let at = src + .find("pub const ALL") + .expect("`Native::ALL` is declared"); + let body = &src[at..src[at..].find("];").expect("the const terminates") + at]; + let mut found = Vec::new(); + for hit in body.match_indices("Native::") { + let rest = &body[hit.0 + "Native::".len()..]; + let name: String = rest + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + if !name.is_empty() { + found.push(name); + } + } + found +} diff --git a/crates/batten/tests/it/verdict_vocabulary.rs b/crates/batten/tests/it/verdict_vocabulary.rs index 9f1da527a..32c13931d 100644 --- a/crates/batten/tests/it/verdict_vocabulary.rs +++ b/crates/batten/tests/it/verdict_vocabulary.rs @@ -62,6 +62,7 @@ const CANDIDATES: &[&str] = &[ "edit", "empty", "event", + "fact", "file", "first", "forge", @@ -85,6 +86,7 @@ const CANDIDATES: &[&str] = &[ "lock", "loose", "manifest", + "marker", "measure", "memory", "mint", @@ -94,20 +96,25 @@ const CANDIDATES: &[&str] = &[ "never", "open", "other", + "output", "own", "parse", "partial", "patch", "path", + "pattern", "pin", "place", "point", "port", "program", "prose", + "provision", "reach", "read", + "recorder", "red", + "redirect", "refused", "release", "remedy", @@ -150,7 +157,10 @@ const CANDIDATES: &[&str] = &[ "unsafe", "unseen", "unused", + "verb", + "verdict", "version", + "waiver", "watch", "wire", "workflow", diff --git a/man/batten-state-settle.1 b/man/batten-state-settle.1 new file mode 100644 index 000000000..12ec81d20 --- /dev/null +++ b/man/batten-state-settle.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-state-settle 1 batten +.SH NAME +batten\-state\-settle \- Record what was decided about a stored finding +.SH SYNOPSIS +\fBbatten state settle\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIidentity\fR> <\fIdisposition\fR> +.SH DESCRIPTION +Record what was decided about a stored finding +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIidentity\fR> +The stored finding\*(Aqs identity, as `state list` prints it +.TP +<\fIdisposition\fR> +What was decided: acted, rejected\-by\-design or rejected\-wrong diff --git a/man/batten-state.1 b/man/batten-state.1 index b7f9a3e56..5adafde73 100644 --- a/man/batten-state.1 +++ b/man/batten-state.1 @@ -22,6 +22,9 @@ Record this ref\*(Aqs findings into the store, and GC instances whose ref is gon batten\-state\-migrate(1) Upgrade the findings store to this binary\*(Aqs record version .TP +batten\-state\-settle(1) +Record what was decided about a stored finding +.TP batten\-state\-list(1) List stored findings and the refs they were observed in .TP diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 4564942fd..647801e24 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -77,6 +77,13 @@ declared_modules := { # is the binary entry point rather than a `pub mod` of the library, and it is # declared rather than excluded: it is a file in the judged set, and a # selector carve-out would be an exemption where a placement is honest. + # `preset` arrived with CLOUD-1181 and this rule named it before a human did + # — the same property the entries above record, working again. It sits BELOW + # `policy` and `verdict`: both read it, and it reads neither. That direction is + # the manifest's whole point — a preset's identity, scope, modules and + # vocabulary are one declaration that the two consumers project, rather than + # three tables that had to know about each other. + "preset", "brief", "main", "selfwrite", # `patch` arrived with CLOUD-739 and this rule named it before a human did — # the same property the three above record, working a second time. `symbols` diff --git a/policy/privileged-lane.rego b/policy/privileged-lane.rego index 365182bbd..831622a58 100644 --- a/policy/privileged-lane.rego +++ b/policy/privileged-lane.rego @@ -39,25 +39,29 @@ import rego.v1 # the compiled binary over a real tree, so there is now a named case a mutation # can turn red, and this gate joins $MUTANT_GATES. # -# THE THIRD CONJUNCT IS THE ONE WORTH CORRUPTING, and choosing an input for it -# took two attempts — which is the whole value of declaring a mutation rather than -# assuming one. A mutation over the trigger list proves little: both spellings deny -# the lanes that matter, so it would pass under the corruption. +# THE HEAD-RESOLUTION CONJUNCT IS THE ONE WORTH CORRUPTING, and choosing an input +# for it took two attempts — which is the whole value of declaring a mutation +# rather than assuming one. A mutation over the trigger list proves little: both +# spellings deny the lanes that matter, so it would pass under the corruption. # # THE FIRST CHOICE SURVIVED, AND THAT IS HOW THIS PARAGRAPH GOT CORRECTED. It -# named `perf.yml` as what the third conjunct keeps out of the subject set. It is -# not: `perf.yml` triggers only on `schedule` and `workflow_dispatch`, neither of -# which is in `outsider_reachable`, so the FIRST conjunct already excludes it and -# dropping the third changes nothing about it. A mutation over a conjunct that +# named `perf.yml` as what the conjunct keeps out of the subject set. It is not: +# `perf.yml` triggers only on `schedule` and `workflow_dispatch`, neither of which +# is in `outsider_reachable`, so the FIRST conjunct already excludes it and +# dropping this one changes nothing about it. A mutation over a conjunct that # another conjunct already excludes cannot discriminate, and surviving is the only # way that gets found. # # What discriminates is an outsider-reachable writer that resolves no outside # head: `issue_comment` plus `contents: write`, with no `pull_request` or # `workflow_run` trigger and no `/pulls` reference anywhere. Clean today, a -# finding the moment the third conjunct stops being asked. -# `tests/privileged-lane.bats` carries that input under the name below. -#MUTANT third-conjunct-dropped|s@^\tselects_outside_head(doc, body)$@\ttrue@|an_outsider_reachable_writer_that_resolves_no_outside_head_is_not_a_subject +# finding the moment `resolves_head` stops being asked. +# +# The row moved with the split (CLOUD-1317): the conjunct it corrupts is now +# `resolves_head(body)` inside the `lane resolve missing` arm, which is where that +# question is asked. The named case is unchanged, because the input that +# discriminates it is unchanged. +#MUTANT resolution-conjunct-dropped|s@^\tresolves_head(body)$@\ttrue@|an_outsider_reachable_writer_that_resolves_no_outside_head_is_not_a_subject #MUTANT-SUITE crates/batten/tests/it/privileged_lane.rs rules contains "privileged-lane-tests-origin" @@ -76,7 +80,11 @@ violation contains { is_workflow(path) } -# The finding itself: a subject job that never mentions the head's origin. +# The finding, in two classes, because the two have DIFFERENT REMEDIES +# (CLOUD-1317). The subject set is unchanged and so is the finding count; what +# changed is that the refusal now says which field its reader must test. +# +# Arm one: the EVENT carries the head, so the test is on the event. violation contains { "rule": "privileged-lane-tests-origin", "verdict": "lane guard missing", @@ -85,7 +93,32 @@ violation contains { some path, doc in input.tree.documents is_workflow(path) some job, body in doc.jobs - is_subject(doc, body) + outsider_reachable(doc) + grants_write(doc, body) + trigger_carries_head(doc) + not tests_origin(body) +} + +# Arm two: no trigger carries a head, so the job LOOKED ONE UP, and the test +# belongs on what the lookup returned. +# +# `not trigger_carries_head(doc)` is what keeps the arms disjoint. Without it a +# `workflow_run` job that also calls `/pulls` would raise BOTH classes for one +# job, doubling the finding count over a tree nothing changed — and the reader +# would get two remedies for one fix. Arm one wins that overlap deliberately: its +# remedy is the earlier of the two, applied before the lookup happens at all. +violation contains { + "rule": "privileged-lane-tests-origin", + "verdict": "lane resolve missing", + "subjects": [{"path": path}, {"artifact": job}], +} if { + some path, doc in input.tree.documents + is_workflow(path) + some job, body in doc.jobs + outsider_reachable(doc) + grants_write(doc, body) + not trigger_carries_head(doc) + resolves_head(body) not tests_origin(body) } @@ -98,6 +131,12 @@ is_workflow(path) if { # author can influence. A gate whose first firing is a false positive gets an # exception written for it, and the exception is what rots. # +# THE THIRD IS ALSO WHERE THE CLASS SPLIT LIVES (CLOUD-1317), which is why it is +# no longer wrapped in an `is_subject` helper. `selects_outside_head` was a +# disjunction over two arms with two different remedies, so collapsing them into +# one predicate was exactly what made the refusal unable to say which field to +# test. The two arms are named separately below and each `violation` asks for one. +# # THIS PARAGRAPH USED TO NAME `perf.yml` AS WHAT THE THIRD CONJUNCT SPARES, AND # THAT WAS WRONG (CLOUD-931). Measured: `perf.yml` triggers on `schedule` and # `workflow_dispatch`, and neither is in `outsider_reachable`'s list below — so it @@ -105,23 +144,14 @@ is_workflow(path) if { # it. The declared mutation above SURVIVED against exactly that reading, which is # how the error was found rather than inherited by the next lane. # -# The input that actually discriminates this conjunct is outsider-reachable AND -# write-granting AND resolving no outside head: an `issue_comment` job with no -# `/pulls` lookup. `tests/privileged-lane.bats` carries it and the mutation names -# it, so the claim is held by a case rather than by this paragraph. Note that -# `test_a_scheduled_writer_with_no_outside_head_is_not_a_subject` below does NOT -# hold it either: that input is not outsider-reachable, so it passes with this -# conjunct deleted. -is_subject(doc, body) if { - outsider_reachable(doc) - grants_write(doc, body) - selects_outside_head(doc, body) -} +# The input that actually discriminates the third conjunct is outsider-reachable +# AND write-granting AND resolving no outside head: an `issue_comment` job with no +# `/pulls` lookup. `crates/batten/tests/it/privileged_lane.rs` carries it and the +# mutation names it, so the claim is held by a case rather than by this paragraph. +# Note that `test_a_scheduled_writer_with_no_outside_head_is_not_a_subject` below +# does NOT hold it either: that input is not outsider-reachable, so it passes with +# the conjunct deleted. -# `schedule` and `workflow_dispatch` are deliberately absent: neither is reachable -# by someone without write access. A schedule that goes on to resolve an outside -# head is still caught, by `selects_outside_head` below — which is why that clause -# reads the job body and not only the trigger. outsider_reachable(doc) if { some trigger in ["pull_request", "pull_request_target", "issue_comment", "workflow_run"] doc.on[trigger] @@ -135,15 +165,18 @@ grants_write(doc, _) if { doc.permissions.contents == "write" } -# Either the trigger inherently carries an outside head, or the job goes and finds -# one through the pulls API. The second arm is what keeps a schedule-driven -# resolver — `auto-bot-land`'s cron arm is exactly one — inside the subject set. -selects_outside_head(doc, _) if { +# The trigger inherently carries an outside head. `github.event.` then +# holds `head_repository.full_name`, which is the field this arm's remedy names. +trigger_carries_head(doc) if { some trigger in ["pull_request", "pull_request_target", "workflow_run"] doc.on[trigger] } -selects_outside_head(_, body) if { +# The job goes and finds a head through the pulls API. This is what keeps a +# schedule-driven resolver — `auto-bot-land`'s cron arm is exactly one — inside +# the subject set, and its remedy names `.head.repo.full_name` on the RESOLVED +# pull request, because the event payload carries no head to test. +resolves_head(body) if { contains(json.marshal(body), "/pulls") } @@ -219,9 +252,13 @@ test_a_scheduled_writer_with_no_outside_head_is_not_a_subject if { } # A schedule-only lane that DOES go looking for pull requests is a subject, even -# though no trigger carries a head. -test_a_scheduled_resolver_of_pulls_is_a_subject if { - count(violation) == 1 with input as {"tree": { +# though no trigger carries a head — and it raises the RESOLVE class, because the +# field its remedy names is on the pull request rather than on the event. +# +# Asserting the class rather than only the count is what makes this case +# discriminate the split: a single-class implementation still counts one here. +test_a_resolver_of_pulls_raises_the_resolve_class if { + found := violation with input as {"tree": { "documents": {".github/workflows/auto-bot-land.yml": { "on": {"issue_comment": {}}, "jobs": {"land": { @@ -231,6 +268,47 @@ test_a_scheduled_resolver_of_pulls_is_a_subject if { }}, "missing": [], }} + count(found) == 1 + some finding in found + finding.verdict == "lane resolve missing" +} + +# A trigger-carried head raises the GUARD class, which is the other half of the +# same discrimination: if both inputs raised one class, this pair would be green +# over an unsplit module. +test_a_trigger_carried_head_raises_the_guard_class if { + found := violation with input as {"tree": { + "documents": {".github/workflows/auto-bot-land.yml": { + "on": {"workflow_run": {}}, + "jobs": {"land": { + "permissions": {"contents": "write"}, + "if": "startsWith(github.event.workflow_run.head_branch, 'renovate/')", + }}, + }}, + "missing": [], + }} + count(found) == 1 + some finding in found + finding.verdict == "lane guard missing" +} + +# A job that is BOTH — a trigger carries a head and it also calls `/pulls` — is +# reported once, under the guard class. Without the `not trigger_carries_head` +# conjunct this counts two, which is the overlap the arms are made disjoint for. +test_a_job_matching_both_arms_is_reported_once if { + found := violation with input as {"tree": { + "documents": {".github/workflows/auto-bot-land.yml": { + "on": {"workflow_run": {}}, + "jobs": {"land": { + "permissions": {"contents": "write"}, + "steps": [{"run": "gh api repos/$REPO/pulls?state=open"}], + }}, + }}, + "missing": [], + }} + count(found) == 1 + some finding in found + finding.verdict == "lane guard missing" } test_a_read_only_lane_is_not_a_subject if { diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index 437f7842d..38386711c 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -1007,12 +1007,12 @@ "null" ], "enum": [ + "ci-hygiene", "commit-hygiene", - "trunk-based", - "shell-hygiene", + "landing-loop", "pinned-toolchain", - "ci-hygiene", - "landing-loop" + "shell-hygiene", + "trunk-based" ] }, "produces": { diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 716ffdb8a..ce474c7b8 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -2917,12 +2917,12 @@ "null" ], "enum": [ + "ci-hygiene", "commit-hygiene", - "trunk-based", - "shell-hygiene", + "landing-loop", "pinned-toolchain", - "ci-hygiene", - "landing-loop" + "shell-hygiene", + "trunk-based" ] }, "produces": {