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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .serena/memories/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -1872,6 +1872,22 @@ judge_fingerprint`, its own domain tag), so a caller can reference content it
projection that spawned would be the class's whole point undone. `Surface::Hook`
is refused (`tests/facts.rs`'s `no_effect_fact_is_hook_resolvable`), as a
census over `Fact::ALL` rather than an assertion about this one variant.
- `preset.rs` — one manifest per vendored preset (CLOUD-1181): identity, the
`scope` its modules decide, the modules themselves, and the refusal classes
they raise. It exists because a preset used to be three unrelated `const`s that
nothing tied together — the name-to-modules table here, the verdict rows inside
`verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the
`[[pattern]]` refusal — so a preset carried no identity beyond its name, no
version, and no declared scope. **Below `policy` and `verdict`, and it reads
neither**: both project the one declaration rather than three tables knowing
about each other, which is what `module-layering` pins. `scope` is the field
the row was written for, and the honest reading of what it buys is in the load
site's own comment: a mismatch was ALREADY refused by the module input-key
check, so this refuses earlier and names the preset a consumer enabled rather
than a module inside the binary. **A manifest is not permission to fetch one**
— CLOUD-129's no-network verdict is unchanged, `include_str!` at build time,
and what the manifest buys CLOUD-970 is that the trust question becomes
askable at all.
- `policy.rs` — the policy evaluator (CLOUD-647, CLOUD-689): a `[[rule]]` of
kind `policy` names a **registered** Rego module, and the module decides over
the resolved fact set. It exists because `run` is a flat loop where no row
Expand Down
83 changes: 74 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,17 +288,82 @@ _diagnostic_, so it never returns `2` — every failure it can report is the
config-or-usage class, and a harness must never read "this checkout is
misconfigured" as a policy denial.

## Extending Batten: three surfaces, and which to reach for
## Extending Batten: ten rule kinds, and which to reach for

Any predicate you can express as a command plus an exit code is expressible in
Batten. There are three ways to do it, and the failure mode is picking the wrong
one — so the boundary matters more than the mechanics.

| What you are gating on | Reach for | Where it is configured |
| ------------------------------------------------------- | ------------------------ | -------------------------------- |
| A **file's contents** | a `command` rule kind | `[[rule]]` with `kind="command"` |
| A **command's output**, when the tool lies about exit 0 | `exec` output predicates | `[[exec_pattern]]` |
| An **existing warn finding**, to make it block | `fail_on_warning` | a top-level key |
Batten — and most of them need no command at all. The failure mode is picking the
wrong kind, so the boundary matters more than the mechanics.

**Reach for the narrowest kind that fits.** A `command` rule spawns a process,
which can read any file and reach the network; every other kind is decided from
facts the boundary already resolved. That is why a `command` rule runs only under
`batten enforce` while the rest are admitted to the read-only `check` surface.

| What you are gating on | Reach for | Where it is configured |
| ------------------------------------------------------------------ | ---------- | --------------------------------- |
| A **literal string** banned from matched files | `forbid` | `[[rule]]` with `kind="forbid"` |
| A **file's contents**, judged by a program you supply | `command` | `[[rule]]` with `kind="command"` |
| A **command line** an agent is about to run | `shape` | `[[rule]]` with `kind="shape"` |
| A **count that must not grow** — a budget you are paying down | `ratchet` | `[[rule]]` with `kind="ratchet"` |
| Whether a **verification receipt** exists and still answers | `receipt` | `[[rule]]` with `kind="receipt"` |
| The **shape of a pipeline** — how a call is composed | `pipeline` | `[[rule]]` with `kind="pipeline"` |
| A judgement a **model** makes, recorded with its own no-fix reason | `judge` | `[[rule]]` with `kind="judge"` |
| **Credentials** reaching a file, via a pinned scanner | `secrets` | `[[rule]]` with `kind="secrets"` |
| A **document's** own structure | `document` | `[[rule]]` with `kind="document"` |
| A **relationship between facts** no single row can express | `policy` | `[[rule]]` with `kind="policy"` |

Two surfaces are not rule kinds and are configured on their own:

| What you are gating on | Reach for | Where it is configured |
| ------------------------------------------------------- | ------------------------ | ---------------------- |
| A **command's output**, when the tool lies about exit 0 | `exec` output predicates | `[[exec_pattern]]` |
| An **existing warn finding**, to make it block | `fail_on_warning` | a top-level key |

### Gating on a relationship between facts — a `policy` rule

The other kinds are each one predicate over one object. A `policy` rule is a
[Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) module
deciding over the whole resolved fact set, which is what makes a predicate over
the _relationship between_ facts expressible at all — the engine's own rule loop
is flat, and no row can consume another's verdict.

