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
25 changes: 25 additions & 0 deletions .serena/memories/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,31 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag),
crate↔config contract, hence the constant. Stated limits: no `cwd`, so an
absolute or `..` path is compared as written, and expansion/substitution hide
operands. Both under-deny, the sanctioned direction.
- `redirect.rs` — the per-path-class redirect table (CLOUD-280): what to run
instead, keyed by **what is protected** rather than by the verb reaching for
it. `[[redirect]]` is `{glob, mutation}`, and `hook::protected_refusal`
consults it BEFORE the verb's own `redirect` — three tiers, table then verb
then `Fix::None`, where the last two are CLOUD-96's behaviour untouched, so the
floor is structural rather than careful (`Fix::declared(Option<&str>)` was
built for this seam). Matching is `rules::glob_match` — one glob semantics for
the engine — over the SAME normalised path `protected.contains` was asked
about, or the two tables would disagree about which path is under discussion.
**Declaration order decides, first match wins**, the tie-break `shape_rules`
already uses and for its stated reason: a reviewer reads a table top to bottom,
and any cleverer precedence is a rule about rules the config does not state.
A **sibling** table rather than a wider `protected`, which keeps `Vec<String>`
so `trust::removed_entries`'s `protected[<entry>]` weakening keys are
byte-identical (asserted). Not policy-bearing — it changes what a refusal says,
never whether it fires — so no raise-only clamp applies; the local layer may
add a class and may not redefine a committed one, and since local rows append
after committed ones, first-match-wins means an uncommitted file can never
change what a committed row says. **The boundary worth knowing**: consumer #1
declares `.github/workflows/**` and `batten.toml`, and deliberately NOT
`.serena/memories/**` — that class's remedy depends on the ACTION
(`write_memory` / `edit_memory` / `rename_memory` / `delete_memory`), so a path
row would override four correct per-verb answers with one weaker sentence.
Per-path beats per-verb only where the path fact dominates. It makes a message
specific; it does not make the named surface reachable (CLOUD-663).
- `refusal.rs` — the refusal contract (CLOUD-122): ONE `Refusal` value —
`{rule, reason, fix}` — constructed at every deny site and projected onto
whatever channel a host reads, so the shape is never re-typed per harness.
Expand Down
30 changes: 30 additions & 0 deletions batten.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,36 @@ protected = [
".github/workflows/**",
]

# ---------------------------------------------------------------------------
# What to run instead, per protected path class (CLOUD-280).
#
# The refusal contract (CLOUD-122) is that every deny names the fix. CLOUD-96
# put that text on the `[[verb]]` row, which is right when the VERB knows the
# remedy and wrong when the PATH does: `rm` says "restore it with `git checkout
# --`", which is true of most files and actively misleading for a workflow,
# where the point is not recovering the bytes but that CI's definition of green
# changes under review.
#
# Consulted BEFORE the verb's own `redirect`; a class named here answers for
# every verb that reaches it. Declaration order decides, first match wins.
#
# WHY `.serena/memories/**` IS DELIBERATELY ABSENT, which is the interesting
# boundary of this table rather than an omission. Its remedy is not a property
# of the path alone: a write wants `write_memory`, an in-place edit wants
# `edit_memory`, a move wants `rename_memory` (the only route that rewrites
# `mem:` referrers), a delete wants `delete_memory`. The verb rows below already
# name the right tool per action, and a row here would OVERRIDE all four with
# one weaker sentence — the opposite of what this table is for. Per-path beats
# per-verb only where the path fact dominates; where the verb fact does, the
# fallback tier is the correct answer and not a leftover.
[[redirect]]
glob = ".github/workflows/**"
mutation = "change it in a pull request — these files are CI's definition of green, so the change has to be reviewed rather than restored"

[[redirect]]
glob = "batten.toml"
mutation = "change it in a pull request — this file is the policy authority every gate reads, and `mise run config-lint` is what checks the edit before it lands"

# ---------------------------------------------------------------------------
# The worktree pileup threshold (CLOUD-46).
#
Expand Down
23 changes: 23 additions & 0 deletions crates/batten/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,18 @@ pub struct Config {
/// lookup are [`crate::verbs`].
#[serde(default, rename = "verb", skip_serializing_if = "Vec::is_empty")]
pub verbs: Vec<crate::verbs::MutatingVerb>,
/// The per-path-class redirect table (CLOUD-280): what to run instead,
/// keyed by what is protected rather than by the verb reaching for it.
///
/// Consulted before [`MutatingVerb::redirect`], which stays the fallback, so
/// the behaviour CLOUD-96 shipped is the floor rather than a regression.
/// Deliberately a sibling of [`Config::protected`] rather than a widening of
/// it: that set keeps its element type, so [`crate::trust`]'s
/// `protected[<entry>]` weakening keys are untouched.
///
/// [`MutatingVerb::redirect`]: crate::verbs::MutatingVerb::redirect
#[serde(default, rename = "redirect", skip_serializing_if = "Vec::is_empty")]
pub redirects: Vec<crate::redirect::Redirect>,
/// Output predicates over a wrapped command's captured streams (CLOUD-117):
/// literals that, found in `batten exec`'s output, promote a lying exit `0`
/// to a violation. Consumer-specific by nature — which warning means
Expand Down Expand Up @@ -420,6 +432,14 @@ pub struct OverrideConfig {
skip_serializing_if = "Vec::is_empty"
)]
pub exec_patterns: Vec<outputs::OutputPattern>,
/// Redirects this file **adds**. A duplicate glob is refused.
///
/// Needs no raise-only clamp, and that is a decision rather than an
/// oversight: a redirect changes what a refusal *says*, never whether it
/// fires, so there is no bar here to lower. Refusing a redefinition is
/// coherence with the other append-only tables.
#[serde(default, rename = "redirect", skip_serializing_if = "Vec::is_empty")]
pub redirects: Vec<crate::redirect::Redirect>,
/// Waivers this file adds, for rules the authority does not declare. A
/// waiver over a committed rule lowers that bar and is refused.
#[serde(default, rename = "waiver", skip_serializing_if = "Vec::is_empty")]
Expand Down Expand Up @@ -497,6 +517,7 @@ fn parse_ungated(text: &str, source: &str) -> Result<Config> {
// too: `batten.local.toml` may add verb rows, and a raise-only override that
// adds an inert one has still written something that cannot mean anything.
crate::verbs::validate(&config.verbs)?;
crate::redirect::validate(&config.redirects)?;
// And the marker table, for the identical reason in the identical shape
// (CLOUD-253). Both tables arrived in one commit; CLOUD-242 wired one of
// them up and nobody checked the sibling, so an empty `token` — which
Expand Down Expand Up @@ -650,6 +671,7 @@ impl Config {
unlanded: Vec::new(),
epoch: None,
verbs: Vec::new(),
redirects: Vec::new(),
markers: Vec::new(),
exec: None,
exec_patterns: Vec::new(),
Expand Down Expand Up @@ -891,6 +913,7 @@ mod tests {
/// [`parse_ungated`] that does it. Deleting a call fails the test below.
const VALIDATED_AT_LOAD: &[(&str, &str)] = &[
("verbs", "crate::verbs::validate("),
("redirects", "crate::redirect::validate("),
("markers", "crate::markers::validate("),
("rules", "crate::rules::validate("),
("exec_patterns", "crate::outputs::validate("),
Expand Down
Loading
Loading