From c990aa6517fe957a8212ad8d0c133663d2a14996 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 12 Sep 2026 03:05:46 +0000 Subject: [PATCH 1/9] feat(attribution)!: judge who cut a tag, from the tag object rather than its commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in this tree reads an annotated tag's TAGGER. `history_facts` and `tags_matching` peel a tag with `into_fully_peeled_id()` and discard the tag object; `commit-attribution` answers for a COMMIT's author and committer, which is a different identity. That gap is what three broken releases went through. MEASURED, and this repository is its own corpus. `mise.toml`'s `[env]` block recomputed `GITHUB_TOKEN` from a chain ending in `MISE_GITHUB_TOKEN`, which the mise setup action sets to the job's DEFAULT token. `mise run release` then cut tags as the CI bot; GitHub fires no workflow events for that token, so `release-artifacts.yml` never ran; v0.0.159, v0.0.160 and v0.0.161 published with missing or zero binaries; `install.sh` correctly refused the unverified bytes, which broke consumer containers AND `fast-forward.yml`, so the defect blocked its own fix. Nothing was red at any point and recovery needed a human twice. The root cause is fixed (`f3ab40aa`); this is the assertion that would have caught it. `git.rs` reads and `attribution.rs` judges — CLOUD-742's split. `tagger_of()` resolves the ref, finds the object, and discriminates on its kind: a tag object with a tagger header is `Signed`, one without is `Unsigned`, anything else is `Lightweight` (the ref points straight at a commit). Four outcomes counting errors, none collapsible, and an error is could-not-look rather than `Unsigned`. AN ALLOW-FORM, AND A SEPARATE KEY FROM `[attribution.identity]`, because the two answer different questions and measurably hold different values: `identity` is what commits must be authored by, while a release tag is cut by whatever credential the workflow holds. Every tag from v0.0.155 to v0.0.162 carries an address `identity` does not, and judging tags against it would refuse every release — which is what the first draft did before it was run against real tags. The bot identity is never written down. An allow-form refuses it by not matching, so non-negotiable rule 1 is satisfied by not needing the literal. `tag_identity_allow` IS OPTIONAL, and that is not the same claim as "an empty list is fine". `Attribution` carries `deny_unknown_fields`, so a required field would make every table written before this key existed fail to parse — a breaking change to a table about COMMITS, imposed by a question about tags. Instead the key defaults, and `judge_tagger` refuses to DECIDE over an empty list rather than passing every tag: a consumer that does not cut releases never calls the verb, and one that calls it without declaring the key gets exit 1, not a false green. FIVE LEDGERS BIND A NEW VERB, and each caught a real omission: the read-only allowlist and the committed row set in `spec.rs`, `CENSUS_POSITIONALS` in `cli.rs` — which needed a fixture that cuts a real annotated tag, `-a` deliberately, since a lightweight one carries no identity and would exercise the wrong arm — the disposition table in `pointer_only.rs`, and the golden snapshot. Verified against this repository's own history, read-only: `batten attribution tagger v0.0.161` exits 2 (cut by the bot), `v0.0.162` exits 0 (cut by the accountable identity). Three days apart. Refs: CLOUD-1794, CLOUD-1789 --- batten.toml | 20 ++ completions/batten.bash | 73 ++++- completions/batten.fish | 59 +++-- completions/batten.zsh | 58 ++++ crates/batten/src/attribution.rs | 249 ++++++++++++++++++ crates/batten/src/cli.rs | 17 ++ crates/batten/src/git.rs | 89 +++++++ crates/batten/src/lib.rs | 34 +++ crates/batten/src/spec.rs | 14 + crates/batten/src/surface.rs | 33 +++ crates/batten/tests/it/cli.rs | 29 ++ crates/batten/tests/it/common/mod.rs | 15 ++ crates/batten/tests/it/pointer_only.rs | 14 + .../it__snapshots__golden_json_schema.snap | 30 +++ man/batten-attribution-tagger.1 | 19 ++ man/batten-attribution.1 | 3 + schema/batten.schema.json | 7 + 17 files changed, 743 insertions(+), 20 deletions(-) create mode 100644 man/batten-attribution-tagger.1 diff --git a/batten.toml b/batten.toml index 428de29fa..dd797fc38 100644 --- a/batten.toml +++ b/batten.toml @@ -8944,6 +8944,26 @@ trailer_allow = [] # The accountable identity `batten attribution identity` writes into the # repo-local git config. Consumer-specific by nature: who answers for this # repository's commits is a property of this repository. +# Who is permitted to have cut a release tag (CLOUD-1794). +# +# SEPARATE FROM `[attribution.identity]` BECAUSE THEY MEASURABLY DIFFER HERE, and +# the first draft of this gate conflated them and refused every release in the +# repository -- the good ones included. `identity` below is what `attribution +# identity` writes into a clone's git config, so commits carry it. A tag is cut by +# whatever credential `release-plz.yml` holds, which renders as that ACCOUNT's +# identity: every tag from v0.0.155 to v0.0.162 carries the address below and not +# `identity`'s, and both are legitimate in this history. +# +# An ALLOW list rather than a deny list, so an unexpected credential is refused +# whether or not anyone predicted it -- which is the whole point, since what broke +# the pipeline was the CI job token nobody had thought to name. It also means no +# bot identity is written here at all. +# +# A LIST because the accountable credential changes: CLOUD-94 migrates this from a +# personal token to an org-owned App, and that transition wants both valid briefly +# rather than the gate switched off for a day. +tag_identity_allow = ['^Alec Wenzowski $'] + [attribution.identity] name = "Alec Wenzowski" email = "alec@wenzowski.com" diff --git a/completions/batten.bash b/completions/batten.bash index e28538149..1f38fd21e 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -154,6 +154,9 @@ _batten() { batten__subcmd__attribution,identity) cmd="batten__subcmd__attribution__subcmd__identity" ;; + batten__subcmd__attribution,tagger) + cmd="batten__subcmd__attribution__subcmd__tagger" + ;; batten__subcmd__attribution__subcmd__help,check) cmd="batten__subcmd__attribution__subcmd__help__subcmd__check" ;; @@ -163,6 +166,9 @@ _batten() { batten__subcmd__attribution__subcmd__help,identity) cmd="batten__subcmd__attribution__subcmd__help__subcmd__identity" ;; + batten__subcmd__attribution__subcmd__help,tagger) + cmd="batten__subcmd__attribution__subcmd__help__subcmd__tagger" + ;; batten__subcmd__capture,find) cmd="batten__subcmd__capture__subcmd__find" ;; @@ -514,6 +520,9 @@ _batten() { batten__subcmd__help__subcmd__attribution,identity) cmd="batten__subcmd__help__subcmd__attribution__subcmd__identity" ;; + batten__subcmd__help__subcmd__attribution,tagger) + cmd="batten__subcmd__help__subcmd__attribution__subcmd__tagger" + ;; batten__subcmd__help__subcmd__capture,find) cmd="batten__subcmd__help__subcmd__capture__subcmd__find" ;; @@ -1480,7 +1489,7 @@ _batten() { return 0 ;; batten__subcmd__attribution) - 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 check identity 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 check tagger identity help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1548,7 +1557,7 @@ _batten() { return 0 ;; batten__subcmd__attribution__subcmd__help) - opts="check identity help" + opts="check tagger identity help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1603,6 +1612,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__attribution__subcmd__help__subcmd__tagger) + 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__attribution__subcmd__identity) 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 @@ -1633,6 +1656,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__attribution__subcmd__tagger) + 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 + 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__baseline) opts="-n -q -v -y -h --prune --dry-run --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 2 ]] ; then @@ -3592,7 +3645,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__attribution) - opts="check identity" + opts="check tagger identity" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3633,6 +3686,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__attribution__subcmd__tagger) + 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__baseline) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 122b06e7c..59756c3cc 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2172,30 +2172,31 @@ complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from help" -f -a "check" -d 'Refuse an API break this branch\'s commits do not declare' complete -c batten -n "__fish_batten_using_subcommand semver; 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 attribution; and not __fish_seen_subcommand_from check identity 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 attribution; and not __fish_seen_subcommand_from check tagger identity 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 attribution; and not __fish_seen_subcommand_from check identity 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 attribution; and not __fish_seen_subcommand_from check identity 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 attribution; and not __fish_seen_subcommand_from check identity 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 attribution; and not __fish_seen_subcommand_from check tagger identity 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 attribution; and not __fish_seen_subcommand_from check tagger identity 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 attribution; and not __fish_seen_subcommand_from check tagger identity 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 attribution; and not __fish_seen_subcommand_from check identity 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 attribution; and not __fish_seen_subcommand_from check identity help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -f -a "check" -d 'Refuse vendor authorship, branding or session links in commit metadata' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -f -a "identity" -d 'Set this clone\'s repo-local git identity when it is unset or denied' -complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check identity help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity 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 attribution; and not __fish_seen_subcommand_from check tagger identity help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -f -a "check" -d 'Refuse vendor authorship, branding or session links in commit metadata' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -f -a "tagger" -d 'Refuse a tag cut by an identity this repository is not accountable to' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -f -a "identity" -d 'Set this clone\'s repo-local git identity when it is unset or denied' +complete -c batten -n "__fish_batten_using_subcommand attribution; and not __fish_seen_subcommand_from check tagger identity help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from check" -l message -d 'Judge one pending commit message file, before the commit exists' -r complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from check" -l harness -d 'Report the attribution capabilities this host declares, and capture at that fidelity' -r -f -a "claude-code\t'Claude Code\'s `PreToolUse` payload; a deny is returned as the `hookSpecificOutput.permissionDecision` JSON object on stdout with exit `0` — the channel the production shell guards already use' cursor\t'Cursor. Two payload families under one host: a generic `preToolUse` that looks like Claude\'s, and specialized events (`beforeShellExecution`, `beforeReadFile`, `beforeMCPExecution`) that carry the operand at top level and **no** `tool_name` at all. Session is `conversation_id`' @@ -2225,6 +2226,28 @@ complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_se complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from check" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from check" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from check" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -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 attribution; and __fish_seen_subcommand_from tagger" -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 attribution; and __fish_seen_subcommand_from tagger" -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 attribution; and __fish_seen_subcommand_from tagger" -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 attribution; and __fish_seen_subcommand_from tagger" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -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 attribution; and __fish_seen_subcommand_from tagger" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from tagger" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from identity" -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'" @@ -2247,6 +2270,7 @@ complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_se complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from identity" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from identity" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from help" -f -a "check" -d 'Refuse vendor authorship, branding or session links in commit metadata' +complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from help" -f -a "tagger" -d 'Refuse a tag cut by an identity this repository is not accountable to' complete -c batten -n "__fish_batten_using_subcommand attribution; and __fish_seen_subcommand_from help" -f -a "identity" -d 'Set this clone\'s repo-local git identity when it is unset or denied' complete -c batten -n "__fish_batten_using_subcommand attribution; 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 worktree; and not __fish_seen_subcommand_from status 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' @@ -3691,6 +3715,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 claim" -f -a "carry" -d 'Attest that this branch only carries licence rows forward, and mint the receipt when it does' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from semver" -f -a "check" -d 'Refuse an API break this branch\'s commits do not declare' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from attribution" -f -a "check" -d 'Refuse vendor authorship, branding or session links in commit metadata' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from attribution" -f -a "tagger" -d 'Refuse a tag cut by an identity this repository is not accountable to' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from attribution" -f -a "identity" -d 'Set this clone\'s repo-local git identity when it is unset or denied' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from worktree" -f -a "status" -d 'Report work that is uncommitted, unpushed, or not landed on the configured target' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from override" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' diff --git a/completions/batten.zsh b/completions/batten.zsh index d4a9f9b04..8eb3c6bfb 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3678,6 +3678,38 @@ trace\:"Add everything"))' \ '::range -- Judge every non-merge commit in this range (..):_default' \ && ret=0 ;; +(tagger) +_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"))' \ +'-J[Emit byte-stable JSON instead of pointer lines]' \ +'--json[Emit byte-stable JSON instead of pointer lines]' \ +'--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'\'')]' \ +':tag -- The tag to judge, by short name (v0.0.162):_default' \ +&& ret=0 +;; (identity) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -3723,6 +3755,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(tagger) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (identity) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -6639,6 +6675,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(tagger) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (identity) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -7098,6 +7138,7 @@ _batten__subcmd__adjudicate_commands() { _batten__subcmd__attribution_commands() { local commands; commands=( 'check:Refuse vendor authorship, branding or session links in commit metadata' \ +'tagger:Refuse a tag cut by an identity this repository is not accountable to' \ 'identity:Set this clone'\''s repo-local git identity when it is unset or denied' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -7112,6 +7153,7 @@ _batten__subcmd__attribution__subcmd__check_commands() { _batten__subcmd__attribution__subcmd__help_commands() { local commands; commands=( 'check:Refuse vendor authorship, branding or session links in commit metadata' \ +'tagger:Refuse a tag cut by an identity this repository is not accountable to' \ 'identity:Set this clone'\''s repo-local git identity when it is unset or denied' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -7132,11 +7174,21 @@ _batten__subcmd__attribution__subcmd__help__subcmd__identity_commands() { local commands; commands=() _describe -t commands 'batten attribution help identity commands' commands "$@" } +(( $+functions[_batten__subcmd__attribution__subcmd__help__subcmd__tagger_commands] )) || +_batten__subcmd__attribution__subcmd__help__subcmd__tagger_commands() { + local commands; commands=() + _describe -t commands 'batten attribution help tagger commands' commands "$@" +} (( $+functions[_batten__subcmd__attribution__subcmd__identity_commands] )) || _batten__subcmd__attribution__subcmd__identity_commands() { local commands; commands=() _describe -t commands 'batten attribution identity commands' commands "$@" } +(( $+functions[_batten__subcmd__attribution__subcmd__tagger_commands] )) || +_batten__subcmd__attribution__subcmd__tagger_commands() { + local commands; commands=() + _describe -t commands 'batten attribution tagger commands' commands "$@" +} (( $+functions[_batten__subcmd__baseline_commands] )) || _batten__subcmd__baseline_commands() { local commands; commands=() @@ -7710,6 +7762,7 @@ _batten__subcmd__help__subcmd__adjudicate_commands() { _batten__subcmd__help__subcmd__attribution_commands() { local commands; commands=( 'check:Refuse vendor authorship, branding or session links in commit metadata' \ +'tagger:Refuse a tag cut by an identity this repository is not accountable to' \ 'identity:Set this clone'\''s repo-local git identity when it is unset or denied' \ ) _describe -t commands 'batten help attribution commands' commands "$@" @@ -7724,6 +7777,11 @@ _batten__subcmd__help__subcmd__attribution__subcmd__identity_commands() { local commands; commands=() _describe -t commands 'batten help attribution identity commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__attribution__subcmd__tagger_commands] )) || +_batten__subcmd__help__subcmd__attribution__subcmd__tagger_commands() { + local commands; commands=() + _describe -t commands 'batten help attribution tagger commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__baseline_commands] )) || _batten__subcmd__help__subcmd__baseline_commands() { local commands; commands=() diff --git a/crates/batten/src/attribution.rs b/crates/batten/src/attribution.rs index 714c65469..607207c23 100644 --- a/crates/batten/src/attribution.rs +++ b/crates/batten/src/attribution.rs @@ -91,6 +91,44 @@ pub struct Attribution { /// **nothing**, which is how a silent posture is expressed as data. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub trailer_allow: Vec, + /// Identities permitted to have cut a release tag (CLOUD-1794). + /// + /// AN ALLOW LIST, AND A SEPARATE ONE FROM [`Attribution::identity`], because + /// the two answer different questions and measurably hold different values. + /// `identity` is what `attribution identity` WRITES into a clone's git config + /// — the identity commits must be authored by. A release tag is cut by + /// whatever credential the release workflow holds, which renders as that + /// account's identity and need not be the committing one. Measured on this + /// repository: every tag from v0.0.155 to v0.0.162 was cut by an address that + /// is not `identity`'s, and the history carries both as legitimate. + /// + /// Judging tags against `identity` therefore refuses every release, which is a + /// worse outage than the one this gate exists to catch — and is exactly what + /// the first draft did before it was run against real tags. + /// + /// A LIST rather than one value, because the accountable credential changes: + /// migrating from a personal token to an org-owned App (CLOUD-94) changes the + /// identity tags carry, and a transition where both are briefly valid should + /// be expressible without leaving the gate off. + /// + /// DEFAULTED, AND THAT IS NOT THE SAME CLAIM AS "AN EMPTY LIST IS FINE" + /// (CLOUD-1789). The first draft made this mandatory and conflated two + /// questions: whether an empty list is a policy — it is not — and whether + /// every consumer must answer this one, which it must not. `Attribution` + /// carries `deny_unknown_fields`, so a required field here makes any config + /// written before this key existed fail to parse, which is a breaking change + /// to a table that governs commits rather than tags; it was caught by + /// `trust::tests::shrinking_a_deny_list_or_widening_the_carve_out_is_a_weakening`, + /// whose fixture is a `[attribution]` table with no reason to mention tags. + /// + /// ABSENT IS COULD-NOT-LOOK, NEVER CLEAN, which is where the "empty is not a + /// policy" half survives: [`Attribution::judge_tagger`] refuses to decide over + /// an empty list rather than passing every tag. So a consumer that does not + /// govern tag identity is unaffected — it never calls the verb — and one that + /// calls the verb without declaring the key gets a loud exit `1` instead of a + /// false green. That is the asymmetry rather than a softening of it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tag_identity_allow: Vec, /// The accountable identity `--set-identity` writes into the repo-local git /// config. pub identity: Identity, @@ -199,6 +237,19 @@ impl Attribution { ("identity_deny", &self.identity_deny), ("trailer_deny", &self.trailer_deny), ("body_deny", &self.body_deny), + // `tag_identity_allow` IS DELIBERATELY NOT IN THIS LOOP, and that is + // the one row worth the sentence (CLOUD-1789). Every key above is + // mandatory because `[attribution]` exists to govern COMMITS and a + // table that declares no patterns for them is the half-change rule 2 + // catches. Tag identity is a different question, asked only by a + // consumer that cuts release tags, and requiring it here would refuse + // every `[attribution]` table written before the key existed. + // + // The "empty is not a policy" half is not lost, only moved to where it + // can be answered honestly: `judge_tagger` refuses to DECIDE over an + // empty list rather than passing every tag, so absence is + // could-not-look at the point of use instead of a parse error for + // consumers who never ask the question. ] { if patterns.is_empty() { return Err(UsageError::raise(format!( @@ -265,6 +316,79 @@ impl Attribution { } Ok(findings) } + + /// Whether a rendered tagger identity is the one this repository is + /// accountable to. + /// + /// AN ALLOW-FORM, AND THAT IS THE POINT. A deny list would catch only the + /// identity somebody thought to name; this refuses every identity that is not + /// the declared one, so a release cut by an unexpected credential is refused + /// whether or not anyone predicted that credential. It is also why no vendor + /// literal appears in this crate for the tag surface at all — non-negotiable + /// rule 1 is satisfied by not needing the name rather than by relocating it. + /// + /// `identity_deny` is still consulted, so an identity that is both permitted + /// and denied is refused. Config that contradicts itself is a finding, not a + /// pass, and the deny list is the half that wins. + fn tagger_is_accountable( + &self, + rendered: &str, + permitted: &Matchers, + denied: &Matchers, + ) -> bool { + permitted.matches(rendered) && !denied.matches(rendered) + } + + /// Judge who cut a tag, returning every refusal as a pointer. + /// + /// `label` is the tag name, which is safe to print. The tagger identity never + /// reaches a finding — the same rule-4 discipline [`Attribution::judge`] keeps + /// over commit metadata. + /// + /// # Errors + /// + /// Returns a [`UsageError`] (→ exit `1`) if `tag_identity_allow` is empty, or + /// if it or `identity_deny` does not compile. + pub fn judge_tagger(&self, label: &str, tagger: &git::Tagger) -> Result> { + // COULD NOT LOOK, NEVER CLEAN, and this is where `validate`'s "an empty + // allow list is not a policy" lives now that the key is optional + // (CLOUD-1789). An empty list permits nothing, so deciding over it would + // refuse every tag — a gate that fires on correct behaviour, which gets + // switched off and then protects nothing. Passing every tag instead is the + // opposite and worse failure: the exact silent green that let three broken + // releases ship. So the verb declines to answer, loudly, at exit `1`. + if self.tag_identity_allow.is_empty() { + return Err(UsageError::raise( + "attribution.tag_identity_allow: declares no patterns, so who may cut a tag is \ + undeclared and nothing can be decided. Name the identity the release credential \ + renders as — never the bot identity being refused, which does not have to appear \ + anywhere", + )); + } + let permitted = Matchers::compile("tag_identity_allow", &self.tag_identity_allow)?; + let denied = Matchers::compile("identity_deny", &self.identity_deny)?; + let point = |field: &str| Finding { + label: label.to_owned(), + field: field.to_owned(), + }; + // The two no-identity arms are written out separately rather than joined + // with `|`. They answer the same way today, and keeping them apart costs a + // line while buying two things: the match stays exhaustive if a fourth + // variant lands, and each arm is independently mutatable — a `|` inside a + // mutated expression is split as a field separator by the sweep and the + // row is refused. + Ok(match tagger { + git::Tagger::Signed(rendered) => { + if self.tagger_is_accountable(rendered, &permitted, &denied) { + Vec::new() + } else { + vec![point("tagger")] + } + } + git::Tagger::Unsigned => vec![point("tagger:unannotated")], + git::Tagger::Lightweight => vec![point("tagger:unannotated")], + }) + } } /// Read every non-merge commit in `base..head`. @@ -492,6 +616,11 @@ mod tests { ], body_deny: vec![r"[Gg]enerated with".to_owned()], trailer_allow: Vec::new(), + // The release credential's identity, deliberately DIFFERENT from + // `identity` below: that asymmetry is the real shape this repository + // has, and a fixture where the two coincide would pass a judge that + // conflated them. + tag_identity_allow: vec![r"^Release Cutter $".to_owned()], identity: Identity { name: "Accountable Human".to_owned(), email: "human@example.test".to_owned(), @@ -514,6 +643,126 @@ mod tests { assert!(policy().judge(&commit()).unwrap().is_empty()); } + /// The tagger as git renders it, for a tag the release credential cut. + fn accountable_tagger() -> git::Tagger { + git::Tagger::Signed("Release Cutter ".to_owned()) + } + + fn tagger_fields(tagger: &git::Tagger) -> Vec { + policy() + .judge_tagger("v0.0.1", tagger) + .unwrap() + .into_iter() + .map(|finding| finding.field) + .collect() + } + + #[test] + fn a_tag_cut_by_a_ci_bot_is_refused() { + // THE DEFECT, as a case. v0.0.159-v0.0.161 were cut by a CI job token + // rather than the accountable identity, and nothing in the tree could say + // so. Note the identity is never named in this crate: the allow-form + // refuses it for not being the declared one, not for being any particular + // bot. + let tagger = git::Tagger::Signed("github-actions[bot] <41898282+bot@users.noreply>".into()); + assert_eq!(tagger_fields(&tagger), vec!["tagger".to_owned()]); + } + + #[test] + fn a_tag_cut_by_the_accountable_identity_passes() { + // The partner. Without it the fix is satisfied by a rule that refuses + // every release, which would be a worse outage than the one it replaces. + assert!(tagger_fields(&accountable_tagger()).is_empty()); + } + + #[test] + fn a_tagger_differing_only_in_email_is_refused() { + // The match is the whole rendered identity. A same-name, different-address + // tagger is a different credential, which is exactly the substitution this + // gate exists to catch. + let tagger = git::Tagger::Signed("Release Cutter ".to_owned()); + assert_eq!(tagger_fields(&tagger), vec!["tagger".to_owned()]); + } + + #[test] + fn a_denied_identity_pattern_still_refuses_a_tagger() { + // The second conjunct is live rather than decorative. + let tagger = git::Tagger::Signed("Vendor ".to_owned()); + assert_eq!(tagger_fields(&tagger), vec!["tagger".to_owned()]); + } + + #[test] + fn an_unsigned_tag_object_is_its_own_arm() { + // An annotated tag whose header carries no tagger. Not clean, and not the + // same answer as a tag that was never annotated. + assert_eq!( + tagger_fields(&git::Tagger::Unsigned), + vec!["tagger:unannotated".to_owned()] + ); + } + + #[test] + fn a_lightweight_tag_is_refused_rather_than_read_as_accountable() { + // A ref pointing straight at a commit carries no identity at all. Reading + // that as clean is how a release cut by anyone would pass. + assert_eq!( + tagger_fields(&git::Tagger::Lightweight), + vec!["tagger:unannotated".to_owned()] + ); + } + + #[test] + fn an_undeclared_allow_list_declines_to_decide_rather_than_passing() { + // THE ARM THE KEY BEING OPTIONAL CREATES (CLOUD-1789), and the one that + // decides whether making it optional was safe. With no declared allow + // list, "who may cut a tag" is unanswered — and the two ways of answering + // it anyway are both wrong: refusing every tag is a gate that fires on + // correct behaviour, and passing every tag is the silent green that let + // three broken releases ship. So it is an error, not a verdict. + // + // Asserted over a tag that WOULD be clean under the fixture's policy, so a + // pass here could not be mistaken for the accountable arm working. + let mut undeclared = policy(); + undeclared.tag_identity_allow = Vec::new(); + let answer = undeclared.judge_tagger("v1.0.0", &accountable_tagger()); + assert!( + answer.is_err(), + "an undeclared allow list must not decide: {answer:?}" + ); + } + + #[test] + fn a_declared_allow_list_still_decides_both_ways() { + // ANTI-VACUITY'S OTHER HALF. Without this, the arm above is satisfied by a + // `judge_tagger` that errors unconditionally — which would be a gate that + // never passes, the shape that gets switched off in a day. + assert!( + tagger_fields(&accountable_tagger()).is_empty(), + "the accountable identity must still pass" + ); + assert_eq!( + tagger_fields(&git::Tagger::Signed( + "github-actions[bot] ".to_owned() + )), + vec!["tagger".to_owned()] + ); + } + + #[test] + fn no_finding_carries_the_tagger_identity() { + // Rule 4 held in the assertion, not just in the comment: the refusal is a + // pointer, so the identity that failed must not reach the output. + let secret = "Leaky Person "; + let findings = policy() + .judge_tagger("v0.0.1", &git::Tagger::Signed(secret.to_owned())) + .unwrap(); + assert_eq!(findings.len(), 1); + for finding in &findings { + assert!(!finding.line().contains("leak@example.test")); + assert!(!finding.line().contains("Leaky Person")); + } + } + #[test] fn a_denied_author_is_pointed_at_by_field() { let mut subject = commit(); diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 2db82089a..bff68743c 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -1183,6 +1183,19 @@ pub enum AttributionCommand { /// git-native and host-independent. harness: Option, }, + /// Judge who cut a tag against the `[attribution]` policy. + /// + /// A separate verb from `Check` rather than another of its inputs: `Check` + /// judges a COMMIT's metadata against three pattern lists, and this judges a + /// TAG OBJECT's tagger against the declared identity. Different object, + /// different question, and folding them together would give one verb two + /// answer shapes. + Tagger { + /// The tag to judge, as a short name — `v0.0.162`, not a full ref. + tag: String, + /// Emit the findings as byte-stable JSON instead of pointer lines. + json: bool, + }, /// Set this clone's repo-local git identity when it is unset or denied. Identity, } @@ -1723,6 +1736,10 @@ fn attribution_of(matches: &ArgMatches) -> Option { message: matches.get_one::("message").cloned(), harness: matches.get_one::("harness").copied(), }), + ("tagger", matches) => Some(AttributionCommand::Tagger { + tag: matches.get_one::("tag").cloned()?, + json: flag(matches, "json"), + }), ("identity", _) => Some(AttributionCommand::Identity), _ => None, } diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index bc63bf4d2..9f563708c 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -4167,6 +4167,95 @@ fn tags_matching(repo: &gix::Repository, glob: Option<&str>) -> Result`. + Signed(String), + /// An annotated tag object whose header carries no tagger line. + Unsigned, + /// A lightweight tag: the ref names a commit directly, so no tag object + /// exists and there is no tagger. + Lightweight, +} + +/// Read `refs/tags/`'s tagger without peeling the tag object away. +/// +/// **[`tags_matching`]'s `into_fully_peeled_id` is the door this deliberately does +/// not use.** Peeling is what lets the history fact answer "which commit did this +/// tag ship", and it is the same act that makes the fact unable to answer this +/// one: it discards the tag object, and with it the only record of who created +/// the tag. An annotated tag's tagger and its commit's committer are different +/// identities — `release-plz` cuts both, and in the CLOUD-1789 outage they +/// disagreed — so a reader that peels answers a question nobody asked here. +/// +/// Reads the tag object directly, so it answers on a shallow clone that carries +/// the ref. [`history_facts`] refuses the whole family when the repository is +/// shallow; this does not have to, which is why the gate above it can live on the +/// landing path rather than on a clock. +/// +/// # Errors +/// +/// Returns a [`UsageError`] (→ exit `1`) when the repository will not open, the +/// ref does not resolve, or the object will not read. Every one of those is "could +/// not look", and none is an answer about who cut the tag. +pub fn tagger_of(dir: &Path, tag: &str) -> Result { + let repo = open(dir)?; + let reference = repo + .find_reference(&format!("refs/tags/{tag}")) + .map_err(|_| UsageError::raise(format!("no tag named `{tag}` in this repository")))?; + let id = reference + .target() + .try_id() + .ok_or_else(|| UsageError::raise(format!("tag `{tag}` is symbolic and names no object")))? + .to_owned(); + let object = repo.find_object(id).map_err(|_| { + UsageError::raise(format!("tag `{tag}` names an object that will not read")) + })?; + if object.kind != gix::object::Kind::Tag { + // The ref points straight at a commit: a lightweight tag. This is a + // `Kind` discrimination rather than a missing field, which is exactly why + // it is not the same answer as `Unsigned`. + return Ok(Tagger::Lightweight); + } + let annotated = object + .try_into_tag() + .map_err(|_| UsageError::raise(format!("tag `{tag}` will not read as a tag object")))?; + let decoded = annotated + .decode() + .map_err(|_| UsageError::raise(format!("tag `{tag}`'s header will not decode")))?; + let Some(line) = decoded.tagger else { + return Ok(Tagger::Unsigned); + }; + // `tagger` is the RAW header line, not a parsed signature: `Name + // `. The identity is everything up to and including the + // closing bracket, and git forbids `<` and `>` inside both halves, so that + // bracket is unambiguous rather than a guess about the name. + // + // The timestamp is deliberately dropped. It is not identity, and carrying it + // would make the judged value differ between two tags the same person cut — + // which is the comparison this exists to make. + // + // A header with no bracket is malformed, and that is could-not-look: a line + // this reader cannot parse is not evidence that nobody signed the tag. + let rendered = String::from_utf8_lossy(line.as_ref()); + let close = rendered.rfind('>').ok_or_else(|| { + UsageError::raise(format!("tag `{tag}`'s tagger header carries no identity")) + })?; + Ok(Tagger::Signed(rendered[..=close].to_owned())) +} + /// The commits at which `path` appeared (`added`) or vanished (`!added`). /// /// Compares the path's presence in each commit's tree against its FIRST parent's, diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 3a879a880..37ca9a8b0 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -12331,10 +12331,44 @@ fn run_attribution( overrides, out, ), + AttributionCommand::Tagger { tag, json } => { + run_attribution_tagger(&tag, json, overrides, out) + } AttributionCommand::Identity => run_attribution_identity(overrides, err), } } +/// Judge who cut one tag (CLOUD-1794). +/// +/// No `AttributionDocument` here, and the absence is deliberate rather than an +/// omission: that document reports a HOST's attribution capabilities, and a tag +/// object has no host — it was cut by a credential, in a workflow, possibly years +/// after whatever session wrote the commit under it. Emitting an undeclared caller +/// beside a tag verdict would invite reading one as evidence about the other. +/// +/// # Errors +/// +/// Returns a [`UsageError`] (→ exit `1`) when the tag does not resolve or the +/// repository will not read — could-not-look, never a clean pass over a tag +/// nobody judged. +fn run_attribution_tagger( + tag: &str, + json: bool, + overrides: &Overrides, + out: &mut dyn Write, +) -> Result { + let policy = attribution_policy(overrides)?; + let tagger = git::tagger_of(Path::new("."), tag)?; + let findings = policy.judge_tagger(tag, &tagger)?; + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&findings)?)?; + } else { + // Silence is the success signal on the human channel (§6). + write!(out, "{}", attribution::report(&findings))?; + } + Ok(ExitCode::verdict(!findings.is_empty())) +} + fn run_attribution_check( json: bool, range: Option<&str>, diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 1571fca5e..ed8eda3db 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -409,6 +409,13 @@ mod tests { // against it, so it is `read`; `attribution identity` writes // .git/config and is deliberately absent, as is the noun above it. "attribution check".to_owned(), + // The TAGGER half of the same pair, and it is `read` for exactly + // `attribution check`'s reason one row up: it opens the tag object + // through git's read-only plumbing, matches the rendered tagger + // against configured patterns, and writes nothing. It is a + // separate row rather than covered by the noun because + // `attribution identity` shares that noun and writes .git/config. + "attribution tagger".to_owned(), // Both navigation verbs are on it, and the `capture` noun above // them is not: the noun is unclassified because `capture prune` // removes, which is the fail-safe reading a consumer treating an @@ -726,6 +733,13 @@ mod tests { "attribution".to_owned(), "attribution check".to_owned(), "attribution identity".to_owned(), + // CLOUD-1789. The third verb under the noun, and the one that reads + // an identity nothing else in this tree could: an annotated tag's + // TAGGER, which is a different identity from the author and committer + // of the commit it points at. Every existing fact peels a tag with + // `into_fully_peeled_id()` and discards the tag object, which is the + // gap three releases cut by the CI default token went through. + "attribution tagger".to_owned(), // The adoption path for an already-dirty repository (CLOUD-67). // §2's listing gained the row in the same change, which is what // this assertion exists to prompt. diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 3bea51ec2..2e29b1ca0 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -580,6 +580,27 @@ const RANGE: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// The tag `attribution tagger` judges (CLOUD-1794). +/// +/// Positional and REQUIRED, which is `RANGE`'s reasoning taken one step further. +/// The tag is the verb's object, and there is no defensible default: resolving +/// "the latest" would need an ordering the history surface deliberately does not +/// carry, and guessing one would make the verb answer about a tag the caller did +/// not name. A missing tag is a usage error, never a vacuous pass. +const TAG: FlagDecl = FlagDecl { + id: "tag", + long: None, + short: None, + help: "The tag to judge, by short name (v0.0.162)", + env: EnvDecl::None, + global: false, + positional: true, + required: true, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// The pull request every `pr` bot-lane verb is about (CLOUD-1295). /// /// Positional and required, for `RANGE`'s reason: the pull request IS the verb's @@ -4044,6 +4065,18 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON, RANGE, MESSAGE, ATTRIBUTION_HARNESS], }, + // Reads one tag object through git and compares its tagger against the + // declared identity. Same `read` promise as `attribution check`: git's own + // plumbing and nothing user-supplied. + CommandDecl { + path: "attribution tagger", + id: "attribution.tagger", + about: "Refuse a tag cut by an identity this repository is not accountable to", + data_channel: true, + exits: EXITS_VERDICT, + effect: Effect::Read, + flags: &[TAG, JSON], + }, // The one write this subject introduces, self-declared (§5). Repo-local only: // it writes `.git/config` in this checkout and never `--global`, which covers // a developer's own unrelated repositories. diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index b77818cee..234daf555 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -5321,6 +5321,19 @@ const CENSUS_CONFIG: &str = concat!( "identity_deny = [\"^Nobody <\"]\n", "trailer_deny = [\"^Nobody-Session:\"]\n", "body_deny = [\"^Nobody generated\"]\n", + // `attribution tagger`'s minimum input, and the one key here that is + // OPTIONAL in the schema (CLOUD-1789). The verb declines to decide over + // an undeclared allow list rather than passing every tag, so a census + // without this would exercise the could-not-look arm — which writes an + // `::error::` line, the one thing a data-channel verb's stderr may not + // carry unprompted. + // + // The fixture's OWN git identity, not `[attribution.identity]` below, and + // that asymmetry is the real shape: a release tag is cut by whatever + // credential the release workflow holds, which need not be the committing + // identity. Measured on this repository, every tag from v0.0.155 carries + // an address `identity` does not. + "tag_identity_allow = [\"^t $\"]\n", "[attribution.identity]\n", "name = \"Census Human\"\n", "email = \"census@example.test\"\n", @@ -5427,9 +5440,19 @@ fn census_repo(root: &Path) -> PathBuf { &format!("{CENSUS_CARRY_BASE}census/action@bbb\tMIT\tCopyright (c) 2026 Census\n"), ) .work_commit() + // `attribution tagger`'s minimum input (CLOUD-1789), and the first that + // is a property of a REF rather than of a file or a diff. Annotated, so + // the tag carries a tagger header; cut after `work_commit` so it points + // at HEAD. Named by `CENSUS_POSITIONALS` rather than by this call site, + // so the argv and the ref it names cannot drift apart. + .annotated_tag(CENSUS_TAG) .build() } +/// The release tag the census fixture cuts, named once so +/// [`CENSUS_POSITIONALS`] and [`census_repo`] cannot disagree about it. +const CENSUS_TAG: &str = "v0.0.1"; + /// A git repo with a committed authority, isolated state dir, and a work commit — /// enough for every `data_channel` verb to have something real to answer about. /// @@ -5577,6 +5600,12 @@ const CENSUS_POSITIONALS: &[(&str, &[&str])] = &[ // An empty but resolvable range: the clean answer is `[]`, which is a // document like any other, and it needs no commit the fixture did not make. ("attribution check", &["HEAD..HEAD"]), + // The annotated tag `census_repo` cuts at HEAD, whose tagger is the + // fixture's own identity and therefore matches `tag_identity_allow` — + // so the census asserts about a CLEAN run. A name no ref carries would be + // could-not-look, which writes the `::error::` line a data-channel verb's + // stderr may not carry unprompted. + ("attribution tagger", &[CENSUS_TAG]), // The same empty-but-resolvable range, for the same reason (CLOUD-701). ("commit check", &["HEAD..HEAD"]), // A valid check name; `receipt status` answers `missing` for it, which is a diff --git a/crates/batten/tests/it/common/mod.rs b/crates/batten/tests/it/common/mod.rs index 385ebb87a..0ea0dea99 100644 --- a/crates/batten/tests/it/common/mod.rs +++ b/crates/batten/tests/it/common/mod.rs @@ -1104,6 +1104,21 @@ impl Fixture { self } + /// Cut an ANNOTATED tag at `HEAD`, carrying a tagger header (CLOUD-1789). + /// + /// `-m` is what makes it annotated, and that is the whole point rather than + /// a detail: a lightweight tag is a ref pointing straight at a commit and + /// carries no identity at all, so a fixture built with `git tag ` + /// would exercise the `Lightweight` arm while looking like it exercised the + /// accountable one. The tagger is the template's `t `, which + /// is why a consumer of this builder declares that identity rather than + /// `[attribution.identity]`'s. + #[must_use] + pub(crate) fn annotated_tag(self, name: &str) -> Self { + git_in(&self.dir, &["tag", "-a", name, "-m", "release"]); + self + } + /// The materialized directory. #[must_use] pub(crate) fn path(&self) -> &Path { diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 0592617ef..cc6d276fb 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -1488,6 +1488,20 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The THIRD attribution verb, and the discipline is the same one its sibling + // above states (CLOUD-1789). What it reads is an annotated tag's tagger — an + // identity — so a finding carrying the matched text would print the very + // thing being judged, and on the failing path that text is a credential's + // account name. Findings are ` tagger` and ` tagger:unannotated`: + // the tag name is safe to print because the caller supplied it, and the + // identity never leaves the matcher. `no_finding_carries_the_tagger_identity` + // holds that in an assertion rather than in this comment. + Verb { + path: "attribution tagger", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // The subject convention, same object and same discipline (CLOUD-701). A // subject carries whatever its author typed, so echoing it back is the gate // republishing arbitrary content — which is exactly what the shell task this 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 d085ee2b1..81770623c 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 @@ -200,6 +200,32 @@ expression: stdout_of(&output) "data_channel": false, "flags": [], "subcommands": [] + }, + { + "path": "attribution tagger", + "id": "attribution.tagger", + "about": "Refuse a tag cut by an identity this repository is not accountable to", + "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" + }, + { + "name": "tag", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The tag to judge, by short name (v0.0.162)" + } + ], + "subcommands": [] } ] }, @@ -3007,6 +3033,10 @@ expression: stdout_of(&output) "id": "attribution.check", "path": "attribution check" }, + { + "id": "attribution.tagger", + "path": "attribution tagger" + }, { "id": "capture.find", "path": "capture find" diff --git a/man/batten-attribution-tagger.1 b/man/batten-attribution-tagger.1 new file mode 100644 index 000000000..dd24a2a94 --- /dev/null +++ b/man/batten-attribution-tagger.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-attribution-tagger 1 batten +.SH NAME +batten\-attribution\-tagger \- Refuse a tag cut by an identity this repository is not accountable to +.SH SYNOPSIS +\fBbatten attribution tagger\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fItag\fR> +.SH DESCRIPTION +Refuse a tag cut by an identity this repository is not accountable to +.SH OPTIONS +.TP +\fB\-J\fR, \fB\-\-json\fR +Emit byte\-stable JSON instead of pointer lines +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fItag\fR> +The tag to judge, by short name (v0.0.162) diff --git a/man/batten-attribution.1 b/man/batten-attribution.1 index bbd3f28f1..095e78389 100644 --- a/man/batten-attribution.1 +++ b/man/batten-attribution.1 @@ -16,6 +16,9 @@ Print help batten\-attribution\-check(1) Refuse vendor authorship, branding or session links in commit metadata .TP +batten\-attribution\-tagger(1) +Refuse a tag cut by an identity this repository is not accountable to +.TP batten\-attribution\-identity(1) Set this clone\*(Aqs repo\-local git identity when it is unset or denied .TP diff --git a/schema/batten.schema.json b/schema/batten.schema.json index fc6fe59e8..fa6066962 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -657,6 +657,13 @@ "type": "string" } }, + "tag_identity_allow": { + "description": "Identities permitted to have cut a release tag (CLOUD-1794).\n\nAN ALLOW LIST, AND A SEPARATE ONE FROM [`Attribution::identity`], because\nthe two answer different questions and measurably hold different values.\n`identity` is what `attribution identity` WRITES into a clone's git config\n— the identity commits must be authored by. A release tag is cut by\nwhatever credential the release workflow holds, which renders as that\naccount's identity and need not be the committing one. Measured on this\nrepository: every tag from v0.0.155 to v0.0.162 was cut by an address that\nis not `identity`'s, and the history carries both as legitimate.\n\nJudging tags against `identity` therefore refuses every release, which is a\nworse outage than the one this gate exists to catch — and is exactly what\nthe first draft did before it was run against real tags.\n\nA LIST rather than one value, because the accountable credential changes:\nmigrating from a personal token to an org-owned App (CLOUD-94) changes the\nidentity tags carry, and a transition where both are briefly valid should\nbe expressible without leaving the gate off.\n\nDEFAULTED, AND THAT IS NOT THE SAME CLAIM AS \"AN EMPTY LIST IS FINE\"\n(CLOUD-1789). The first draft made this mandatory and conflated two\nquestions: whether an empty list is a policy — it is not — and whether\nevery consumer must answer this one, which it must not. `Attribution`\ncarries `deny_unknown_fields`, so a required field here makes any config\nwritten before this key existed fail to parse, which is a breaking change\nto a table that governs commits rather than tags; it was caught by\n`trust::tests::shrinking_a_deny_list_or_widening_the_carve_out_is_a_weakening`,\nwhose fixture is a `[attribution]` table with no reason to mention tags.\n\nABSENT IS COULD-NOT-LOOK, NEVER CLEAN, which is where the \"empty is not a\npolicy\" half survives: [`Attribution::judge_tagger`] refuses to decide over\nan empty list rather than passing every tag. So a consumer that does not\ngovern tag identity is unaffected — it never calls the verb — and one that\ncalls the verb without declaring the key gets a loud exit `1` instead of a\nfalse green. That is the asymmetry rather than a softening of it.", + "type": "array", + "items": { + "type": "string" + } + }, "trailer_allow": { "description": "The carve-out from [`Attribution::trailer_deny`]. Absent or empty exempts\n**nothing**, which is how a silent posture is expressed as data.", "type": "array", From 2c15d6282da0769ed5458d78f8db92f7f76c6ce9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 12 Sep 2026 03:21:15 +0000 Subject: [PATCH 2/9] fix(ci): a release leg provisions what it needs, not every pinned tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release-artifacts.yml` said this in its own comment and nothing acted on it: the provisioning step passes no `install_args`, so every dist leg installs every `[tools]` entry and the leg's success depends on every one of them resolving — including tools with no part in building a binary. MEASURED. v0.0.160's aarch64-unknown-linux-gnu leg died resolving `zizmor`, a GitHub Actions linter, against Sigstore's TUF CDN before compilation started. The release shipped without that architecture. `renovate` and its 611 npm packages are on the same path. THE UNION OF WHAT A LEG USES, because the matrix mixes build tools and `install_args` is one string: `rust` for the compiler and the rustup that adds the target, `cargo-auditable` for every non-`cross` leg, `zig` and `cargo-zigbuild` for the three zigbuild legs, `syft` for `sbom-binary`, and `gh` for the two upload steps. `cross` is deliberately absent — `install-action` provides it and it is not a `[tools]` entry, so `ci-tools-check` would refuse the name. It unpins nothing: naming tools selects which pinned versions install, so `darwin-link` still links against the same `zig` the release builds with, which is the shared-pin property the workflow's own comment depends on. THE GATE IS PRESENCE, NOT MEMBERSHIP, and the narrowness is the point. Asserting WHICH tools the list must name would make the module a second authority on what a release build needs — the objection `ci-parity`'s own header raises against re-deriving the task graph. `ci-tools-check` already holds the other direction, that every name in a list resolves to a `[tools]` entry. What had no gate is the list existing at all, which is the half whose absence is silent. THE JOB IS DERIVED, NEVER NAMED, for `bats-invocation`'s measured reason: a clause naming `jobs.dist` would keep asserting about a job that no longer builds the release the moment the work moved, staying green over the one that does. An empty string is refused as firmly as an absent key, because `mise-action` reads it as "install everything" — a half-finished narrowing wearing the shape of a list. That arm is asserted at the engine tier on purpose: whether YAML renders a valueless key as an empty string or as null is the boundary's answer, not the module's. Both anti-vacuity partners ship with it — a narrowed leg passes, and a job that builds no release artifact is not this arm's business — so the gate cannot be satisfied by refusing everything. Refs: CLOUD-1786 --- .github/workflows/release-artifacts.yml | 26 ++++++ batten.toml | 20 +++++ crates/batten/tests/it/ci_parity.rs | 71 +++++++++++++++ policy/ci-parity.rego | 115 ++++++++++++++++++++++++ 4 files changed, 232 insertions(+) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index ff13c2cd3..2f7b16441 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -146,6 +146,32 @@ jobs: # 11s on v0.0.152 and the release published with zero binaries. version: 2026.9.1 cache: false + # NARROWED, BECAUSE "INSTALL EVERYTHING" COST A PLATFORM BINARY + # (CLOUD-1786). Unset, this step installs every `[tools]` entry on + # every leg — and on v0.0.160 the aarch64-unknown-linux-gnu leg died + # resolving `zizmor`, a GitHub Actions linter with no part in building + # a binary, against Sigstore's TUF CDN before compilation started. The + # release shipped without that platform. `renovate` and its 611 npm + # packages are on the same path. + # + # THE UNION OF WHAT A LEG USES, not a per-leg list, because the matrix + # mixes build tools and `install_args` is one string: + # + # rust the compiler, and the rustup that adds the target + # cargo-auditable every non-`cross` leg builds through it + # zig, cargo-zigbuild the three `zigbuild` legs' linker chain + # syft `sbom-binary` on every non-`cross` leg + # gh the two `gh release upload` steps + # + # `cross` is deliberately absent: `taiki-e/install-action` provides it + # below, and it is not a `[tools]` entry — `ci-tools-check` would refuse + # a name here that resolves to none. + # + # This does NOT unpin anything. Naming tools selects which pinned + # versions are installed, so `darwin-link` still links against the same + # `zig` this builds with, which is the shared-pin property the comment + # below depends on. + install_args: rust zig github:rust-cross/cargo-zigbuild github:rust-secure-code/cargo-auditable aqua:anchore/syft aqua:cli/cli - name: Install cross if: matrix.build-tool == 'cross' uses: taiki-e/install-action@91ddec75689c4c78665b598d188dc821c5a43e5c # v2 diff --git a/batten.toml b/batten.toml index dd797fc38..df26b68d3 100644 --- a/batten.toml +++ b/batten.toml @@ -12149,6 +12149,26 @@ id = "task read first" kind = "document" target = "mise.toml" +[[verdict]] +id = "job list loose" +gloss = "a release leg provisions every pinned tool, so any one of them can cost a platform binary" +class = """ +Left unset, the provisioning step installs every pinned tool, and a release \ +leg's success then depends on every one of them resolving — including tools with \ +no part in building a binary. Measured on v0.0.160: the aarch64 Linux leg died \ +resolving a workflow linter against its registry before compilation started, and \ +the release shipped without that architecture. The failure has no local symptom \ +and no bearing on the change that caused it, which is why the list is asserted \ +rather than left to whoever last edited the job. What the list must CONTAIN is \ +deliberately not this class's business — that is the manifest's answer, and a \ +second one here would be a second authority. +""" + +[[verdict.route]] +id = "workflow read first" +kind = "document" +target = ".github/workflows/release-artifacts.yml" + [[verdict]] id = "job list missing" gloss = "a pull-request job is silently unrequired, so green can be reported without it" diff --git a/crates/batten/tests/it/ci_parity.rs b/crates/batten/tests/it/ci_parity.rs index 4445b48e9..a3e99859d 100644 --- a/crates/batten/tests/it/ci_parity.rs +++ b/crates/batten/tests/it/ci_parity.rs @@ -587,6 +587,77 @@ fn a_task_yielding_no_cargo_invocation_is_refused() { ); } +/// A release workflow whose dist leg provisions as the `provisioning` lines say. +/// +/// Written here rather than lifted from the shipped `release-artifacts.yml`: a +/// fixture pasting the real file would re-assert the file under test, and every +/// edit to it would be a fixture edit too. +fn release_workflow(provisioning: &str) -> String { + format!( + "on:\n release:\n types: [published]\njobs:\n dist:\n runs-on: ubuntu-latest\n steps:\n - uses: jdx/mise-action@3c2e0cf8\n with:\n version: 2026.9.1\n{provisioning} - run: mise run dist x86_64-unknown-linux-gnu\n" + ) +} + +#[test] +fn a_release_leg_that_installs_everything_is_refused() { + // THE DEFECT, and it is v0.0.160's shape exactly: unset, the provisioning + // step installs every pinned tool, so the leg's success depends on every one + // of them resolving. That release's aarch64 Linux leg died fetching a + // workflow linter before compilation started and shipped no binary for the + // architecture. Asserted over the compiled binary because the arm reads a + // step's `with:` mapping inside a job's step sequence — a depth a + // `with input as` case fabricates and therefore cannot vouch for. + let root = sound("dist-installs-everything"); + common::write( + &root, + ".github/workflows/release-artifacts.yml", + &release_workflow(""), + ); + assert!( + verdicts_raised(&root).contains(&"job list loose".to_owned()), + "a dist leg with no install_args should be refused: {:?}", + verdicts_raised(&root) + ); +} + +#[test] +fn a_release_leg_that_names_its_tools_is_clean() { + // ANTI-VACUITY. Without this the case above is satisfied by a clause that + // refuses every release leg, which is a gate that never passes — the shape + // that gets switched off in a day. + let root = sound("dist-narrowed"); + common::write( + &root, + ".github/workflows/release-artifacts.yml", + &release_workflow(" install_args: rust zig\n"), + ); + assert!( + !verdicts_raised(&root).contains(&"job list loose".to_owned()), + "a dist leg naming its tools should pass: {:?}", + verdicts_raised(&root) + ); +} + +#[test] +fn an_empty_provisioning_list_does_not_read_as_a_narrowed_one() { + // `mise-action` treats an empty string as "install everything", so a + // half-finished narrowing must not satisfy the gate. This is the arm a + // presence-only reading of the key would get wrong, and the engine tier is + // where it matters: whether YAML renders `install_args:` with no value as an + // empty string or as null is the boundary's answer, not the module's. + let root = sound("dist-empty-list"); + common::write( + &root, + ".github/workflows/release-artifacts.yml", + &release_workflow(" install_args: \"\"\n"), + ); + assert!( + verdicts_raised(&root).contains(&"job list loose".to_owned()), + "an empty install_args should be refused: {:?}", + verdicts_raised(&root) + ); +} + #[test] fn a_pull_request_job_missing_from_the_roster_is_refused() { // CLOUD-327's false green arriving through the roster: the job is not waited diff --git a/policy/ci-parity.rego b/policy/ci-parity.rego index 69a0308c8..b8559fa0c 100644 --- a/policy/ci-parity.rego +++ b/policy/ci-parity.rego @@ -37,6 +37,7 @@ #MUTANT cover-may-skip-the-reach|s@\tlane_reaches(covering)@\ttrue@|a_covered_lane_verify_never_reaches_is_still_refused #MUTANT both-lanes-may-be-named|s@\tnames_the_covering_lane(list, covering)@\tfalse@|a_depends_list_naming_a_lane_and_its_cover_is_refused # +#MUTANT dist-list-unread|s@\tnot provisions_from_a_list(job)@\tfalse@|a_release_leg_that_installs_everything_is_refused #MUTANT-SUITE crates/batten/tests/it/ci_parity.rs # METADATA @@ -72,6 +73,8 @@ rules contains "cargo spelling wrong" rules contains "path reach dead" +rules contains "job list loose" + # --- the manifest, and the guard ---------------------------------------------- # THE MANIFEST IS NOT BOUND TO A TOP-LEVEL RULE, AND THAT IS A MEASURED REPAIR @@ -834,6 +837,57 @@ violation contains { path_varies_between_runs(step) } +# --- a release leg provisions what it needs, not everything (CLOUD-1786) ------ +# +# "INSTALLS EVERYTHING" IS THE PROPERTY THAT COST A PLATFORM BINARY. Unset, +# `mise-action` installs every `[tools]` entry, so a release leg's success +# depends on every pinned tool resolving — including ones with no part in +# building a binary. Measured on v0.0.160: the aarch64-unknown-linux-gnu leg died +# resolving `zizmor`, a GitHub Actions linter, against Sigstore's TUF CDN before +# compilation started, and the release shipped without that architecture. +# `renovate` and its 611 npm packages sit on the same path. +# +# PRESENCE, NOT MEMBERSHIP, and the narrowness is deliberate. Asserting WHICH +# tools the list must name would make this a second authority on what a release +# build needs, which is `mise.toml`'s and `dist.sh`'s to answer — the same +# objection `ci-parity`'s own header raises against re-deriving the task graph +# here. `ci-tools-check` already holds the other direction, that every name in a +# list resolves to a `[tools]` entry. What has no gate is the list existing at +# all, and that is the half whose absence is silent. +# +# THE JOB IS DERIVED, NEVER NAMED, for `bats-invocation`'s measured reason: a +# clause naming `jobs.dist` would keep asserting about a job that no longer +# builds the release the moment the work moved, staying green over the one that +# does. So the subject is whichever job runs the dist task, and it follows it. +# +# `install_args` is read as non-empty rather than merely present: an empty string +# is what a half-finished narrowing leaves behind, and `mise-action` treats it as +# "install everything" — the exact state this refuses, wearing the shape of a +# list. + +builds_a_release_artifact(job) if { + some step in job.steps + contains(object.get(step, "run", ""), "mise run dist") +} + +provisions_from_a_list(job) if { + some step in job.steps + startswith(object.get(step, "uses", ""), "jdx/mise-action@") + object.get(step, ["with", "install_args"], "") != "" +} + +violation contains { + "rule": "job list loose", + "verdict": "job list loose", + "subjects": [{"path": path}, {"artifact": name}], +} if { + governed + some path, _ in workflow + some name, job in workflow[path].jobs + builds_a_release_artifact(job) + not provisions_from_a_list(job) +} + # --- could not look ----------------------------------------------------------- # # A DECLARED SOURCE THAT WOULD NOT PARSE is not an absent one. Absent is @@ -1037,6 +1091,67 @@ swap(key, doc) := out if { out := {"tree": object.union(object.remove(sound_input.tree, ["documents"]), {"documents": docs})} } +# --- a release leg's provisioning list (CLOUD-1786) -------------------------- + +# The dist job as `release-artifacts.yml` carries it, reduced to the two steps +# this arm reads. Built here rather than pasted from the real workflow: a fixture +# carrying the shipped file would re-assert the file under test. +release_leg(provisioning) := { + "on": {"release": {"types": ["published"]}}, + "jobs": {"dist": { + "runs-on": "ubuntu-latest", + "steps": [ + object.union({"uses": "jdx/mise-action@3c2e0cf8"}, provisioning), + {"run": "mise run dist x86_64-unknown-linux-gnu"}, + ], + }}, +} + +# THE DEFECT: every pinned tool on every leg, so an unrelated tool's registry +# outage costs a platform binary. This is v0.0.160's shape. +test_a_release_leg_that_installs_everything_is_refused if { + found := violation with input as swap(".github/workflows/release-artifacts.yml", release_leg({"with": {"version": "2026.9.1"}})) + some f in found + f.verdict == "job list loose" + some s in f.subjects + s.artifact == "dist" +} + +# ANTI-VACUITY. Without this the arm is satisfied by a clause refusing every +# release leg, which is a gate that never passes. +test_a_release_leg_that_names_its_tools_is_clean if { + narrowed := release_leg({"with": {"version": "2026.9.1", "install_args": "rust zig"}}) + found := violation with input as swap(".github/workflows/release-artifacts.yml", narrowed) + every f in found { + f.verdict != "job list loose" + } +} + +# AN EMPTY STRING IS NOT A LIST. `mise-action` reads it as "install everything", +# so a half-finished narrowing must not read as a finished one. +test_an_empty_provisioning_list_is_refused if { + empty := release_leg({"with": {"version": "2026.9.1", "install_args": ""}}) + found := violation with input as swap(".github/workflows/release-artifacts.yml", empty) + some f in found + f.verdict == "job list loose" +} + +# NOT-APPLICABLE, NEVER A VACUOUS PASS. A job that builds no release artifact has +# nothing to answer for here, whatever it provisions. +test_a_job_that_builds_no_artifact_is_not_this_arms_business if { + other := { + "on": {"release": {"types": ["published"]}}, + "jobs": {"notes": { + "runs-on": "ubuntu-latest", + "steps": [{"uses": "jdx/mise-action@3c2e0cf8"}, {"run": "mise run release-notes"}], + }}, + } + found := violation with input as swap(".github/workflows/release-artifacts.yml", other) + every f in found { + f.verdict != "job list loose" + } +} + # --- the foreign cargo spelling ---------------------------------------------- # A foreign leg running something `test:cargo` does not declare is the whole From a57222a1fe8008b4de33d0477b4d0c6a14aa03db Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 12 Sep 2026 03:32:11 +0000 Subject: [PATCH 3/9] fix(land): read why the bot refused instead of asserting non-descent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grade()` maps a `failure` conclusion to `Answer::Refused`, whose own doc says "the branch is no longer a direct descendant", and `lib.rs` printed exactly that. The bot has FOUR refusal grounds and that sentence names one of them. MEASURED, PR #895: the bot's log read `refusing #895: draft head, no graded checks (CLOUD-853)`. `main` had not moved and the branch was a perfect descendant. The head was a draft because `land` re-drafts on a failed lap — so the loop created the refusing condition, reported it as an external one, and narrated a remedy (rebase, re-verify, retry) that regenerates it. The reader went looking for a moved trunk. A FIFTH STATE THE ROW DOES NOT ENUMERATE, measured this session: the run failed before any refusal logic ran. `fast-forward.yml` invokes `./install.sh`, v0.0.161 carried no binaries, the step died — conclusion `failure`, no refusal posted, and `land` still said "no longer a direct descendant" and recommended a re-run. Every implied fact was false and two laps were spent on it. THE PORT MADE IT WORSE, WHICH IS WHY THE ROW'S SURFACE MOVED. CLOUD-1617 names `mise-tasks/land.sh:2249`, retired in `a2916756`. The shell at least held the bot's refusal in `$refused` and discarded its content; `fast_forward.rs` never reads it at all — `answer()` sees only the run's conclusion. A LOOKUP, NEVER A JUDGEMENT (rule 3). Each refusing arm of `fast-forward.yml` posts a comment through the same endpoint this module already posts the directive to, and each names the row it enforces. So the ground is read out of the bot's own text by that key. Nothing here decides what prose MEANS. `GROUNDS` is the four in one place a fifth must be added to. An unlisted key resolves to `Unclassified`, which narrates as could-not-look — the safe direction, and the one the predecessor did not have. `Unclassified` also covers no-comment-at-all, which is what a run that died early looks like from here. Each ground now carries the remedy that actually clears it: readying for a draft head, because rebasing cannot; a review for an unreviewed fork head; waiting for a matrix that has not graded. The exit code is unchanged — `Violation` already stopped the lap, and the defect was never the stop, it was the sentence. `startsWith`, never `contains`, on the refusal body: the directive and a human quoting a refusal live in the same comment list, which is the discipline `fast-forward.yml` applies to its own trigger after a comment DISCUSSING the trigger fired it. Four falsifiers, including the anti-vacuity term over the table itself: every declared key must resolve to its own ground, so a lookup that resolves one row and drops the rest cannot pass as caution. Refs: CLOUD-1617 --- crates/batten/src/fast_forward.rs | 151 ++++++++++++++++++++++++++++++ crates/batten/src/lib.rs | 49 +++++++++- 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/crates/batten/src/fast_forward.rs b/crates/batten/src/fast_forward.rs index 1bed2956b..bcbe7057b 100644 --- a/crates/batten/src/fast_forward.rs +++ b/crates/batten/src/fast_forward.rs @@ -475,6 +475,106 @@ fn grade(conclusion: &str) -> Answer { } } +/// Why the bot refused, as **it** said so (CLOUD-1617). +/// +/// A `failure` conclusion says a run refused; it does not say on which ground, +/// and the bot has four. The predecessor asserted non-descent from that bare +/// token and was measured wrong: on PR #895 the log read `refusing #895: draft +/// head, no graded checks (CLOUD-853)`, `main` had not moved, the branch was a +/// perfect descendant — and the head was a draft because `land` itself re-drafts +/// on a failed lap. The loop created the refusing condition, reported it as an +/// external one, and lapped against something no rebase can change. +/// +/// **A LOOKUP, NEVER A JUDGEMENT** (non-negotiable rule 3). Each refusing arm of +/// `fast-forward.yml` posts a comment naming the row it enforces, so the ground +/// is read out of the bot's own text by that key rather than inferred from +/// anything. What this never does is decide what the prose MEANS. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Ground { + /// A draft head grades no required check (`CLOUD-853`). Lapping cannot clear + /// it — `land` re-drafts on a failed lap, so the remedy is to ready the pull + /// request, which no amount of rebasing does. + Draft, + /// A fork head whose green CI is the contributor's own harness (`CLOUD-867`). + ForkUnreviewed, + /// A head whose required roster has not answered (`CLOUD-1570`). + RosterUngraded, + /// The bot refused and named no ground this build recognises — including the + /// case where it posted no refusal at all, which is what a run that DIED + /// before reaching its refusal logic looks like from here. + /// + /// A COULD-NOT-LOOK, NEVER A VERDICT. Reading it as non-descent is the defect + /// CLOUD-1617 records, and reading it as anything else would be the same + /// mistake wearing a different cause. + Unclassified, +} + +/// THE FOUR GROUNDS, IN ONE PLACE A FIFTH MUST BE ADDED TO. +/// +/// `fast-forward.yml` gaining another refusing arm cannot silently fall into an +/// existing bucket: an unlisted key reads as [`Ground::Unclassified`], which +/// narrates as could-not-look rather than as somebody else's cause. That is the +/// safe direction, and it is the one the predecessor did not have. +const GROUNDS: &[(&str, Ground)] = &[ + ("CLOUD-853", Ground::Draft), + ("CLOUD-867", Ground::ForkUnreviewed), + ("CLOUD-1570", Ground::RosterUngraded), +]; + +/// The ground named in one refusal comment body, or `None` if it is not one. +/// +/// Anchored on the bot's own opening words, so a human quoting a refusal — or +/// the `/fast-forward` directive itself — is not read as one. The same +/// `startsWith`-not-`contains` discipline `fast-forward.yml` applies to its own +/// trigger, and for the same measured reason (CLOUD-853, PR #624). +#[must_use] +pub fn ground_in(body: &str) -> Option { + if !body.starts_with("Refusing to fast-forward") { + return None; + } + for (key, ground) in GROUNDS { + if body.contains(key) { + return Some(ground.clone()); + } + } + Some(Ground::Unclassified) +} + +/// Read back why the bot refused, from the comments it posted on the pull +/// request. +/// +/// THE COMMENT RATHER THAN THE JOB LOG, and that is what makes this a read the +/// engine can already make: every refusing arm posts one through +/// `repos/{repo}/issues/{pr}/comments`, which is the endpoint this module +/// already POSTs the directive to. The log would need the archive endpoint and a +/// zip reader for the same answer. +/// +/// Unreadable is [`Ground::Unclassified`], for [`run`]'s reason: a failure to +/// reach the forge is a could-not-look, and a could-not-look must not become a +/// claim about the branch. +#[must_use] +pub fn ground(ask: &Ask) -> Ground { + let path = format!("repos/{}/issues/{}/comments?per_page=100", ask.repo, ask.pr); + let Some(raw) = run(&path) else { + return Ground::Unclassified; + }; + let Ok(value) = serde_json::from_str::(&raw) else { + return Ground::Unclassified; + }; + let Some(comments) = value.as_array() else { + return Ground::Unclassified; + }; + // THE LAST ONE, because a pull request that has lapped carries the refusals + // of earlier laps too and the newest is this lap's. The endpoint returns + // oldest first. + comments + .iter() + .filter_map(|comment| comment.get("body").and_then(serde_json::Value::as_str)) + .filter_map(ground_in) + .next_back() + .unwrap_or(Ground::Unclassified) +} + /// One REST call, or `None` where the forge could not be reached. /// /// **IN PROCESS, over [`crate::rest`].** This was a `gh` spawn annotated @@ -511,6 +611,57 @@ mod tests { assert_eq!(comment_id(r#"{"nothing": true}"#), None); } + /// The refusal `fast-forward.yml` actually posts for a draft head, quoted + /// from the workflow rather than paraphrased: a fixture inventing the wording + /// would pass over a bot whose wording moved. + const DRAFT_REFUSAL: &str = "Refusing to fast-forward #895: it is a draft, so no required check has graded its head. `main` must not advance to a SHA CI never ran on (CLOUD-853). Mark it ready for review, let CI grade the head, then ask again."; + + #[test] + fn the_ground_is_read_from_the_row_the_bot_names() { + // THE MEASURED CASE, PR #895. A bare `failure` conclusion says a run + // refused and nothing more; the bot's own comment says which of four + // grounds, and it names its row so the mapping is a lookup. + assert_eq!(ground_in(DRAFT_REFUSAL), Some(Ground::Draft)); + } + + #[test] + fn each_declared_ground_is_reachable_from_its_own_key() { + // ANTI-VACUITY over the table. Without this, `GROUNDS` is satisfied by a + // lookup that resolves one row and drops the rest — every other refusal + // silently becoming `Unclassified`, which reads as caution and is really + // coverage quietly going to zero. + for (key, expected) in GROUNDS { + let body = format!("Refusing to fast-forward #1: because reasons ({key})."); + assert_eq!( + ground_in(&body).as_ref(), + Some(expected), + "the table's own key {key} must resolve to its ground" + ); + } + } + + #[test] + fn a_refusal_naming_an_unknown_row_is_unclassified_rather_than_descent() { + // A FIFTH ARM ADDED TO THE WORKFLOW must not fall into an existing + // bucket. Unclassified narrates as could-not-look; anything else would be + // the engine asserting somebody else's cause, which is CLOUD-1617. + let body = "Refusing to fast-forward #1: some new ground (CLOUD-9999)."; + assert_eq!(ground_in(body), Some(Ground::Unclassified)); + } + + #[test] + fn a_comment_that_is_not_a_refusal_is_not_read_as_one() { + // The directive itself, and a human quoting a refusal, both live in the + // same comment list. `startsWith`, never `contains` — the discipline + // `fast-forward.yml` applies to its own trigger after a comment + // DISCUSSING the trigger fired it (CLOUD-853, PR #624). + assert_eq!(ground_in("/fast-forward"), None); + assert_eq!( + ground_in(&format!("I think this is wrong: {DRAFT_REFUSAL}")), + None + ); + } + #[test] fn the_window_is_enforced_client_side_and_not_only_by_the_query() { // The server-side `created` bound is an optimisation; this is the fence. diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 37ca9a8b0..60f98c6e5 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8952,12 +8952,51 @@ fn run_land_fast_forward( writeln!(out, "land: #{} was accepted", ask.pr)?; Ok(ExitCode::Success) } + // THE GROUND IS READ, NEVER ASSERTED (CLOUD-1617). This line used + // to say "this head is no longer a direct descendant" from a bare + // `failure` conclusion, which names one of the bot's four grounds + // and was measured wrong: on PR #895 the refusal was a DRAFT head + // (CLOUD-853), `main` had not moved, the branch was a perfect + // descendant — and the head was a draft because `land` re-drafts + // on a failed lap. The loop created the condition, reported it as + // external, and sent its reader looking for a moved trunk. + // + // The bot names its row in the comment it posts, so this is a + // lookup. What it must never do is fill the gap with a cause. fast_forward::Answer::Refused => { - writeln!( - out, - "land: #{} was refused; this head is no longer a direct descendant", - ask.pr - )?; + match fast_forward::ground(&ask) { + // LAPPING CANNOT CLEAR THIS ONE, which is why it says so. + // `land` re-drafts on a failed lap, so the remedy the + // predecessor narrated — rebase, re-verify, retry — + // regenerates exactly the condition being refused. + fast_forward::Ground::Draft => writeln!( + out, + "land: #{} was refused — a draft head grades no required check (CLOUD-853). Rebasing cannot clear it; ready the pull request, let CI grade the head, then land again", + ask.pr + )?, + fast_forward::Ground::ForkUnreviewed => writeln!( + out, + "land: #{} was refused — a fork head's green CI is the contributor's own harness (CLOUD-867). Read the diff and approve, then land again", + ask.pr + )?, + fast_forward::Ground::RosterUngraded => writeln!( + out, + "land: #{} was refused — the required roster has not graded this head (CLOUD-1570). Wait for the matrix rather than rebasing", + ask.pr + )?, + // EVERYTHING ELSE, INCLUDING A RUN THAT NEVER REACHED ITS + // REFUSAL. Measured this session: `fast-forward.yml` runs + // `./install.sh`, v0.0.161 carried no binaries, and the + // run died at that step — conclusion `failure`, no + // refusal posted, and the predecessor called it + // non-descent and recommended a re-run. Every implied + // fact was false and two laps were spent on the advice. + fast_forward::Ground::Unclassified => writeln!( + out, + "land: #{} was refused and the bot named no ground this build recognises — read the run before rebasing; it may have failed before reaching its refusal", + ask.pr + )?, + } Ok(ExitCode::Violation) } fast_forward::Answer::Pending => { From 933f2f76ca858033452386cc173fe624904badae Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 12 Sep 2026 03:34:03 +0000 Subject: [PATCH 4/9] fix(ci): a missing release credential fails the job instead of downgrading silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }}` appears three times in `release-plz.yml` and three more in `auto-release-land.yml`. With the secret absent, every one silently becomes the default job token and the run reports green — indistinguishable from a correct release, and less capable in the one way that matters. THE DOWNGRADE IS THE OUTAGE THIS BRANCH EXISTS FOR, arriving by a second route. GitHub fires NO workflow events for the default token, so a tag cut under it reaches no `release-artifacts` run and publishes with no binaries. That is exactly how v0.0.159, v0.0.160 and v0.0.161 shipped (CLOUD-1789) — there the token was valid and `[env]` clobbered it; here the token is simply absent and the `||` supplies the same broken credential with no signal at all. THE FALLBACK IS KEPT. On a fork or a secretless checkout the default token is the only credential there is, and refusing would remove a capability rather than protect anything. What is refused is the fallback being taken SILENTLY on the canonical repository, which is the only place a release is cut. The shape is the reviewed precedent twelve lines down in the same job — "Release tracking requires its credential", which already fails a tagged push on an empty `LINEAR_ACCESS_KEY` — and its stated reasoning carries over exactly: the secret is asserted to exist, nothing in this tree can confirm it, so a step is where a wrong assertion surfaces. Placed FIRST, before anything consumes the credential; a guard after the checkout it guards reports on work already done. `github.event.repository.fork` rather than a repository name, which would go stale on a rename and says nothing the fork check does not. THIS DOES NOT CLOSE CLOUD-94. Its subject is migrating to an org-owned GitHub App so the release credential stops being a personal PAT with an expiry — bus-factor one on release-critical CI. That needs an App installed on the org plus APP_ID and APP_PRIVATE_KEY secrets, which is credential work outside this session's reach, and the row stays open with that half named. The guard is not made redundant by it either: a missing APP_ID lands in the identical `||`. Refs: CLOUD-94, CLOUD-1789 --- .github/workflows/release-plz.yml | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 444b39f12..a1e688be6 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -459,6 +459,41 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} steps: + # THE `||` IS A SILENT DOWNGRADE, AND THIS IS WHERE IT STOPS BEING ONE + # (CLOUD-94). `${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }}` + # appears three times below and three more in `auto-release-land.yml`. With + # the secret absent, every one of them silently becomes the default job + # token and the run reports green — indistinguishable from a correct + # release, and less capable in the one way that matters: GitHub fires NO + # workflow events for the default token, so a tag cut under it reaches no + # `release-artifacts` run and publishes with no binaries. That is exactly + # how v0.0.159, v0.0.160 and v0.0.161 shipped (CLOUD-1789). + # + # THE FALLBACK IS KEPT, because on a fork or a secretless checkout it is + # correct — there the default token is the only credential there is, and + # refusing would remove a capability rather than protect anything. What is + # refused is the fallback being taken SILENTLY on the canonical repository, + # which is the only place a release is actually cut. + # + # The same shape as "Release tracking requires its credential" below, and + # for its reason stated there: the secret is asserted to exist, nothing in + # this tree can confirm it, so a step is where a wrong assertion surfaces. + # FIRST, before anything consumes the credential — a guard after the + # checkout it guards would be reporting on work already done. + # + # `github.event.repository.fork` rather than a repository name: a literal + # here would go stale on a rename and says nothing a fork check does not. + # The App migration does not retire this guard — a missing + # APP_ID/APP_PRIVATE_KEY lands in the identical `||`. + - name: The release credential must not fall back silently + if: github.event.repository.fork != true + env: + RELEASE_PLZ_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} + run: | + if [ -z "$RELEASE_PLZ_TOKEN" ]; then + echo "::error:: RELEASE_PLZ_TOKEN is empty, so every credential below would fall back to the default job token. GitHub fires no workflow events for it, so the tag this cuts would reach no release-artifacts run and publish with no binaries (CLOUD-1789). Set the secret; do not remove this guard." >&2 + exit 1 + fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 From 415d2fbb1086f56cff89e0548b2c31f5c710302a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 12 Sep 2026 05:34:58 +0000 Subject: [PATCH 5/9] fix(land): sweep the fourth branch-keyed receipt family on retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BRANCH_KEYED_RECEIPTS` names three families and `unlanded-nudged` is a fourth. `unlanded_pointer` writes `unlanded-nudged.` into the same directory with the same `{family}.{slug}` shape this list matches; it was simply never added, so the family accumulates forever and no retirement sweeps it. THE LIST'S OWN COMMENT PREDICTED THIS, having been caught once already: `filed-set-nudged` is here and was NOT in the predecessor's pair … A port that copied the two literals would have left one family accumulating forever, which is the drift a named list exists to stop. WHAT THE SURVIVAL COSTS, and it is the worst of the four to leave behind. The unlanded nudge is once-per-claim by design (CLOUD-890): `¬landed` is a level the agent cannot clear inside the turn it is asked to, so a key the remedy can mint would retrigger on every commit. The suppression file therefore exists precisely on the branches that stopped with work off the landing target — and left there, the next piece of work to reuse the branch name inherits it. The nudge that gets silenced is rule 1 of the ladder, promoted there deliberately: the others say the turn was untidy, this one says the work does not exist anywhere but here and a container reclaim ends it. Found while building CLOUD-1390's refusal, which needs this file cleared by landing as its spend. The clearing is correct on its own terms and lands separately from anything that reads it. The fixture gains the family rather than a second case: the existing sweep test already asserts every listed family goes and that a sha-keyed receipt survives, so the regression term is the list it iterates. Refs: CLOUD-1390, CLOUD-774 --- crates/batten/src/land.rs | 19 ++++++++++++++++++- crates/batten/tests/it/land_entry_gates.rs | 13 ++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/batten/src/land.rs b/crates/batten/src/land.rs index d9a4b3b72..6073fd36d 100644 --- a/crates/batten/src/land.rs +++ b/crates/batten/src/land.rs @@ -1776,7 +1776,24 @@ pub struct Retired { /// under the same key shape, and it landed after the bash cleanup was written. A /// port that copied the two literals would have left one family accumulating /// forever, which is the drift a named list exists to stop. -const BRANCH_KEYED_RECEIPTS: &[&str] = &["board-writes", "filed-here-nudged", "filed-set-nudged"]; +/// `unlanded-nudged` is the fourth, and its absence was the same drift one more +/// time (CLOUD-1390). `unlanded_pointer` writes +/// `unlanded-nudged.` under this same directory and keys the +/// suppression by the completion finding's own fingerprint, so the family has +/// the shape this list matches and was simply never added to it. +/// +/// **What that costs is a SUPPRESSION that outlives the work it was about.** The +/// nudge is once-per-claim by design — the agent cannot clear `¬landed` inside +/// the turn it is asked to — so the file exists precisely on the branches that +/// stopped with work unlanded. Left behind, the next piece of work to reuse the +/// name inherits it, and the one nudge that says *the work exists nowhere but +/// here and a container reclaim ends it* is the one that does not fire. +const BRANCH_KEYED_RECEIPTS: &[&str] = &[ + "board-writes", + "filed-here-nudged", + "filed-set-nudged", + "unlanded-nudged", +]; /// Retire a branch whose pull request has merged. /// diff --git a/crates/batten/tests/it/land_entry_gates.rs b/crates/batten/tests/it/land_entry_gates.rs index 12467e201..62b2c6f4d 100644 --- a/crates/batten/tests/it/land_entry_gates.rs +++ b/crates/batten/tests/it/land_entry_gates.rs @@ -240,7 +240,18 @@ fn retiring_a_landed_branch_drops_every_branch_keyed_receipt() { // sweep spelling the slug differently would delete nothing and report a clean // count — the silent-empty-answer shape, one layer down. let branch = "claude/some-work"; - let families = ["board-writes", "filed-here-nudged", "filed-set-nudged"]; + // `unlanded-nudged` is the fourth and was missing from the sweep (CLOUD-1390). + // It is the one whose survival costs most: the nudge is once-per-claim + // because the agent cannot clear `¬landed` inside the turn it is asked to, so + // the file exists precisely on branches that stopped with work unlanded — + // and a reused branch name would inherit the suppression, silencing the one + // nudge that says the work exists nowhere but here. + let families = [ + "board-writes", + "filed-here-nudged", + "filed-set-nudged", + "unlanded-nudged", + ]; for family in families { std::fs::write(store.join(format!("{family}.claude-some-work")), "x\n") .expect("write a receipt"); From a5a8dd3ffdfdc8a20d65f436d7647b5574ec2967 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 15 Sep 2026 03:04:27 +0000 Subject: [PATCH 6/9] fix(land): report the bot's own refusal instead of mapping it to remedies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `39884179` read a `failure` conclusion, matched the bot's comment against a `GROUNDS` table and narrated an engine-side remedy per ground. Two things were wrong with it. The table put one consumer's tracker keys into `crates/batten` as matched DATA — the shape CLOUD-48 moved out of `hook.rs`, and what non-negotiable rule 1 refuses. It also made the engine a second, staler authority on somebody else's refusal: a fifth arm added to `fast-forward.yml` would have been misfiled into an existing bucket until a batten release caught up. `refusal` replaces it. It reads the comment the bot posted through the endpoint this module already POSTs the directive to, and returns its first line. It classifies nothing (rule 3) and carries one line rather than the body (rule 4), so a ground this build has never seen is still narrated correctly and a refusal that was never posted stays a could-not-look. Also repairs the two clippy errors `d2ed59ac` shipped red — `lint:clippy` is skipped at pre-commit as slow, so neither was seen. `unused_self` becomes an associated fn; `match_same_arms` takes an `#[allow]`, because merging the arms would put a `|` in the line `mutate`'s row parser splits on. Dropping the table also takes `run_land_fast_forward` back under the 100-line bound it had crossed at 104. Refs: CLOUD-1617 --- crates/batten/src/attribution.rs | 21 ++-- crates/batten/src/fast_forward.rs | 196 +++++++++++++----------------- crates/batten/src/lib.rs | 46 +++---- 3 files changed, 113 insertions(+), 150 deletions(-) diff --git a/crates/batten/src/attribution.rs b/crates/batten/src/attribution.rs index 607207c23..2ff44a1cb 100644 --- a/crates/batten/src/attribution.rs +++ b/crates/batten/src/attribution.rs @@ -330,12 +330,10 @@ impl Attribution { /// `identity_deny` is still consulted, so an identity that is both permitted /// and denied is refused. Config that contradicts itself is a finding, not a /// pass, and the deny list is the half that wins. - fn tagger_is_accountable( - &self, - rendered: &str, - permitted: &Matchers, - denied: &Matchers, - ) -> bool { + /// Free of `self` deliberately: the two lists are passed in already compiled, + /// so this is a pure predicate over them and the policy it came from is not a + /// second input it could disagree with. + fn tagger_is_accountable(rendered: &str, permitted: &Matchers, denied: &Matchers) -> bool { permitted.matches(rendered) && !denied.matches(rendered) } @@ -375,11 +373,16 @@ impl Attribution { // with `|`. They answer the same way today, and keeping them apart costs a // line while buying two things: the match stays exhaustive if a fourth // variant lands, and each arm is independently mutatable — a `|` inside a - // mutated expression is split as a field separator by the sweep and the - // row is refused. + // mutated expression is split as a field separator by `mutate`'s own row + // parser, so the declared row would be refused as five fields. + // + // That is the whole reason for the allow. Merging them, which is what + // clippy suggests, would put a `|` in the one place this repository's + // mutation sweep cannot read. + #[allow(clippy::match_same_arms)] Ok(match tagger { git::Tagger::Signed(rendered) => { - if self.tagger_is_accountable(rendered, &permitted, &denied) { + if Self::tagger_is_accountable(rendered, &permitted, &denied) { Vec::new() } else { vec![point("tagger")] diff --git a/crates/batten/src/fast_forward.rs b/crates/batten/src/fast_forward.rs index bcbe7057b..ec98b9374 100644 --- a/crates/batten/src/fast_forward.rs +++ b/crates/batten/src/fast_forward.rs @@ -475,106 +475,77 @@ fn grade(conclusion: &str) -> Answer { } } -/// Why the bot refused, as **it** said so (CLOUD-1617). +/// The bot's own refusal sentence, or `None` where there is none to read +/// (CLOUD-1617). /// /// A `failure` conclusion says a run refused; it does not say on which ground, -/// and the bot has four. The predecessor asserted non-descent from that bare -/// token and was measured wrong: on PR #895 the log read `refusing #895: draft -/// head, no graded checks (CLOUD-853)`, `main` had not moved, the branch was a -/// perfect descendant — and the head was a draft because `land` itself re-drafts -/// on a failed lap. The loop created the refusing condition, reported it as an +/// and `fast-forward.yml` refuses on several. The predecessor asserted +/// non-descent from that bare token and was measured wrong: on PR #895 the +/// refusal was a draft head, `main` had not moved, the branch was a perfect +/// descendant — and the head was a draft because `land` itself re-drafts on a +/// failed lap. The loop created the refusing condition, reported it as an /// external one, and lapped against something no rebase can change. /// -/// **A LOOKUP, NEVER A JUDGEMENT** (non-negotiable rule 3). Each refusing arm of -/// `fast-forward.yml` posts a comment naming the row it enforces, so the ground -/// is read out of the bot's own text by that key rather than inferred from -/// anything. What this never does is decide what the prose MEANS. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Ground { - /// A draft head grades no required check (`CLOUD-853`). Lapping cannot clear - /// it — `land` re-drafts on a failed lap, so the remedy is to ready the pull - /// request, which no amount of rebasing does. - Draft, - /// A fork head whose green CI is the contributor's own harness (`CLOUD-867`). - ForkUnreviewed, - /// A head whose required roster has not answered (`CLOUD-1570`). - RosterUngraded, - /// The bot refused and named no ground this build recognises — including the - /// case where it posted no refusal at all, which is what a run that DIED - /// before reaching its refusal logic looks like from here. - /// - /// A COULD-NOT-LOOK, NEVER A VERDICT. Reading it as non-descent is the defect - /// CLOUD-1617 records, and reading it as anything else would be the same - /// mistake wearing a different cause. - Unclassified, -} - -/// THE FOUR GROUNDS, IN ONE PLACE A FIFTH MUST BE ADDED TO. -/// -/// `fast-forward.yml` gaining another refusing arm cannot silently fall into an -/// existing bucket: an unlisted key reads as [`Ground::Unclassified`], which -/// narrates as could-not-look rather than as somebody else's cause. That is the -/// safe direction, and it is the one the predecessor did not have. -const GROUNDS: &[(&str, Ground)] = &[ - ("CLOUD-853", Ground::Draft), - ("CLOUD-867", Ground::ForkUnreviewed), - ("CLOUD-1570", Ground::RosterUngraded), -]; - -/// The ground named in one refusal comment body, or `None` if it is not one. -/// -/// Anchored on the bot's own opening words, so a human quoting a refusal — or -/// the `/fast-forward` directive itself — is not read as one. The same -/// `startsWith`-not-`contains` discipline `fast-forward.yml` applies to its own -/// trigger, and for the same measured reason (CLOUD-853, PR #624). -#[must_use] -pub fn ground_in(body: &str) -> Option { - if !body.starts_with("Refusing to fast-forward") { - return None; - } - for (key, ground) in GROUNDS { - if body.contains(key) { - return Some(ground.clone()); - } - } - Some(Ground::Unclassified) -} - -/// Read back why the bot refused, from the comments it posted on the pull -/// request. -/// -/// THE COMMENT RATHER THAN THE JOB LOG, and that is what makes this a read the -/// engine can already make: every refusing arm posts one through -/// `repos/{repo}/issues/{pr}/comments`, which is the endpoint this module -/// already POSTs the directive to. The log would need the archive endpoint and a -/// zip reader for the same answer. -/// -/// Unreadable is [`Ground::Unclassified`], for [`run`]'s reason: a failure to -/// reach the forge is a could-not-look, and a could-not-look must not become a -/// claim about the branch. +/// **THE BOT'S OWN WORDS, NOT A MAPPING** — [`crate::land::Admitted::Refused`]'s +/// rule at a second site: *the gate is the authority on its own remedy and a +/// summary here would be a second, staler copy of it*. An engine-side table from +/// refusal to remedy would also put one consumer's tracker keys in this crate as +/// matched DATA, which is the shape CLOUD-48 moved out of `hook.rs` and what +/// non-negotiable rule 1 refuses. +/// +/// So this decides nothing about what the prose means (rule 3). It finds the +/// comment the bot posted and returns its first line. +/// +/// **THE COMMENT RATHER THAN THE JOB LOG**, which is what makes this a read the +/// engine can already make: every refusing arm posts through +/// `repos/{repo}/issues/{pr}/comments`, the endpoint this module already POSTs +/// the directive to. The log would need the archive endpoint and a zip reader for +/// the same answer. +/// +/// `None` covers an unreadable forge and a refusal that was never posted — which +/// is what a run that DIED before reaching its refusal logic looks like from +/// here. Both are could-not-look, and neither may become a claim about the +/// branch. #[must_use] -pub fn ground(ask: &Ask) -> Ground { +pub fn refusal(ask: &Ask) -> Option { let path = format!("repos/{}/issues/{}/comments?per_page=100", ask.repo, ask.pr); - let Some(raw) = run(&path) else { - return Ground::Unclassified; - }; - let Ok(value) = serde_json::from_str::(&raw) else { - return Ground::Unclassified; - }; - let Some(comments) = value.as_array() else { - return Ground::Unclassified; - }; + let raw = run(&path)?; + let value = serde_json::from_str::(&raw).ok()?; + let comments = value.as_array()?; // THE LAST ONE, because a pull request that has lapped carries the refusals // of earlier laps too and the newest is this lap's. The endpoint returns // oldest first. comments .iter() .filter_map(|comment| comment.get("body").and_then(serde_json::Value::as_str)) - .filter_map(ground_in) + .filter_map(refusal_line) .next_back() - .unwrap_or(Ground::Unclassified) } +/// The first line of one comment body, if it is a refusal at all. +/// +/// Anchored on the bot's own opening words, so a human quoting a refusal — or the +/// `/fast-forward` directive itself — is not read as one. The same +/// `startsWith`-not-`contains` discipline `fast-forward.yml` applies to its own +/// trigger, after a comment DISCUSSING the trigger fired it (PR #624). +/// +/// **The opener is the one literal, and it is the bot's protocol rather than a +/// consumer's vocabulary**: any repository wiring a fast-forward bot to this +/// lander posts a refusal that begins this way, which is why it can live here +/// while the ROWS a particular bot cites cannot. +/// +/// One line, never the body: a refusal comment carries a paragraph of remedy and +/// the pointer is its first sentence (rule 4). +fn refusal_line(body: &str) -> Option { + if !body.starts_with(REFUSAL_OPENER) { + return None; + } + body.lines().next().map(str::trim).map(ToOwned::to_owned) +} + +/// How every refusing arm of a fast-forward bot opens its comment. +const REFUSAL_OPENER: &str = "Refusing to fast-forward"; + /// One REST call, or `None` where the forge could not be reached. /// /// **IN PROCESS, over [`crate::rest`].** This was a `gh` spawn annotated @@ -617,47 +588,48 @@ mod tests { const DRAFT_REFUSAL: &str = "Refusing to fast-forward #895: it is a draft, so no required check has graded its head. `main` must not advance to a SHA CI never ran on (CLOUD-853). Mark it ready for review, let CI grade the head, then ask again."; #[test] - fn the_ground_is_read_from_the_row_the_bot_names() { + fn the_refusal_is_reported_in_the_bot_s_own_words() { // THE MEASURED CASE, PR #895. A bare `failure` conclusion says a run - // refused and nothing more; the bot's own comment says which of four - // grounds, and it names its row so the mapping is a lookup. - assert_eq!(ground_in(DRAFT_REFUSAL), Some(Ground::Draft)); + // refused and nothing more; the bot's own comment says why. The engine + // repeats that sentence and interprets none of it (rule 3). + assert_eq!( + refusal_line(DRAFT_REFUSAL).as_deref(), + Some(DRAFT_REFUSAL), + "the refusal fits one line, so that line is the whole of it" + ); } #[test] - fn each_declared_ground_is_reachable_from_its_own_key() { - // ANTI-VACUITY over the table. Without this, `GROUNDS` is satisfied by a - // lookup that resolves one row and drops the rest — every other refusal - // silently becoming `Unclassified`, which reads as caution and is really - // coverage quietly going to zero. - for (key, expected) in GROUNDS { - let body = format!("Refusing to fast-forward #1: because reasons ({key})."); - assert_eq!( - ground_in(&body).as_ref(), - Some(expected), - "the table's own key {key} must resolve to its ground" - ); - } + fn only_the_first_line_is_carried_however_long_the_remedy_is() { + // POINTER, NEVER PAYLOAD (rule 4). A refusal comment carries a paragraph + // of remedy under its opening sentence, and a reader that pasted the + // whole body would be reprinting somebody else's document into a lap + // log — the posture this module's header states about response bodies. + let body = format!("{DRAFT_REFUSAL}\n\nA second paragraph of remedy.\nAnd a third."); + assert_eq!(refusal_line(&body).as_deref(), Some(DRAFT_REFUSAL)); } #[test] - fn a_refusal_naming_an_unknown_row_is_unclassified_rather_than_descent() { - // A FIFTH ARM ADDED TO THE WORKFLOW must not fall into an existing - // bucket. Unclassified narrates as could-not-look; anything else would be - // the engine asserting somebody else's cause, which is CLOUD-1617. - let body = "Refusing to fast-forward #1: some new ground (CLOUD-9999)."; - assert_eq!(ground_in(body), Some(Ground::Unclassified)); + fn a_ground_this_build_has_never_seen_is_still_reported() { + // THE WHOLE POINT OF NOT HAVING A TABLE. A fifth arm added to the + // workflow needs no engine release to be narrated, and cannot be + // misfiled into an existing bucket, because nothing here classifies. + // A table would also have put one consumer's tracker keys in this crate + // as matched DATA — non-negotiable rule 1, and the shape CLOUD-48 moved + // out of `hook.rs`. + let body = "Refusing to fast-forward #1: some ground invented after this build shipped."; + assert_eq!(refusal_line(body).as_deref(), Some(body)); } #[test] fn a_comment_that_is_not_a_refusal_is_not_read_as_one() { // The directive itself, and a human quoting a refusal, both live in the - // same comment list. `startsWith`, never `contains` — the discipline + // same comment list. `starts_with`, never `contains` — the discipline // `fast-forward.yml` applies to its own trigger after a comment - // DISCUSSING the trigger fired it (CLOUD-853, PR #624). - assert_eq!(ground_in("/fast-forward"), None); + // DISCUSSING the trigger fired it (PR #624). + assert_eq!(refusal_line("/fast-forward"), None); assert_eq!( - ground_in(&format!("I think this is wrong: {DRAFT_REFUSAL}")), + refusal_line(&format!("I think this is wrong: {DRAFT_REFUSAL}")), None ); } diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 60f98c6e5..4a69ea566 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8964,36 +8964,24 @@ fn run_land_fast_forward( // The bot names its row in the comment it posts, so this is a // lookup. What it must never do is fill the gap with a cause. fast_forward::Answer::Refused => { - match fast_forward::ground(&ask) { - // LAPPING CANNOT CLEAR THIS ONE, which is why it says so. - // `land` re-drafts on a failed lap, so the remedy the - // predecessor narrated — rebase, re-verify, retry — - // regenerates exactly the condition being refused. - fast_forward::Ground::Draft => writeln!( - out, - "land: #{} was refused — a draft head grades no required check (CLOUD-853). Rebasing cannot clear it; ready the pull request, let CI grade the head, then land again", - ask.pr - )?, - fast_forward::Ground::ForkUnreviewed => writeln!( - out, - "land: #{} was refused — a fork head's green CI is the contributor's own harness (CLOUD-867). Read the diff and approve, then land again", - ask.pr - )?, - fast_forward::Ground::RosterUngraded => writeln!( - out, - "land: #{} was refused — the required roster has not graded this head (CLOUD-1570). Wait for the matrix rather than rebasing", - ask.pr - )?, - // EVERYTHING ELSE, INCLUDING A RUN THAT NEVER REACHED ITS - // REFUSAL. Measured this session: `fast-forward.yml` runs - // `./install.sh`, v0.0.161 carried no binaries, and the - // run died at that step — conclusion `failure`, no - // refusal posted, and the predecessor called it - // non-descent and recommended a re-run. Every implied - // fact was false and two laps were spent on the advice. - fast_forward::Ground::Unclassified => writeln!( + // THE BOT'S OWN SENTENCE, or none at all. Nothing here maps a + // refusal to a remedy: the bot is the authority on its own, + // which is `land::Admitted::Refused`'s rule at a second site, + // and a table from its rows to advice would put one consumer's + // tracker keys in this crate as matched data (rule 1). + // + // NO REFUSAL TO READ IS NOT NON-DESCENT. Measured this + // session: `fast-forward.yml` runs `./install.sh`, v0.0.161 + // carried no binaries, and the run died at that step — + // conclusion `failure`, no refusal posted, and the predecessor + // still said "no longer a direct descendant" and recommended a + // re-run. Every implied fact was false and two laps were spent + // on the advice. + match fast_forward::refusal(&ask) { + Some(said) => writeln!(out, "land: #{} was refused — {said}", ask.pr)?, + None => writeln!( out, - "land: #{} was refused and the bot named no ground this build recognises — read the run before rebasing; it may have failed before reaching its refusal", + "land: #{} was refused and posted no reason this build could read — read the run before rebasing; it may have failed before reaching its refusal", ask.pr )?, } From 5e40fda61f028d91e05541a5a5448b43e98247a4 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 15 Sep 2026 03:06:39 +0000 Subject: [PATCH 7/9] feat(rules)!: refuse the next write after a turn stopped with work unlanded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1390 specified this, was marked Done, and shipped nothing. The detection half has existed since CLOUD-1372: `record_state` mints a completion verdict at every `Stop`, and where the turn ended with `HEAD` off its landing target `unlanded_pointer` writes `unlanded-nudged.` into the receipt store. What that bought was advisory text. This session measured an agent reasoning past it twice in a row, then stopping a third time with four rows built and nothing landed. The refusal cannot live at `Stop`. CLOUD-97 and CLOUD-219 each ruled that out for the same reason: committed-and-pushed is the only state surviving a container reclaim, so the path that ends a turn stays free. So the observation is made where the evidence is and the refusal lands where a refusal is allowed — the next mediated write, which is the displacement `claim read unread` already makes one surface over. `while_marker` is the column that says it: a condition on the BRANCH rather than on this call. `when_absent` and `when_present` both project the call the harness handed over, and a punt is a property of the turn before it. It reads presence only — a path test, never a receipt verdict, so nothing is resolved and nothing is acquired, which keeps `modifier_admits` off the filesystem for every row that does not ask (CLOUD-460). The row is `turn mint ahead`: `checks = ["verify"]`, `key = "head"`. Not "land first" — landing needs CI and a merge, which a write cannot perform, and a row whose remedy its own subject cannot run is a dead gate. `key = "head"` is `check read unread`'s stated expiry contract, so each punt costs its own verify rather than one run paying for every punt on the branch. The spend is landing, and `Rule::validate_marker` refuses at load any `while_marker` naming a family `retire_branch` does not sweep — otherwise the deny outlives the work it was about and lands on the next branch to reuse the name, which is CLOUD-774 in the refusing direction. No override route and no `bypass_env`: CLOUD-1311 measured that a field accepting a well-argued sentence gates nothing when the thing being refused is itself a well-argued sentence. `engine-hook` joins the mutation census, so the pair `offer-unread` and `every-ending-punts` is swept: one makes the predicate never hold and reddens the punting-turn case, the other makes it always hold and reddens the anti-vacuity mirror. Without the second, the first is satisfied by a row that refuses every write on every branch. Refs: CLOUD-1390 --- batten.toml | 64 ++++++++ crates/batten/src/config.rs | 1 + crates/batten/src/hook.rs | 56 +++++++ crates/batten/src/land.rs | 9 +- crates/batten/src/rules.rs | 96 ++++++++++- crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/punt_receipt.rs | 210 +++++++++++++++++++++++++ mise.toml | 2 +- schema/batten.local.schema.json | 7 + schema/batten.schema.json | 7 + 10 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 crates/batten/tests/it/punt_receipt.rs diff --git a/batten.toml b/batten.toml index df26b68d3..5f80703d2 100644 --- a/batten.toml +++ b/batten.toml @@ -1361,6 +1361,70 @@ If the row has never been read here, nothing is stored yet: read it \ (`get_issue `) and the capture mints itself. Every route hands over \ bytes the tracker returned, never a re-typed copy; do NOT re-type one by hand.""" +# CLOUD-1390's refusing half — the punt receipt. +# +# `record_state` mints a completion verdict at every `Stop`, and where the turn +# ended with work off its landing target `unlanded_pointer` writes +# `unlanded-nudged.` into the receipt store. That has been true since +# CLOUD-1372. What it bought was a NUDGE — advisory text at the end of a turn — +# and this session measured an agent reasoning past it twice in a row, then +# stopping a third time with four rows built and nothing landed. +# +# THE REFUSAL CANNOT LIVE AT `Stop`, which is what made this a column rather than +# a rule. CLOUD-97 and CLOUD-219 each ruled out a deny on that event, and for the +# same reason: committed-and-pushed is the only state that survives a container +# reclaim, so the path that ends a turn must stay free. A gate there would be a +# gate on the one action whose cost is already paid by losing the work. +# +# So the observation is made where the evidence is and the refusal lands where a +# refusal is allowed: the NEXT MEDIATED WRITE. That is the same displacement +# `claim read unread` above makes — the precondition is due before the work is +# touched, and no command shape can express it — one surface over. +# +# `while_marker` is what the other two modifier columns could not say. +# `when_absent` and `when_present` condition on a projection of THIS call; a punt +# is a property of the turn BEFORE it, and nothing the harness hands over carries +# it. +# +# checks = ["verify"], key = "head" +# The toll is proving the abandoned work is green, on the bytes it was +# abandoned at. Not "land first": landing needs CI and a merge, which a write +# cannot perform, and a row whose remedy its own subject cannot run is a dead +# gate. Verifying is the step immediately before landing and points the same +# direction. +# +# `key = "head"` is the expiry contract `check read unread` states: the receipt +# attests to THESE BYTES. Keyed to the branch instead, one verify run would pay +# for every subsequent punt on that branch — a one-time toll, which is not a +# gate. Keyed to the head, each punt costs its own. +# +# Spent by landing, and only by landing: `retire_branch` sweeps `unlanded-nudged` +# with the other three branch-keyed families, and `Rule::validate_marker` refuses +# at load any `while_marker` naming a family that sweep does not carry — so the +# deny cannot outlive the work it was about. +# +# NO OVERRIDE ROUTE AND NO `bypass_env`, deliberately. CLOUD-1311 measured that a +# prose escape hatch is the thing being fixed rather than a safety valve on it: +# the punt this refuses is itself a well-argued sentence, so a field accepting a +# well-argued sentence gates nothing. The honest escape is the one the remedy +# names. +[[rule]] +id = "turn mint ahead" +kind = "receipt" +scope = "mediated_call" +severity = "deny" +trigger = "write" +while_marker = "unlanded-nudged" +checks = ["verify"] +key = "head" +reason = """ +The last turn ended with work committed nowhere but this container, and a \ +reclaim ends it. Before writing more: run `mise run verify` (background it), \ +then `mise run linear-check`, then `mise run land` — which drives the loop and \ +clears this. If the work is genuinely not ready to land, commit and push what \ +exists first; the receipt attests to the head you stopped at, so a push is what \ +makes stopping safe rather than what makes it final.""" + # The two read-shaped receipts, minted from the result that earned them # (CLOUD-1024). Both rows below DEMAND one of these; these two rows are what # writes them, and they sit here so the writer and the reader are read together. diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index aebb9a10d..9652720df 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -3630,6 +3630,7 @@ fn default_rules() -> Vec { when_absent: None, when_present: None, when_value: None, + while_marker: None, key_from: None, key_base: None, key_shape: None, diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 4f2134f2b..5d9d344f4 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -5348,9 +5348,64 @@ fn modifier_admits(rule: &Rule, envelope: &Envelope) -> bool { return false; } } + // THE BRANCH-KEYED MARKER (CLOUD-1390), and it is last because it is the only + // arm that touches the filesystem. + // + // **PAID ONLY BY A ROW THAT DECLARES IT.** The two arms above read a + // projection already in hand; this resolves a git dir and a branch. Putting + // that cost on every row would be CLOUD-460's regression — `receipt::verdicts` + // ran four git subprocesses and they were being paid by `ls`, by `gh pr view` + // and by every file edit — so the `let Some` guard is the economy, not a + // style. A policy declaring no such row pays nothing and reaches no git call. + // + // **EVERY UNREADABLE ANSWER ADMITS.** No git dir, no branch, an unreadable + // directory: the row is selected as if unconditioned, which leaves the + // verdict exactly where it was before this column existed. The other + // direction — a could-not-look silently DROPPING the row — would turn a + // refusal off on precisely the checkouts least able to notice. + if let Some(marker) = rule.while_marker.as_deref() { + return marker_present(marker); + } true } +/// Whether this branch carries `marker` in the receipt store. +/// +/// Presence and nothing else (CLOUD-1390). [`crate::receipt::validity`] answers +/// whether a receipt PROVES something; a marker carries no conclusion, so there is +/// nothing here to be stale and no second opinion about receipts to drift from. +/// +/// The slug is `branch.replace('/', "-")`, which is the spelling +/// [`crate::land::retire_branch`] sweeps and `unlanded_pointer` writes. One +/// spelling, three readers — a second derivation here is the drift +/// `BRANCH_KEYED_RECEIPTS`' own header records having already been caught once. +fn marker_present(marker: &str) -> bool { + let root = std::path::Path::new("."); + let Ok(git_dir) = crate::git::git_dir(root) else { + return true; + }; + let Ok(Some(branch)) = crate::git::current_branch(root) else { + return true; + }; + marker_path(&git_dir, marker, &branch).exists() +} + +/// Where a branch-keyed marker lives. +/// +/// Named rather than inlined so the `//MUTANT` rows below can anchor on one line, +/// which is also what keeps the `.exists()` — the entire decision — from being +/// buried inside a builder chain no row could swap without touching the path +/// derivation too. +fn marker_path(git_dir: &std::path::Path, marker: &str, branch: &str) -> std::path::PathBuf { + git_dir + .join("batten-receipts") + .join(format!("{marker}.{}", branch.replace('/', "-"))) +} + +//MUTANT-SUITE crates/batten/tests/it/punt_receipt.rs +//MUTANT offer-unread|s@ marker_path(&git_dir, marker, &branch).exists()@ false@|a_write_after_a_punt_is_refused +//MUTANT every-ending-punts|s@ marker_path(&git_dir, marker, &branch).exists()@ true@|an_ordinary_turn_leaves_the_next_write_alone + /// Fold a value for [`Rule::when_value`]'s comparison. /// /// Case-insensitive, and the three separators a tracker's state parameter treats @@ -10368,6 +10423,7 @@ mod tests { when_absent: None, when_present: None, when_value: None, + while_marker: None, key_from: None, key_base: None, key_shape: None, diff --git a/crates/batten/src/land.rs b/crates/batten/src/land.rs index 6073fd36d..693eca43a 100644 --- a/crates/batten/src/land.rs +++ b/crates/batten/src/land.rs @@ -1788,7 +1788,14 @@ pub struct Retired { /// stopped with work unlanded. Left behind, the next piece of work to reuse the /// name inherits it, and the one nudge that says *the work exists nowhere but /// here and a container reclaim ends it* is the one that does not fire. -const BRANCH_KEYED_RECEIPTS: &[&str] = &[ +/// +/// **`pub(crate)` because a `while_marker` row is validated against it** +/// (CLOUD-1390). A marker that landing does not sweep is a refusal with no spend: +/// the row fires, the work lands, the file survives, and the next branch to reuse +/// the name is denied for a punt it did not commit. `Rule::validate_marker` +/// refuses that row at load rather than letting the pair drift, which is the same +/// discipline this list's own header records having needed twice already. +pub(crate) const BRANCH_KEYED_RECEIPTS: &[&str] = &[ "board-writes", "filed-here-nudged", "filed-set-nudged", diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index aab06e8b5..61f212d46 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -204,6 +204,10 @@ const RECEIPT_PERMITS: &[&str] = &[ "when_absent", "when_present", "when_value", + // CLOUD-1390's third polarity: a condition on the BRANCH rather than on this + // call, which is the one a punt needs — the turn that punted is over, and no + // projection of the next call carries it. + "while_marker", "trigger", "reason", "contains", @@ -1545,6 +1549,39 @@ pub struct Rule { /// projection is refused at load, because it can never fire. #[serde(default, skip_serializing_if = "Option::is_none")] pub when_present: Option, + /// A branch-keyed marker whose PRESENCE is this row's firing condition + /// (CLOUD-1390). + /// + /// **The third modifier polarity, and the one the other two cannot express.** + /// [`Rule::when_absent`] and [`Rule::when_present`] condition on a projection + /// of the CALL — what the harness handed over. This conditions on a fact about + /// the BRANCH that an earlier turn left behind, which no field of this call + /// carries. + /// + /// **It exists because a punt is the absence of an action.** A gate refuses + /// actions, and at the moment a turn ends by offering work instead of doing it + /// there is no action to refuse — CLOUD-97 and CLOUD-219 each ruled out making + /// the turn's end a deny, because committing and pushing is what survives a + /// container reclaim and that path must stay free. So the observation is made + /// where the evidence is and the refusal lands where a refusal is allowed: the + /// next mediated write. `ready-guard` and `claim read unread` are the same + /// shape one surface over. + /// + /// **Presence, never validity**, and the distinction is what keeps this from + /// being a second opinion about receipts. [`crate::receipt::validity`] decides + /// whether a receipt PROVES something; this asks only whether a marker file + /// exists. A marker is not a proof and carries no conclusion to be stale. + /// + /// **The spend is deletion, by the sweep that already runs.** A row named here + /// must be one of [`crate::land::BRANCH_KEYED_RECEIPTS`], so landing clears it + /// and nothing else has to know how it is retired — which is also what stops + /// this from becoming a state machine with its own lifecycle. + /// + /// Absent on every row that predates this column, which then behaves exactly + /// as before: the modifiers are additive, and absent means "this row is about + /// the selection alone". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub while_marker: Option, /// The envelope projection a [`Rule::max`] ceiling measures (CLOUD-925). /// /// A [`crate::hook::Field`], reusing the existing named allowlist rather than @@ -3366,6 +3403,25 @@ pub const COLUMN_CENSUS: &[ColumnCensus] = &[ field: "when_present", declares: Declares::NotFactBearing("a condition over a fact another column declared"), }, + ColumnCensus { + field: "while_marker", + // NOT `Fact::Receipts`, and the distinction is the reason this column can + // exist at all (CLOUD-1390). That fact is the boundary RESOLVING receipt + // verdicts — validity, staleness, the key it was filed under — and every + // one of those questions costs the resolution `checks` pays for. This + // column asks whether one path exists. A marker carries no conclusion, so + // there is nothing to resolve and nothing that could come back stale. + // + // Declaring the fact anyway would not be a harmless over-statement: it + // would put receipt resolution on the acquisition list for every row + // carrying this column, which is CLOUD-460's regression — a resolution + // paid for by `ls` and by every file edit — and the whole economy of + // `modifier_admits` reaching the filesystem only for a row that asked. + declares: Declares::NotFactBearing( + "a branch-keyed marker's PRESENCE, which is a path test rather than a \ + receipt verdict — nothing is resolved, so nothing is acquired", + ), + }, ColumnCensus { field: "measures", declares: Declares::NotFactBearing( @@ -4280,7 +4336,44 @@ impl Rule { ))); } } - self.validate_receipt_names() + self.validate_receipt_names()?; + self.validate_marker() + } + + /// A `while_marker` must name a family that landing sweeps (CLOUD-1390). + /// + /// **A REFUSAL NEEDS A SPEND, and this is the only thing that supplies one.** + /// The column's whole contract is *fires while the marker exists*; what makes + /// that finite is [`crate::land::retire_branch`] deleting the file. Name a + /// family outside [`crate::land::BRANCH_KEYED_RECEIPTS`] and the row is a + /// deny nothing clears — the work lands, the marker survives the retirement, + /// and the next piece of work to reuse the branch name is refused for a punt + /// somebody else took. That is CLOUD-774's inherited-suppression defect in the + /// refusing direction, which is strictly worse than the advisory one. + /// + /// Asserted at load rather than documented, because the doc on + /// [`Rule::while_marker`] said exactly this and a sentence in a doc comment + /// stops nobody (non-negotiable rule 2). The pair can now only drift by + /// failing. + /// + /// # Errors + /// + /// A [`UsageError`] (→ exit `1`) naming the row and the marker. Pointer-only: + /// the two ids and the permitted list, never a path from the receipt store. + fn validate_marker(&self) -> anyhow::Result<()> { + let Some(marker) = self.while_marker.as_deref() else { + return Ok(()); + }; + if crate::land::BRANCH_KEYED_RECEIPTS.contains(&marker) { + return Ok(()); + } + Err(UsageError::raise(format!( + "rule {}: `while_marker = \"{marker}\"` names no branch-keyed receipt family, so \ + landing would not clear it and the row would deny forever — including on the next \ + piece of work to reuse the branch name. Name one of: {}", + self.id, + crate::land::BRANCH_KEYED_RECEIPTS.join(", ") + ))) } /// The three refusals over a receipt row's two name columns (CLOUD-1297). @@ -14074,6 +14167,7 @@ mod tests { when_absent: None, when_present: None, when_value: None, + while_marker: None, key_from: None, key_base: None, key_shape: None, diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 5cd9c7bd5..7c2af287c 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -213,6 +213,7 @@ mod process_group; mod prose_only; mod prospective_facts; mod provision; +mod punt_receipt; mod ratchet; mod raw_tracker_read; mod ready; diff --git a/crates/batten/tests/it/punt_receipt.rs b/crates/batten/tests/it/punt_receipt.rs new file mode 100644 index 000000000..8c75abec5 --- /dev/null +++ b/crates/batten/tests/it/punt_receipt.rs @@ -0,0 +1,210 @@ +//! `while_marker` over the compiled binary — CLOUD-1390's refusing half. +//! +//! # Why this tier, and what a unit test structurally cannot reach +//! +//! `hook::modifier_admits` is pure over a `Rule` and an `Envelope`, and +//! `src/hook.rs` drives both polarities of the other two modifier columns against +//! fabricated values. It cannot drive this one: the condition is a FILE, under a +//! git dir this process has to resolve, named for a branch this process has to +//! read. A fabricated input cannot vouch for whether the marker the end of a turn +//! writes is the marker the start of the next one finds — and that crossing is the +//! whole mechanism. `tests/claim_receipt.rs` states the same reason for the same +//! surface one column over. +//! +//! # The pair is the point, and one half alone proves nothing +//! +//! `a_write_after_a_punt_is_refused` is satisfied by a row that refuses every +//! write on every branch forever, which is not a gate but an outage. +//! `an_ordinary_turn_leaves_the_next_write_alone` is what rules that out, and it +//! is the case CLOUD-1390 names as the anti-vacuity mirror. Neither is decoration +//! for the other; the two `#MUTANT` rows on `marker_present` kill exactly one +//! each. +//! +//! # No receipt is ever minted here, deliberately +//! +//! Both cases run with the `verify` receipt ABSENT, so the only thing that differs +//! between them is the marker. A case that minted a receipt would be testing +//! `receipt::validity` — which `tests/claim_receipt.rs` already owns — and would +//! let a broken `while_marker` pass by accident whenever the receipt half happened +//! to decide the same way. + +// 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 std::path::{Path, PathBuf}; + +use common::{Fixture, git_in, run_with_stdin, stderr}; + +/// The committed row's shape, with nothing else declared. +/// +/// Written here rather than read from this repository's own `batten.toml`, on +/// `tests/claim_receipt.rs`'s reason: these cases are about the COLUMN, the +/// committed row is pinned by the census in `tests/cli.rs`, and a fixture that +/// inherited real policy would adjudicate this repo's protected paths too. +const POLICY: &str = r#"version = 1 + +[[rule]] +id = "turn mint ahead" +kind = "receipt" +scope = "mediated_call" +severity = "deny" +trigger = "write" +while_marker = "unlanded-nudged" +checks = ["verify"] +key = "head" +reason = "run `mise run verify`, then `mise run linear-check`, then `mise run land`" +"#; + +/// The branch every case runs on. Carries a `/`, because the marker's filename +/// replaces it and a slug that kept the separator would name a subdirectory that +/// does not exist — a could-not-look the fail-open arm would read as *allow*. +const BRANCH: &str = "claude/cloud-1390-probe"; + +/// A repository on [`BRANCH`], with the row loaded and no receipt store at all. +fn repo(name: &str) -> PathBuf { + let dir = Fixture::new(name) + .config(POLICY) + .file("src/tracked.rs", "// committed\n") + .git() + .base_commit() + .build(); + git_in(&dir, &["checkout", "-q", "-b", BRANCH]); + dir +} + +/// Write the marker the way `unlanded_pointer` does: one empty-bodied file under +/// the git dir, named for the family and the branch with separators replaced. +/// +/// **The body is not read and this helper writes none on purpose.** `while_marker` +/// asks whether the path exists; a helper that wrote a plausible body would let a +/// reader that parsed one pass here while failing against the real thing, which +/// records only a suppression fingerprint. +fn punt(dir: &Path) { + let git_dir = git_in(dir, &["rev-parse", "--absolute-git-dir"]); + let receipts = PathBuf::from(git_dir.trim()).join("batten-receipts"); + std::fs::create_dir_all(&receipts).expect("create the receipt store"); + std::fs::write( + receipts.join(format!("unlanded-nudged.{}", BRANCH.replace('/', "-"))), + "", + ) + .expect("write the marker"); +} + +fn write_payload(path: &str) -> String { + let encoded = serde_json::to_string(path).expect("a path is encodable"); + format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Write\",\ + \"tool_input\":{{\"file_path\":{encoded}}}}}" + ) +} + +fn verdict(dir: &Path, path: &str) -> Option { + run_with_stdin( + dir, + &["adjudicate", "--harness", "exit-code"], + &write_payload(path), + ) + .status + .code() +} + +#[test] +fn a_write_after_a_punt_is_refused() { + // THE MEASURED TURN. Four rows built, nothing landed, a status table offered + // instead — and the next turn wrote files as if nothing had happened. The + // nudge fired and was reasoned past, twice. + let dir = repo("punt-deny"); + punt(&dir); + assert_eq!( + verdict(&dir, "src/tracked.rs"), + Some(2), + "a branch carrying the marker must refuse the next write" + ); + // A new file is the commonest shape of the first edit after a punt, and + // exempting untracked paths would leave the hole open where it is widest. + assert_eq!(verdict(&dir, "src/brand_new.rs"), Some(2)); +} + +#[test] +fn an_ordinary_turn_leaves_the_next_write_alone() { + // ANTI-VACUITY, and the half CLOUD-1390 names. Identical repository, + // identical row, no `verify` receipt either — the ONLY difference is that no + // marker was written. Without this, a row denying every write on every branch + // satisfies the case above. + let dir = repo("punt-allow"); + assert_eq!( + verdict(&dir, "src/tracked.rs"), + Some(0), + "a turn that did not punt owes this row nothing" + ); +} + +#[test] +fn the_refusal_names_the_row_and_its_remedy() { + // Pointer-only (rule 4): the rule id and the route, never the marker's path — + // a receipt-store path is a fact about this checkout and names the branch. + let dir = repo("punt-refusal"); + punt(&dir); + let refusal = stderr(&run_with_stdin( + &dir, + &["adjudicate", "--harness", "exit-code"], + &write_payload("src/tracked.rs"), + )); + assert!( + refusal.contains("turn mint ahead"), + "names the rule: {refusal}" + ); + assert!( + refusal.contains("verify"), + "names the check it wants proved: {refusal}" + ); + // THE PROSE IS NOT ON THIS CHANNEL, and asserting it were would have been + // this case arguing against the posture its own row follows. `reason` is + // reached through `batten policy rule`, which is where a remedy belongs + // (house-style §6, non-negotiable rule 4): the channel carries a pointer and + // the document carries the payload. + assert!( + !refusal.contains("mise run land"), + "the remedy stays in the config the refusal points at: {refusal}" + ); + assert!( + !refusal.contains("batten-receipts"), + "no store path reaches the channel: {refusal}" + ); +} + +#[test] +fn a_marker_no_sweep_clears_is_refused_at_load() { + // THE SPEND IS WHAT MAKES THE REFUSAL FINITE. A `while_marker` naming a family + // `retire_branch` does not sweep would deny past its own landing and on into + // the next piece of work to reuse the branch name — CLOUD-774's inherited + // suppression, in the refusing direction. `Rule::validate_marker` refuses the + // row rather than shipping a deny nothing can clear. + let dir = Fixture::new("punt-unswept") + .config(&POLICY.replace( + "while_marker = \"unlanded-nudged\"", + "while_marker = \"never-swept\"", + )) + .file("src/tracked.rs", "// committed\n") + .git() + .base_commit() + .build(); + let output = run_with_stdin( + &dir, + &["adjudicate", "--harness", "exit-code"], + &write_payload("src/tracked.rs"), + ); + let said = stderr(&output); + assert_eq!( + output.status.code(), + Some(1), + "a config that cannot be loaded is a usage error, never a verdict: {said}" + ); + assert!(said.contains("never-swept"), "names the marker: {said}"); + assert!( + said.contains("unlanded-nudged"), + "names what it could have said instead: {said}" + ); +} diff --git a/mise.toml b/mise.toml index 2049fc0b0..4cb52f6b4 100644 --- a/mise.toml +++ b/mise.toml @@ -617,7 +617,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" BATS_TEST_TIMEOUT = "300" REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "mise,attestation-check,engine-checks-green,engine-config,engine-doctor,engine-landed,engine-perf,engine-mcp,engine-pinned,engine-ready,engine-verdict,engine-wiring,engine-surface,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow,engine-lease,engine-handler,engine-speculation,engine-pipeline,engine-policy,rules-paths-trigger,skill-frontmatter-complete,engine-land" +MUTANT_GATES = "mise,attestation-check,engine-checks-green,engine-config,engine-doctor,engine-landed,engine-perf,engine-mcp,engine-pinned,engine-ready,engine-verdict,engine-wiring,engine-surface,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow,engine-lease,engine-handler,engine-speculation,engine-pipeline,engine-policy,rules-paths-trigger,skill-frontmatter-complete,engine-land,engine-hook" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index db796cc03..bcd6d0fc5 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -1498,6 +1498,13 @@ "string", "null" ] + }, + "while_marker": { + "description": "A branch-keyed marker whose PRESENCE is this row's firing condition\n(CLOUD-1390).\n\n**The third modifier polarity, and the one the other two cannot express.**\n[`Rule::when_absent`] and [`Rule::when_present`] condition on a projection\nof the CALL — what the harness handed over. This conditions on a fact about\nthe BRANCH that an earlier turn left behind, which no field of this call\ncarries.\n\n**It exists because a punt is the absence of an action.** A gate refuses\nactions, and at the moment a turn ends by offering work instead of doing it\nthere is no action to refuse — CLOUD-97 and CLOUD-219 each ruled out making\nthe turn's end a deny, because committing and pushing is what survives a\ncontainer reclaim and that path must stay free. So the observation is made\nwhere the evidence is and the refusal lands where a refusal is allowed: the\nnext mediated write. `ready-guard` and `claim read unread` are the same\nshape one surface over.\n\n**Presence, never validity**, and the distinction is what keeps this from\nbeing a second opinion about receipts. [`crate::receipt::validity`] decides\nwhether a receipt PROVES something; this asks only whether a marker file\nexists. A marker is not a proof and carries no conclusion to be stale.\n\n**The spend is deletion, by the sweep that already runs.** A row named here\nmust be one of [`crate::land::BRANCH_KEYED_RECEIPTS`], so landing clears it\nand nothing else has to know how it is retired — which is also what stops\nthis from becoming a state machine with its own lifecycle.\n\nAbsent on every row that predates this column, which then behaves exactly\nas before: the modifiers are additive, and absent means \"this row is about\nthe selection alone\".", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false, diff --git a/schema/batten.schema.json b/schema/batten.schema.json index fa6066962..1b4863969 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -4124,6 +4124,13 @@ "string", "null" ] + }, + "while_marker": { + "description": "A branch-keyed marker whose PRESENCE is this row's firing condition\n(CLOUD-1390).\n\n**The third modifier polarity, and the one the other two cannot express.**\n[`Rule::when_absent`] and [`Rule::when_present`] condition on a projection\nof the CALL — what the harness handed over. This conditions on a fact about\nthe BRANCH that an earlier turn left behind, which no field of this call\ncarries.\n\n**It exists because a punt is the absence of an action.** A gate refuses\nactions, and at the moment a turn ends by offering work instead of doing it\nthere is no action to refuse — CLOUD-97 and CLOUD-219 each ruled out making\nthe turn's end a deny, because committing and pushing is what survives a\ncontainer reclaim and that path must stay free. So the observation is made\nwhere the evidence is and the refusal lands where a refusal is allowed: the\nnext mediated write. `ready-guard` and `claim read unread` are the same\nshape one surface over.\n\n**Presence, never validity**, and the distinction is what keeps this from\nbeing a second opinion about receipts. [`crate::receipt::validity`] decides\nwhether a receipt PROVES something; this asks only whether a marker file\nexists. A marker is not a proof and carries no conclusion to be stale.\n\n**The spend is deletion, by the sweep that already runs.** A row named here\nmust be one of [`crate::land::BRANCH_KEYED_RECEIPTS`], so landing clears it\nand nothing else has to know how it is retired — which is also what stops\nthis from becoming a state machine with its own lifecycle.\n\nAbsent on every row that predates this column, which then behaves exactly\nas before: the modifiers are additive, and absent means \"this row is about\nthe selection alone\".", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false, From 21fd31890efb2d5a3695693d32a26a66ecffd9ee Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 16 Sep 2026 04:41:58 +0000 Subject: [PATCH 8/9] fix(attribution): join the no-identity arms instead of escaping the lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c990aa65` added `#[allow(clippy::match_same_arms)]` to keep `Unsigned` and `Lightweight` as separate arms. `spawn add other` refuses an added clippy escape outside the three test-module lints, with no `bypass_env` and no override route, and it is right to: the inventory may not be self-service, and an escape an agent annotates about its own work is the shape CLOUD-1338 measured. Neither thing the separate arms bought survives inspection. Exhaustiveness is unaffected — `A | B` introduces no wildcard, so a fourth `Tagger` variant still fails to compile. Independent mutatability was the honest reason, since a `|` inside a mutated expression is split as a field separator by `mutate`'s own row parser; but it was speculative. This module declares no `#MUTANT` row, so there was no declared row to refuse, and the escape was paid for a mutation nobody wrote. An author who later declares one over these arms can split them again and will own the escape at that point, with a row to point at. Refs: CLOUD-1794, CLOUD-1338 --- crates/batten/src/attribution.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/batten/src/attribution.rs b/crates/batten/src/attribution.rs index 2ff44a1cb..9da11fc72 100644 --- a/crates/batten/src/attribution.rs +++ b/crates/batten/src/attribution.rs @@ -369,17 +369,19 @@ impl Attribution { label: label.to_owned(), field: field.to_owned(), }; - // The two no-identity arms are written out separately rather than joined - // with `|`. They answer the same way today, and keeping them apart costs a - // line while buying two things: the match stays exhaustive if a fourth - // variant lands, and each arm is independently mutatable — a `|` inside a - // mutated expression is split as a field separator by `mutate`'s own row - // parser, so the declared row would be refused as five fields. + // The two no-identity arms are JOINED, and the `#[allow(match_same_arms)]` + // that once kept them apart is gone. It was an added clippy escape, which + // `spawn add other` refuses with no override route — correctly, since that + // rule counts any escape outside the three test-module lints. // - // That is the whole reason for the allow. Merging them, which is what - // clippy suggests, would put a `|` in the one place this repository's - // mutation sweep cannot read. - #[allow(clippy::match_same_arms)] + // Neither thing the separate arms were said to buy survives inspection. + // Exhaustiveness is unaffected: `A | B` introduces no wildcard, so a fourth + // variant still fails to compile. Independent mutatability was the real + // reason — a `|` inside a mutated expression is split as a field separator + // by `mutate`'s own row parser — but it was speculative: this module + // declares no `#MUTANT` row, here or anywhere, so there is no row to refuse. + // An author who later declares one over these arms can split them again and + // will own the escape that costs. Ok(match tagger { git::Tagger::Signed(rendered) => { if Self::tagger_is_accountable(rendered, &permitted, &denied) { @@ -388,8 +390,9 @@ impl Attribution { vec![point("tagger")] } } - git::Tagger::Unsigned => vec![point("tagger:unannotated")], - git::Tagger::Lightweight => vec![point("tagger:unannotated")], + git::Tagger::Unsigned | git::Tagger::Lightweight => { + vec![point("tagger:unannotated")] + } }) } } From 4fab657d5c6ceaa987869d6623b1f57e508cf771 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 16 Sep 2026 04:59:59 +0000 Subject: [PATCH 9/9] fix(ci-parity): name the provisioning action the way the tree already names it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prose point other` refused three lines this module added: a `startswith` over `jdx/mise-action@` and two fixtures carrying the same literal. The rule forbids naming a third-party tool, and its exclude carries `uses:` so a workflow may reference an action by name — but a rego object spells the key `"uses"`, which that alternation cannot reach. The rule is not the thing that was wrong. `policy/lock-complete.rego` does this same job on `main` and passes, because it matches bare `mise-action` and writes its fixtures in the YAML form the exclude already covers. The idiom existed and this module did not follow it, so the module moves rather than the predicate. The owner is dropped from the match, not just from the prose: `contains` over `mise-action@` identifies a provisioning step without asserting who publishes the action, which is `mise-pin-agreement`'s question and not this one's. The fixtures carry `owner/mise-action@` — the shape a real `uses` value has, with no vendor in it. Widening the exclude was tried first and is reverted. It is a `rule-predicate-changed` weakening, which needs a groomed row that declared it before the work started, and spending that to accommodate a module with the wrong spelling would have bought nothing. Refs: CLOUD-1786 --- policy/ci-parity.rego | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policy/ci-parity.rego b/policy/ci-parity.rego index b8559fa0c..32838b189 100644 --- a/policy/ci-parity.rego +++ b/policy/ci-parity.rego @@ -872,7 +872,7 @@ builds_a_release_artifact(job) if { provisions_from_a_list(job) if { some step in job.steps - startswith(object.get(step, "uses", ""), "jdx/mise-action@") + contains(object.get(step, "uses", ""), "mise-action@") object.get(step, ["with", "install_args"], "") != "" } @@ -1101,7 +1101,7 @@ release_leg(provisioning) := { "jobs": {"dist": { "runs-on": "ubuntu-latest", "steps": [ - object.union({"uses": "jdx/mise-action@3c2e0cf8"}, provisioning), + object.union({"uses": "owner/mise-action@3c2e0cf8"}, provisioning), {"run": "mise run dist x86_64-unknown-linux-gnu"}, ], }}, @@ -1143,7 +1143,7 @@ test_a_job_that_builds_no_artifact_is_not_this_arms_business if { "on": {"release": {"types": ["published"]}}, "jobs": {"notes": { "runs-on": "ubuntu-latest", - "steps": [{"uses": "jdx/mise-action@3c2e0cf8"}, {"run": "mise run release-notes"}], + "steps": [{"uses": "owner/mise-action@3c2e0cf8"}, {"run": "mise run release-notes"}], }}, } found := violation with input as swap(".github/workflows/release-artifacts.yml", other)