```toml
[[rule]]
id = "no-orphan-workflow"
kind = "policy"
scope = "tree"
sources = [".github/workflows/*.yml"]
module = "policy/no-orphan-workflow.rego"
severity = "deny"
```

A module is **deny-only by construction** — there is no allow spelling — so
enabling one can never weaken policy, which is what preserves the raise-only
invariant above. A refusal it raises is `{rule, verdict, subjects}`, and the
`verdict` is a declared class rather than free prose, so
`batten policy explain <class>` reaches the remedy from any refusal.

### Presets: the batteries, and how to switch one on

Batten ships policy modules for common practices, compiled into the binary. They
are the reason a new repository does not have to author every predicate from
scratch — the same shape Conftest, Semgrep, ESLint and Clippy all take.

```toml
[[rule]]
id = "trunk-based"
kind = "policy"
scope = "mediated_call"
preset = "trunk-based"
severity = "deny"
```

`batten config show` lists the presets this binary ships. Each declares the
**scope** its modules decide, and enabling one at the other scope is refused at
load rather than quietly deciding nothing.

There is **no network and no registry**: a preset's bytes ship inside the binary
you already trust, under the same checksum as the rest of it.

Everything a consumer adds is **raise-only** (§8): a git-ignored
`batten.local.toml` may add a rule or a pattern, never redefine or remove one the
Expand Down
30 changes: 30 additions & 0 deletions batten.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,33 @@ raw = ["span_text"]
max_payload_bytes = 16384
run = "my-judge --model {{model}}"
model = "some-model-id"

# THE BATTERIES: policy presets, compiled into the binary.
#
# Every rule above is one predicate over one object. A `policy` rule is a Rego
# module deciding over the whole resolved fact set, which is what makes a
# predicate over the RELATIONSHIP between facts expressible — the engine's rule
# loop is flat, so no row can consume another's verdict.
#
# You do not have to write one to get value from them. Batten ships modules for
# common practices, and enabling one is a rule row naming it. `batten config show`
# lists what this binary carries; today that is `ci-hygiene`, `commit-hygiene`,
# `landing-loop`, `pinned-toolchain`, `shell-hygiene` and `trunk-based`.
#
# SCOPE IS THE FIELD TO GET RIGHT. A preset's modules read one surface's facts:
# `tree` for the working tree, `mediated_call` for the single command a hook is
# adjudicating. Enabling one at the wrong scope is refused at LOAD, naming both —
# it does not quietly evaluate and decide nothing.
#
# A preset is deny-only, like every module, so switching one on can only ADD
# refusals. There is no network and no registry: the bytes ship inside the binary
# you already trust, under the same checksum as the rest of it.
#
# Uncomment to enable one:
#
# [[rule]]
# id = "trunk-based"
# kind = "policy"
# scope = "mediated_call"
# preset = "trunk-based"
# severity = "deny"
106 changes: 97 additions & 9 deletions batten.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5389,13 +5389,13 @@ keep = 2
mb = 7938
worst_mb = 7938
multiplier = 1
measured = "2026-09-01"
measured = "2026-09-02"

[prune.cold]
mb = 18984
worst_mb = 18984
multiplier = 1
measured = "2026-09-01"
measured = "2026-09-02"

# THE BASIS EACH FLOOR WAS MEASURED AGAINST (CLOUD-1158), because `measured` is a
# pointer to a basis and not the basis itself.
Expand Down Expand Up @@ -5468,14 +5468,32 @@ measured = "2026-09-01"
# exactly this basis move. Refreshing the count without claiming a floor
# measurement I did not take is the honest half of the remedy.

# THE 2026-09-02 MOVE, AND IT IS THE 2026-09-01 MOVE AGAIN, DELIBERATELY.
# A bundle connecting the refusal ABI added two compiled-binary tiers —
# `config_fault_class.rs` and `preset_manifest.rs` — and the live count reached
# 164, twelve past the basis and outside the tolerance. That is this gate working:
# the floor it was defending was taken against a smaller tree.
#
# `count` moves to the live 164 with `measured`, as the block above instructs, and
# THE FLOORS AGAIN DELIBERATELY DO NOT MOVE. The reasoning is unchanged and is
# worth restating rather than assumed: both are far above what the grouped-target
# tree needs — the lap that tripped this reported 10733MB free against a 7938MB
# warm floor, so free space was never the problem — and a floor too high only
# refuses laps while one too low fails silently inside a test run. Moving them
# down needs the independent measurement this block names (a build from an empty
# `target` for cold, a minimal post-prune tree for warm), which is CLOUD-1158's
# and was not taken here. Refreshing the count without claiming a floor
# measurement I did not take is the honest half of the remedy, exactly as the
# entry above says.

