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/.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 diff --git a/batten.toml b/batten.toml index 428de29fa..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. @@ -8944,6 +9008,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" @@ -12129,6 +12213,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/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..9da11fc72 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,85 @@ 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. + /// 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) + } + + /// 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 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. + // + // 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) { + Vec::new() + } else { + vec![point("tagger")] + } + } + git::Tagger::Unsigned | git::Tagger::Lightweight => { + vec![point("tagger:unannotated")] + } + }) + } } /// Read every non-merge commit in `base..head`. @@ -492,6 +622,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 +649,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/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/fast_forward.rs b/crates/batten/src/fast_forward.rs index 1bed2956b..ec98b9374 100644 --- a/crates/batten/src/fast_forward.rs +++ b/crates/batten/src/fast_forward.rs @@ -475,6 +475,77 @@ fn grade(conclusion: &str) -> Answer { } } +/// 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 `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. +/// +/// **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 refusal(ask: &Ask) -> Option { + let path = format!("repos/{}/issues/{}/comments?per_page=100", ask.repo, ask.pr); + 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(refusal_line) + .next_back() +} + +/// 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 @@ -511,6 +582,58 @@ 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_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 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 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_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. `starts_with`, never `contains` — the discipline + // `fast-forward.yml` applies to its own trigger after a comment + // DISCUSSING the trigger fired it (PR #624). + assert_eq!(refusal_line("/fast-forward"), None); + assert_eq!( + refusal_line(&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/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/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 d9a4b3b72..693eca43a 100644 --- a/crates/batten/src/land.rs +++ b/crates/batten/src/land.rs @@ -1776,7 +1776,31 @@ 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. +/// +/// **`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", + "unlanded-nudged", +]; /// Retire a branch whose pull request has merged. /// diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 3a879a880..4a69ea566 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8952,12 +8952,39 @@ 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 - )?; + // 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 posted no reason this build could read — read the run before rebasing; it may have failed before reaching its refusal", + ask.pr + )?, + } Ok(ExitCode::Violation) } fast_forward::Answer::Pending => { @@ -12331,10 +12358,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/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/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/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/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/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"); 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/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/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/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/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/policy/ci-parity.rego b/policy/ci-parity.rego index 69a0308c8..32838b189 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 + contains(object.get(step, "uses", ""), "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": "owner/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": "owner/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 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 fc6fe59e8..1b4863969 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", @@ -4117,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,