Skip to content

feat(hook): adjudicate mediated calls from a declarative rule table - #203

Merged
wenzowski merged 1 commit into
mainfrom
wenzowski/cloud-48-replace-the-ad-hoc-command-if-chain-with-a-declarative-rule
Aug 9, 2026
Merged

wenzowski merged 1 commit into
mainfrom
wenzowski/cloud-48-replace-the-ad-hoc-command-if-chain-with-a-declarative-rule

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-48. Depends on CLOUD-269 (landed).

hook's policy was a hardcoded match (pair[0], pair[1]) in hook.rs carrying four gh shapes and their reason strings — the ad-hoc if chain of the title. It now dispatches on [[rule]] rows, and this repo's four rows live in its own batten.toml.

The payoff is non-negotiable rule 1, which previously did not hold: mise run land, mise run ci-wait and the whole gh lifecycle have left crates/batten. git grep -Fn "mise run land" crates/ now finds only a test fixture. A reviewer can read what will be refused without reading Rust (§9).

All 18 pre-existing hook tests pass unchanged against a fixture Policy, including every CLOUD-181 wrapper-lookthrough case — the policy moved from code to config without a single verdict changing.

Schema

pattern is reused rather than adding a shape column: for forbid a substring banned from files, for shape a banned command line whose first word is the effective program and the rest the adjacent non-flag words. One key, one meaning, two domains.

New columns: reason (required by shape — a mediated deny is the entire explanation a caller gets, so an id alone is un-actionable per CLOUD-122), optional policy_url, and contains. contains earns its place on one real case: the landing directive lives inside a quoted --body, so it is not one of the words pattern matches, and without it every ordinary gh pr comment would be refused alongside the one carrying it.

glob becomes optional (a shape rule inspects no files) and RuleScope::MediatedCall routes a rule to its surface. Spelled mediated_call, not command, because command is already a RuleKind token — one word meaning two unrelated things across two keys in one table is the confusion the scope/severity separation exists to prevent.

Validation by census

Rule::validate was two match arms naming one required and one forbidden field each. Correct for two kinds and three columns; silently wrong the moment either grows, because a new column appears in no arm and every kind then accepts it. Columns are now listed once (Rule::columns) and each kind is asked about all of them via requires/permits/scopes, with every_optional_rule_field_is_classified_by_every_kind failing if one is added and left unplaced.

Validation also moves to load time. Defensible while the tree engine was the only runner; hook is a second one, and a malformed mediated_call row validated only by check is a row that loads, matches nothing at the mediation channel, and reads as coverage.

What keeps hook unable to run configured code

Policy::from_resolved filters on scope alone. That is safe only because RuleKind::scopes pairs every spawning kind with Tree — asserted by no_mediated_call_kind_spawns_a_process over the whole cross product, not a named pair.

Resolving through resolve rather than config::load is deliberate: a batten.local.toml may only tighten, adding a shape row is a tightening, and a raise-only override model is worth nothing at a surface that ignores it. --config-from comes along for free.

Absent authority is the empty policy (allow, silently) because hook mediates every call in whatever directory the agent is in. An authority that exists and cannot be read is exit 1, loud, never a deny — §7 spends 2 on the verdict alone. That discharges CLOUD-40's "fails open loudly" clause, which was unreachable while hook read no config.

Two corrections and one recorded negative

  • Acceptance (e) was unimplementable as written. It asked for exit 2 on an invalid severity; 2 is the deny code, so a config typo would refuse every mediated call, which non-negotiable rule 5 and no_failure_code_can_deny_a_mediated_call forbid. Implemented as exit 1. Ready block amended.
  • committed_rules_pin_severity_and_scope_explicitly asserted scope == "tree" for every committed rule — a literal correct with one scope, now a vocabulary check.
  • the_schema_cannot_express_per_kind_requirements records an honest gap. Rule is a flat deny_unknown_fields struct (a flatten enum would defeat that guarantee), so schemars emits one required list and "required iff kind == shape" has nowhere to live: glob and reason are looser in the published schema than in the binary. The binary is the authority and still refuses — the safe direction — but it is asserted rather than left to be discovered.