[prune.warm.basis]
glob = "crates/batten/tests/**/*.rs"
count = 152
count = 164
tolerance = 10

[prune.cold.basis]
glob = "crates/batten/tests/**/*.rs"
count = 152
count = 164
tolerance = 10

# THE REGROWABLE ROOTS THE ESCALATION MAY DROP (CLOUD-1157), in the order it drops
Expand Down Expand Up @@ -6234,6 +6252,10 @@ gloss = "a tracked surface moving under a session"
word = "event"
gloss = "a harness or forge event"

[[vocabulary.subject]]
word = "fact"
gloss = "a declared fact row"

[[vocabulary.subject]]
word = "forge"
gloss = "the code host"
Expand Down Expand Up @@ -6282,14 +6304,26 @@ gloss = "a lockfile"
word = "manifest"
gloss = "a package manifest"

[[vocabulary.subject]]
word = "marker"
gloss = "a declared marker row"

[[vocabulary.subject]]
word = "memory"
gloss = "an agent memory document"

[[vocabulary.subject]]
word = "mint"
gloss = "a declared receipt-minting row"

[[vocabulary.subject]]
word = "module"
gloss = "a policy module"

[[vocabulary.subject]]
word = "output"
gloss = "a declared exec output predicate"

[[vocabulary.subject]]
word = "patch"
gloss = "a change identified by its content"
Expand All @@ -6298,6 +6332,10 @@ gloss = "a change identified by its content"
word = "path"
gloss = "a filesystem path"

[[vocabulary.subject]]
word = "pattern"
gloss = "a declared named-pattern row"

[[vocabulary.subject]]
word = "pin"
gloss = "fixed at a version"
Expand All @@ -6310,6 +6348,18 @@ gloss = "an executable"
word = "prose"
gloss = "authored text"

[[vocabulary.subject]]
word = "provision"
gloss = "a declared provisioned-tool row"

[[vocabulary.subject]]
word = "recorder"
gloss = "a declared recorder row"

[[vocabulary.subject]]
word = "redirect"
gloss = "a declared per-path remedy row"

[[vocabulary.subject]]
word = "release"
gloss = "a cut release"
Expand Down Expand Up @@ -6386,10 +6436,22 @@ gloss = "a third-party validator"
word = "turn"
gloss = "one agent turn"

[[vocabulary.subject]]
word = "verb"
gloss = "a declared mutating-verb row"

[[vocabulary.subject]]
word = "verdict"
gloss = "a declared refusal class"

[[vocabulary.subject]]
word = "version"
gloss = "a declared version"

[[vocabulary.subject]]
word = "waiver"
gloss = "a declared waiver row"

[[vocabulary.subject]]
word = "workflow"
gloss = "a CI workflow"
Expand Down Expand Up @@ -8217,12 +8279,38 @@ target = "mise run lint:deno"

[[verdict]]
id = "lane guard missing"
gloss = "a job an outside author can reach holds contents:write and tests no head origin"
gloss = "a job whose TRIGGER carries an outside head holds contents:write and tests no origin"
class = """
The privileged-lane shape where the event itself hands the job a head: \
`pull_request`, `pull_request_target` or `workflow_run`, a token that can write, \
and no check that the head came from this repository. The head is on the event, \
so the test is on the event -- compare `head_repository.full_name` against \
`github.repository` before the job does anything with its permissions, or drop \
the permission.

Its sibling `lane resolve missing` is the same shape reached the other way, and \
the split exists because the two name DIFFERENT FIELDS: a lane told to read \
`head_repository.full_name` on an `issue_comment` event is being sent to a field \
its payload does not carry.
"""

[[verdict.route]]
id = "workflow read first"
kind = "document"
target = ".github/workflows"

[[verdict]]
id = "lane resolve missing"
gloss = "a job that LOOKS UP an outside head holds contents:write and tests no origin"
class = """
The privileged-lane shape: a trigger an outside author can fire, a token that can \
write, and no check that the head being built came from this repository. Test the \
head's origin before the job does anything with its permissions, or drop the \
permission.
The same privileged-lane shape reached the other way: no trigger carries a head, \
so the job goes and finds one through the pulls API -- a cron arm, or an \
`issue_comment` responding to a command. The event payload has no head to test, \
so the test belongs on the RESOLVED pull request: compare `.head.repo.full_name` \
against this repository on what the lookup returned, before checking it out.

Reached only where no trigger carries a head, so a job that is both is reported \
once, under `lane guard missing`, whose remedy is the earlier of the two.
"""

[[verdict.route]]
Expand Down
Loading
Loading