Notes for review

  • hook_reads_no_config_so_a_broken_one_cannot_block is deleted here by design. CLOUD-40 wrote it to pin "config is not load-bearing"; its deletion is the seam where that stopped being true.
  • the_committed_shape_rules_fire_on_every_banned_shape is the obligation the fixture tests do not discharge: every other hook test supplies its own policy, so deleting a row from the real batten.toml would otherwise break nothing while silently disarming the guard chain.
  • New corpus fixture shape-mediated-call satisfies the_corpus_covers_every_rule_kind (landed in feat(gate): discover a fixture-repo corpus, and collapse nine copies of the materializer #191 mid-flight) and pins the routing property: check walks past a mediated_call row without erroring and does not mistake it for a file rule.
  • hook stays Effect::Unclassified. After this change read would be defensible — it reaches no user-supplied code — but the classification is load-bearing in exactly one place, the derived agent allowlist, and putting a deny-issuing mediator on it buys nothing an agent needs. The reason is now stated in surface.rs beside the row, replacing a bare CLOUD-202 citation.
  • min_batten_version → 0.0.42, the current workspace version. Not the next patch: check_min_version compares against the running build, so a higher floor makes the tree refuse its own binary. Precedent: 20f138e set 0.0.30 while Cargo.toml was 0.0.32.

mise run verify green on this SHA, rebased on origin/main ea48b71.

`hook`'s policy was a hardcoded `match (pair[0], pair[1])` in `hook.rs`
carrying four `gh` shapes and their reason strings — the ad-hoc `if`
chain of CLOUD-48's title. It now dispatches on `[[rule]]` rows, and this
repo's four rows live in its own `batten.toml`.

The engine stops carrying one consumer's vocabulary (non-negotiable rule
1): `mise run land`, `mise run ci-wait` and the `gh` lifecycle leave
`crates/batten` entirely, and a reviewer can read what will be refused
without reading Rust (§9).

## The schema delta

`pattern` is reused rather than adding a shape column: for `forbid` it is
a substring banned from files, for `shape` a banned command line whose
first word is the effective program and the rest the adjacent non-flag
words. One key, one meaning, two domains.

New: `reason` (required by `shape` — a mediated deny is the whole
explanation a caller gets, so an id alone is un-actionable, CLOUD-122),
optional `policy_url`, and `contains`. `contains` earns its place on one
real case: the landing directive lives inside a quoted `--body`, so it is
not one of the words `pattern` matches, and without it every ordinary
`gh pr comment` would be refused alongside the one that carries it.

`glob` becomes optional, since a shape rule inspects no files, and
`RuleScope::MediatedCall` is what routes a rule to the surface that
evaluates it. Spelled `mediated_call`, not `command`, because `command` is
already a `RuleKind` token and one word meaning two unrelated things
across two keys in one table is the confusion the scope/severity
separation exists to prevent.

## Validation by census, not a per-kind match

`Rule::validate` was two match arms naming one required and one forbidden
field each. That was correct for two kinds and three columns and fails
silently the moment either grows — a new column appears in no arm, so
every kind accepts it. Columns are now listed once (`Rule::columns`) and
every kind is asked about all of them via `requires`/`permits`/`scopes`,
with `every_optional_rule_field_is_classified_by_every_kind` failing if a
column is added and left unplaced.

Validation also moves to load time. It was defensible while the tree
engine was the only runner; `hook` is a second one, and a malformed
`mediated_call` row validated only by `check` is a row that loads,
matches nothing at the mediation channel, and reads as coverage.

## What keeps `hook` unable to run configured code

`Policy::from_resolved` filters on scope alone. That is safe because
`RuleKind::scopes` pairs every spawning kind with `Tree` only, which
`no_mediated_call_kind_spawns_a_process` pins over the whole cross
product rather than a named pair.

Resolving through `resolve` rather than `config::load` is deliberate: a
`batten.local.toml` may only tighten, adding a shape row is a tightening,
and a raise-only override model is worth nothing at a surface that
ignores it. `--config-from` comes along for free.

Absent authority is the empty policy — allow, silently, because `hook`
mediates every call in whatever directory the agent is in. An authority
that exists and cannot be read is exit 1, loud, never a deny: §7 spends
2 on the verdict alone.

## Corrections to the ticket, and one recorded negative

The Ready block's acceptance (e) said an invalid `severity` exits 2. It
cannot: 2 is the deny code, so a config typo would refuse every mediated
call, which non-negotiable rule 5 and `no_failure_code_can_deny_a_mediated_call`
forbid. Implemented as exit 1, matching every sibling config error.

`the_schema_cannot_express_per_kind_requirements` records an honest gap
rather than hiding it. `Rule` is a flat `deny_unknown_fields` struct, so
schemars emits one `required` list and "required iff kind == shape" has
nowhere to live; `glob` and `reason` are looser in the published schema
than in the binary. The binary is the authority and still refuses, which
is the safe direction, but it is asserted rather than left to be
discovered.

`hook` stays `Effect::Unclassified`. After this change `read` would be
defensible — it reaches no user-supplied code — but the classification is
load-bearing in exactly one place, the derived agent allowlist, and
putting a deny-issuing mediator on it buys nothing an agent needs. The
reason is now stated in `surface.rs` beside the row.

Refs: CLOUD-48
@linear-code

linear-code Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
CLOUD-48 Replace the ad-hoc command `if` chain with a declarative rule table

Why
Sequential ad-hoc command checks should become declarative rules with stable IDs, reasons, policy links, and explicit severities.

Definition of done

  • Add rule rows with id, command shape, severity, reason, and optional policy URL
  • Keep scope separate from severity
  • Consume the severity model — vocabulary (deny/warn/allow), scope≠severity separation, and explicit-defaults discipline — defined by CLOUD-61; this issue does not define it

Acceptance

  • Example rules such as banning inline Python and in-place sed load and fire correctly
  • Each rule row carries a severity (typed by CLOUD-61) and a separate scope key; the rule table consumes that model rather than re-specifying it

Refinement — reconciled seam (severity ownership → CLOUD-61)

The severity model (enum, scope≠severity, explicit per-field defaults, conformance test) is owned solely by CLOUD-61 to avoid two definitions drifting. This issue is now scoped to the rule-table structure — stable id, command shape, reason, optional policy URL, and dispatch — with a severity column typed by CLOUD-61. Hard-blocked by CLOUD-61 (needs the type).


Re-scoped against the landed engine (CLOUD-12).

The [[rule]] table skeleton has landed — id, kind, glob, pattern with deny_unknown_fields. This issue is no longer 'build the rule table'; it is add the governance columns to that landed struct: reason, optional policy URL, severity (typed by CLOUD-61), and a separate scope key. Re-pointed: blockedBy CLOUD-12 (the struct it extends) + CLOUD-61 (the severity type); dropped CLOUD-47 (canceled) and CLOUD-29 (the table already loads without the override layer).


Refinement — Ready (the hook's hardcoded deny table becomes governed [[rule]] rows)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). The one authoritative artifact is the Rule struct behind batten.toml. This issue extends it with a command-shape kind plus the governance columns — reason, optional policy_url, severity (typed by CLOUD-61), a separate scope key — and batten hook dispatches on those rows in place of its hardcoded Rust policy table (the ad-hoc if chain). CLOUD-202 ports the remaining shell guards into that table; relatedTo, not blocking — this issue makes declarative whatever the table holds when it lands.
  • Computable predicate (§2). Policy-engine-first: command-shape matching from config is the capability today's rule model lacks, and this issue is the engine growth that adds it — the mechanism is the Rust change itself, gated by the existing mise run test:cargo (hk gate + CI). A fixture config with a shape row loads and fires; an unknown or ill-typed key stays a hard schema error (exit 2).
  • Effect (§3). No new command surface. check stays read; hook keeps its own inverted contract. No effect-table change.
  • Output & exit (§5). A deny is pointer-only and points to the fix (CLOUD-122 posture): rule id + reason + policy_url when set — never a dump of the mediated command. Channels unchanged: claude-code harness emits the JSON permissionDecision deny and exits 0; exit-code harness exits 2. A row with an invalid severity or scope value is a config error: exit 2, never a silent skip.
  • Commit / bump (§6). feat → patch until 0.1.0 (DoR §6: below 0.1.0 release-plz bumps the patch whatever the type says).
  • Test obligation (§7). E2E over the compiled binary (tests/cli.rs): (a) a fixture shape row denies a matching mediated command on both harness channels; (b) a non-matching command is allowed; (c) the deny names id and reason; (d) scope parses independently of severity; (e) an invalid severity value exits 2.
  • Blockers (§8). blockedBy CLOUD-61 (the severity type the rows consume) and CLOUD-12 (the landed [[rule]] struct this extends). Refinable now; implement after both land.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 9, 2026 19:52
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit b1fdcb8 into main Aug 9, 2026
10 checks passed
@wenzowski
wenzowski deleted the wenzowski/cloud-48-replace-the-ad-hoc-command-if-chain-with-a-declarative-rule branch August 9, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants