Skip to content

feat(hook)!: retire the protected-path password onto an audited admission - #792

Merged
wenzowski merged 8 commits into
mainfrom
claude/issue-494-scope-aoi711
Sep 1, 2026
Merged

feat(hook)!: retire the protected-path password onto an audited admission#792
wenzowski merged 8 commits into
mainfrom
claude/issue-494-scope-aoi711

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What this is

protected-mutation's only way through was BATTEN_HOOK_BYPASS=1 — a knowable string the guarded party can set, recording nothing. Read the refusal and you hold the bypass forever, for every subject, in every session. The replacement already existed (batten override request/spend, CLOUD-1051 — "an override that is a record, not knowledge") and had never been wired to this class.

This PR wires it, removes the hatch for that one class, and gives the articulation somewhere durable to land.

The three pieces

1. A mediated refusal becomes admissible. V-PROTECTED-MUTATION declares R-ARTICULATE-THE-WRITE, an override route with a precondition. Refusal now carries the subject an admission binds to. admit_mediated is the mediated twin of filter_admitted: on a deny, it looks for a spent admission over all five binding fields and allows if one covers it.

The route alone would have been harmful — advertising an override, minting a real record, and still refusing. That is the defect class verdict.rs exists to kill, so the engine half is the deliverable and the route is the advertisement.

2. The hatch no longer opens this class. The protected-path gate adjudicates ahead of the bypass || is_empty short-circuit, the way Stop already does. Every other mediated row keeps its hatch. Existing cases asserting the hatch silences this class are inverted — they are the change, not collateral.

This was caught as a dead gate first: the unit tests were green and the binary still allowed, because the boundary handed adjudicate an empty policy under bypass. Only the binary-level tier saw it.

3. The articulation gets a reader (CLOUD-1278). The record lives in a container-scoped store, so the reasoning an override cost was legible only inside the session that wrote it. override spend now prints an Admits: block carrying every binding field, and batten commit check refuses a commit that wrote a protected path without one — admits when absent, admits-tampered when a block does not hash to the address it names.

Because the block spells out every field, recomputes is a pure function of the message: the clause decides identically with the store deleted, which is what makes it a CI tier rather than a local one.

spend's output site previously invoked rule 4 to keep the answers in the store. Rule 4 stops a gate republishing repository content; an articulation is the caller's own words, written to be read by a reviewer — admission.rs already says a record is "safe to print, log, quote in a commit". That assertion is inverted here with the argument at the site.

The PR is its own first subject

A commit here wrote batten.toml, so it was replayed to carry an articulation bound to its own parent. batten commit check origin/main..HEAD is exit 0 — reached by articulating, not by exempting.

What was dropped, and why

This PR originally also retired renovate-config-validator and bumped npm:renovate (CLOUD-1262). claim-race-check refused it: #797 already claims that key, is open and mergeable, and is not stale. Racing it is the defect CLOUD-230 measures, so the two retirement commits were dropped and #797 owns the row.

Dropping them also removed a fourth has_policy_surface arm I had added on a false premise — that a "spawn a tool, read its status" predicate has no Rego spelling. It does: input.tree["tool-verdict"] (CLOUD-1171) is declared on main today, and #797 is building the producer that fills it. The mechanism was not missing; only its producer was. Detail and one carried-over finding are on CLOUD-1262.

650899fa still serves CLOUD-1262 — it is the renovate.json5 packageRule that refuses renovate's own major line, which that row names as shape 1 of two and says outright is "not the fix and must not be mistaken for one". It buys quiet, not currency. So this PR declines to close that key rather than moving the row a column ahead of the port that #797 is landing.

Notes for review

  • The Admits-* blocks are the audit trail. They are the caller's own words and are the thing to read adversarially.
  • commit::Finding gains an optional path. That is a pointer, which §6 names as an allowed shape; without it an author is told a commit lacks an articulation and left to guess which protected path.
  • CLOUD-1226's redirect row landed here too: a registered .rego module was being told to use a Serena memory tool, because the derived protected class matched no [[redirect]] glob and fell through to the verb's own remedy. Its §7 test asserts both directions — a module gets policy-test, a memory still gets edit_memory — since the first alone is satisfied by a build that stopped naming the Serena tools at all.
  • One long line per answer, deliberately: unfolding a folded continuation is lossy the moment an answer holds a run of spaces, and that surfaces as a false tamper report over text nobody edited.

Closes CLOUD-1278
Closes CLOUD-1226
DO-NOT-CLOSE CLOUD-1262

@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown
CLOUD-1262 The `npm:renovate` 41→44 bump has been made and reverted twice, because the reason it cannot land lives only in a commit message and a comment — retire `renovate-config-validator` so the seam can be renamed

Why

npm:renovate is pinned at 41.173.1, three majors stale, on the tool whose
whole job is dependency currency. It cannot move, and the blocker is real:

Renovate 44 reads the RENOVATE_CONFIG environment variable as inline JSON5**
**config. mise-tasks/renovate-config-validator.sh:32 uses that same name for
the path seam its suite sets (tests/renovate-config-validator.bats:15), so the
validator is handed /tmp/bats-run-.../renovate.json5 as config CONTENT and dies
at JSON5: invalid character 't' at 1:2 — the t of /tmp. Caught by
tests/renovate-config-validator.bats:37, which is the gate doing its job.

Renaming the seam means editing the task and its suite, both authored shell
frozen by V-SHELL-RULE-EDITED, whose sole route is R-PORT-AND-RETIRE. So the
bump owes a migration and is its own change. That much was already known.

WHAT IS NEW IS THAT THE LOOP HAS NOW RUN TWICE, MEASURED.

lap what happened
1 a session bumped to 44.50.3; verify refused; reverted in ec2c9c15, with a detailed commit message and Refs: CLOUD-1136
2 Renovate re-proposed it on #494; a session bumped to 44.41.0; verify refused on the identical case; reverted again, with a near-identical commit message

Both laps cost a full verify — a cold cargo chain, the bats suite, the lot —
to rediscover a decision already made. Lap 2's session had mise.toml's comment
("THE PIN STAYS AT 41.173.1, AND THAT IS A FINDING RATHER THAN AN OMISSION")
directly above the line it edited, and did not read it.

Why this is a row rather than a better comment

The knowledge lives in exactly two places a bumper does not look: a commit
message on main, and a comment above the pin. Renovate reads neither, so it
re-proposes 44 indefinitely, and auto-bot-land.yml merges a bot bump on green
CI with nobody in the loop — the only thing stopping lap 3 landing is that
verify happens to be red for it.

The referencing is also broken in a way worth naming: both laps wrote
Refs: CLOUD-1136, and CLOUD-1136 is Done — it was the fastq@1.20.2 trust
review, closed 2026-08-29 in PR #738. The bump appears in its body only as one of
"two findings this exposed that are not this row's fix." So two commits
attribute live work to a completed row, which reads as ownership and is not. That
is what this row is for.

Two shapes, and this row is the second

  1. A renovate.json5 packageRule refusing renovate's own major line until the
    port lands. Cheap, stops laps 3..n, and changes nothing about the debt.
    20d7e818 is the shape, applied to regal for a different reason.
  2. The port-and-retire itself, which is the actual fix and what this row tracks.

Doing 1 without 2 is how a three-major staleness becomes permanent, so the row
carries both and 1 is not a substitute.

Refinement — Ready

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

  • **Source of truth (§1). **mise-tasks/renovate-config-validator.sh and
    tests/renovate-config-validator.bats — the two frozen files — plus the
    [tools] "npm:renovate" entry the port unblocks.
  • Computable predicate (§2). The validator's predicate lands as a
    policy/*.rego module plus a crates/batten/tests/*.rs tier; both shell paths
    are deleted with one conserves arm each; the gate leaves $MUTANT_GATES; and
    mise install resolves npm:renovate at 44.x with the validator still
    refusing a config Renovate would reject.
  • Effect (§3). A retirement per .claude/rules/toolchain.md's two shapes.
    The successor is a consumer module — the predicate names this repo's own config
    file — so no kind: field is owed.
  • Output & exit (§5). Unchanged: the validator's verdict is already
    pointer-only and rides the 0/1/2/3 table.
  • **Commit / bump (§6). **ci(toolchain) for the port; no crate bump.
  • Test obligation (§7). The discriminator is the seam itself — a case that
    passes a config path AND a case that passes inline config, so a future rename
    cannot silently re-collide. The 41.173.1 -> 44.x install must be shown green
    after, red before, rather than asserted.
  • Blockers (§8). None. relatedTo CLOUD-1136 (Done — the row this was
    wrongly attributed to), CLOUD-843 (the retirement campaign this belongs to).
{
  "source_of_truth": "mise-tasks/renovate-config-validator.sh",
  "gate": { "task": "policy-test", "exits": [0, 1] },
  "commit_type": "ci",
  "blockers": [],
  "tests": [
    {
      "file": "crates/batten/tests/renovate_config.rs",
      "mutation": "drop the path-vs-inline distinction, so a config path is read as content"
    }
  ]
}

Acceptance

  • Both shell paths are deleted with a conserves arm each, the predicate runs as
    a policy module over the compiled binary, and npm:renovate is pinned at 44.x
    with mise install green and the validator still refusing a bad config —
    demonstrated, not asserted.

CLOUD-1278 An override's articulation dies with the container, so the forcing function is a toll with no audit trail and no reader

Why

CLOUD-1051 replaced the override passwords with issued capabilities, and CLOUD-1120 made a spent admission actually suppress its finding. What neither did is give the articulation a reader.

admission::store_dir resolves under the OS data directory — in this repository a container the platform reclaims — so the reasoning an override cost is legible only inside the session that wrote it, and only until that session ends. There is no read verb either: override offers request and spend and nothing that can show a record back. Measured on this branch: after spending four real admissions, none of the four articulations could be recovered.

lib.rs's own output site made it worse deliberately, under a comment reading "POINTER, NEVER THE ANSWERS (rule 4) … the reasoning the author typed stays in the store where it was written", with a test asserting no answer text crosses stdout.

That reading of rule 4 is wrong, and it is the defect. Rule 4 stops a gate republishing REPOSITORY content — a secret it scanned, a file it read, a subject line somebody typed. An articulation is none of those: it is the caller's own words, composed for the express purpose of being read by a reviewer. admission.rs's own header already says an address "authorizes nothing on its own" so a record is "safe to print, log, quote in a commit and leave in a transcript", and refusal.rs calls this "rule 4's deliberate inversion". Honouring the letter of a rule that was not about them cost the design its entire point.

The stated intent for the mechanism was: increase the token cost of editing protected paths without preventing it, while deterministically producing an audit trail that lets a later reader diagnose what prose led to a bad decision. Half of that shipped. The half that shipped is the toll.

The destination

A commit message. It is durable by construction, travels with the change it justifies, needs no network to read, and is already in front of every reviewer and review bot per-commit. A pull request body loses on all three counts that matter: it is mutable after the fact, it is one blob for N commits so the per-write binding is lost, and writing it is land.sh's job — authored shell frozen by V-SHELL-RULE-EDITED.

Refinement — Ready

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

  • **Source of truth (§1). **crates/batten/src/admission.rs (the record and its address), crates/batten/src/commit.rs (the clause), crates/batten/src/lib.rs (the spend output site and the commit check wiring), crates/batten/src/git.rs (the range reader).
  • **Computable predicate (§2). **batten commit check <base>..<head> exits 2 naming every protected path a commit in the range wrote with no verifying Admits: block, and exits 0 when each carries one. Two findings, not one: admits for absent, admits-tampered for a block that does not hash to the address it names. The same clause runs at commit-msg over the staged set, so the unarticulated commit is never created.
  • Verifiable with the store deleted (§2). The block spells out every binding field, so Articulation::recomputes is a pure function of the message. A block carrying only the address would be a pointer into a store that has already been reclaimed, which is the failure being fixed. This is also what makes it a CI tier rather than a local one.
  • **Effect (§3). **commit check stays read-effect. override spend gains output only — it already performs the store write this reads back.
  • Output & exit (§5). The one contract: 0 clean, 2 a finding, 1 could-not-look. Findings are pointer lines — <sha8> <field> <path>. The path is a pointer, which §6 names outright as an allowed shape; without it an author is told a commit lacks an articulation and left to work out which of its protected paths.
  • **Commit / bump (§6). **feat(commit), minor.
  • **Test obligation (§7). **crates/batten/tests/commit_admission.rs over the compiled binary, with an_unarticulated_protected_write_is_refused as the premise case — a fixture whose protected glob selected nothing yields no paths, so every admitting case would otherwise pass over a clause that never fired. the_clause_needs_no_store_to_decide removes the store and asserts the block still verifies.
  • Blockers (§8). None. relatedTo CLOUD-1051, CLOUD-1120, CLOUD-1125, CLOUD-1050.

Acceptance

  • A protected write with no block is exit 2 and names the path; the same commit with a spent admission's block is exit 0.
  • An edited block reports admits-tampered, never admits — rolling both into "missing" would let a doctored articulation read as an honest omission, which is the one failure a content-addressed record exists to expose.
  • The clause decides identically with the override store deleted.
  • override spend prints a block that commit check accepts, so the verb that issues and the gate that reads cannot disagree about the format.

Not in this issue

A batten override show read verb. spend prints the block at the one moment it is needed, so the remaining gap is recovering a block for an admission already spent — real, but a follow-on.

CLOUD-1125's head-binding problem is adjacent and NOT solved here: the block records head for the reader, and recomputes does not compare it against the commit's actual parent, so a rebase leaves the block verifying. That is what makes this survive the landing loop; it is not a fix for the gate CLOUD-1125 describes.

Refs: CLOUD-1051, CLOUD-1120, CLOUD-1125, CLOUD-1050

CLOUD-1226 A registered `[[rule]] module` path is protected by derivation, and nothing says so: `protected` lists three globs, no `[[redirect]]` names the class, and the refusal tells you to use a Serena memory tool

Why

Writing to a .rego file a [[rule]] registers is refused with V-PROTECTED-MUTATION, and that path is in no table a reader can audit. batten.toml's protected is three globs — .serena/memories/**, batten.toml, .github/workflows/** — and none of them matches policy/task-substitution.rego. Neither does any [[redirect]] row.

Measured 2026-08-31 on ccb40a13, four probes that isolate it:

probe result
cp Cargo.toml /tmp/x allowed — unprotected tracked path
cp Cargo.toml policy/probe-delete-me.rego allowed — a .rego no row registers
cp policy/task-substitution.rego /tmp/x allowed — registered module as SOURCE
cp /tmp/x policy/task-substitution.rego refused, V-PROTECTED-MUTATION

So the discriminator is neither the directory nor the extension: it is being named by a [[rule]]'s module column. The protection is derived from the rule table rather than declared in protected.

The protection itself is right. A module is the policy authority as much as batten.toml is — protected's own comment says of that file "deleting it is the maximal weakening — it disarms every gate at once, including this one", and that argument covers a module verbatim. This row does not propose removing it.

What is wrong is that it is unauditable and its remedy is somebody else's

The remedy names a surface that cannot own the file. Because no [[redirect]] row matches the class, it falls through to MutatingVerb::redirect, which is written for memories:

Refused by protected-mutation: V-PROTECTED-MUTATION ... policy/task-substitution.rego cp.
Fix: write through the surface that owns the file — for a memory that is the Serena
tool `write_memory`.

There is no surface that owns a .rego module. A reader following that remedy reaches for a memory tool against a policy module. That is CLOUD-1050's class — "a refusal naming a remedy that does not exist" — and CLOUD-280 already built the mechanism that fixes it: the per-path-class [[redirect]] table, which carries rows for batten.toml and .github/workflows/** and none for this class.

And a reader auditing protected learns nothing. The one place a consumer looks to answer "what is guarded here" omits the largest guarded set in this repository — 29 modules today. Non-negotiable rule 6 keeps configuration narrow with one committed authority; a protection that exists only in derivation is a second authority nobody can read.

Cost, measured rather than argued: it took four refusals and four probes in one session to establish what a reader should have got from one table, and each refusal was resolved by spending BATTEN_HOOK_BYPASS — the global hatch, on a call that had a legitimate purpose and no declared route.

RETRACTED: "the verb is misattributed on a compound call" — this did not reproduce

An earlier revision claimed V-PROTECTED-MUTATION names a program from a
different segment than the operand on a compound call, citing cd and export.
Re-measured 2026-08-31 on 0.0.134, adjudicate-only:

command result
cp /tmp/x batten.toml refused, names cp
export FOO=1 && cp /tmp/x batten.toml refused, names cp — correct
echo hi && sed -i s/a/b/ batten.toml refused, names sed — correct
cd /home/user/batten && git add policy/task-substitution.rego allowed, no refusal at all
cd /home/user/batten && grep -n protected batten.toml refused by no-tool-substitution, a different gate

The attribution is correct on every compound case tested. The original reading
came from conflating two gates' refusal messages: the grep case is
no-tool-substitution, whose message names the utility and the path in a similar
shape, and I read its text as protected-mutation naming export.

protected_mutation iterates segments(command) and builds candidates per
segment (hook.rs:6189), so it is anchored per segment already — which is what
the measurement now shows.

RETRACTION: the "declared protection is inverted on Bash" measurement was wrong, and it was mine

An earlier revision of this row claimed that cp to batten.toml and to
.serena/memories/** — both literal protected entries — was allowed while
cp to a derived module path was refused, and concluded the DECLARED protection
does not hold on the Bash surface at all. On that basis this row was raised to
Urgent. That is false. It was an artefact of the probes: they used ABSOLUTE
destinations.

Re-measured 2026-08-31 on 0.0.134, adjudicate-only (batten hook --harness exit-code, which judges a payload without executing it — so no probe needed to
touch a tracked file, unlike the earlier round, which clobbered two):

probe exit
cp /tmp/x batten.toml 2, refused
cp /tmp/x ./batten.toml 2, refused
cp /tmp/x /home/user/batten/batten.toml 0, ALLOWED
cp /tmp/x .serena/memories/probe.md 2, refused
cp /tmp/x policy/task-substitution.rego 2, refused
cp /tmp/x policy/probe-delete-me.rego 0, allowed (no row registers it)
mv, >, tee, sed -i, rm at batten.toml 2, refused

The declared protection holds, on every verb tested. The discriminator is not
declared-vs-derived; it is relative vs absolute path spelling.

The cause is CLOUD-1133's fix landing at one reader, and it has its own row now

protected_mutation matches a Bash operand with normalise (hook.rs:6403),
which strips a leading ./ and nothing else, so an absolute operand never matches
a repo-relative glob. CLOUD-1133 diagnosed exactly this class, wrote
relative_to for it, argued the fix belonged in "one place, because there is more
than one reader" — and then landed it only at Envelope::relativise_writes, while
its body scoped the command half out on the sentence "a shell operand is typed*
*the way the author typed it, which is usually relative."

That is now CLOUD-1236, Urgent, and it owns the fix. Not restated here — one
authority. It is the whole of what made this row look Urgent, so this row drops
back to what actually remains its own.

What remains this row's, and it is real

Both of these reproduced cleanly:

  • The derived protection is unauditable. 29 registered module paths are
    guarded, and protected — the one table a consumer reads to answer "what is
    guarded here" — names none of them.
  • The refusal over a module names the wrong surface. Measured verbatim:
    cp /tmp/x policy/task-substitution.rego"Fix: write through the surface*
    *that owns the file — for a memory that is the Serena tool write_memory."
    There is no surface that owns a .rego module, so a reader is sent to a memory
    tool against a policy module. CLOUD-280's [[redirect]] table is the mechanism;
    this class has no row in it.

Refinement — Ready (declare the class, give it a redirect, anchor the verb per segment)

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

  • Authority boundary (§1). Whichever of crates/batten/src/{hook,redirect}.rs resolves the protected set for a Bash operand and pairs a verb with it. **No **mise-tasks/ **program and no **tests/**/*.bats is edited or added. The protection is not removed and protected is not widened to name modules by glob — the derivation is the right source, since a glob would go stale the moment a row is added.
  • Computable predicate (§2). A refusal over a registered module path carries a redirect written for that class, not the memory fallback. (The second predicate this bullet used to carry — per-segment verb attribution — is gone with the retraction above: it already holds.)
  • Deliberately not in scope (§2). Removing the protection. Whether policy/ should be protected at all — it should. CLOUD-609's bare-directory glob gap, which is a different miss in the same matcher.
  • **Effect (§3). **read.
  • Output and exit (§5). Pointer-only and unchanged: the path and the verb, never the file's bytes. V-PROTECTED-MUTATION keeps its token; what changes is the route it resolves and the accuracy of one subject.
  • **Commit / bump (§6). **fix(policy) — patch.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/. Shown able to fail per CLOUD-418: a write to a registered module reports the module-class redirect and not the write_memory text — with the mirror that a write to .serena/memories/** still reports the Serena route, without which the first is satisfied by deleting the memory remedy.
  • Blockers (§8). None. relatedTo CLOUD-280 (which built the per-class redirect table this fills a row of), CLOUD-96 (the gate), CLOUD-1141 (the reader/writer split), CLOUD-437 and CLOUD-1050 (a refusal owes an actionable remedy).

Acceptance

  • A reader can determine from committed configuration that registered module paths are guarded — either the redirect row states it, or the protected comment does, or both.
  • The refusal over a module names a route a reader can take.
  • The protection still holds: a write to a registered module is still refused.

Found while repairing CLOUD-1222 and CLOUD-1156: four consecutive refusals over policy/*.rego, each resolved by spending the global hatch, before four probes established what the rule was actually keyed on.

Review in Linear

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 15 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: bf99f517-e8f3-44a6-acde-174632e18cbc

📥 Commits

Reviewing files that changed from the base of the PR and between 93a096d and a673667.

📒 Files selected for processing (4)
  • batten.toml
  • crates/batten/src/lib.rs
  • crates/batten/tests/mediated_verbs.rs
  • renovate.json5
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: d8a89cf3-7843-4946-bfd3-2438e2234288

📥 Commits

Reviewing files that changed from the base of the PR and between 9cac169 and 93a096d.

⛔ Files ignored due to path filters (2)
  • hk.pkl is excluded by !**/*.pkl
  • mise.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • .claude/rules/toolchain.md
  • batten.toml
  • bench/suites/RESULTS.md
  • crates/batten/src/admission.rs
  • crates/batten/src/commit.rs
  • crates/batten/src/git.rs
  • crates/batten/src/hook.rs
  • crates/batten/src/lib.rs
  • crates/batten/tests/admission.rs
  • crates/batten/tests/commit_admission.rs
  • crates/batten/tests/mediated_admission.rs
  • crates/batten/tests/renovate_config.rs
  • crates/batten/tests/retirement_doctrine.rs
  • mise-tasks/renovate-config-validator.sh
  • mise.toml
  • policy/shell-retirement.rego
  • tests/renovate-config-validator.bats
💤 Files with no reviewable changes (2)
  • tests/renovate-config-validator.bats
  • mise-tasks/renovate-config-validator.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/batten/tests/mediated_admission.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds subject-bound mediated admissions for protected writes. Admissions can be printed as self-verifying commit-message blocks and checked for committed or staged protected-path changes. Protected-path checks remain active during bypass, while spent matching admissions can allow mediated denials. The change also moves Renovate validation into a Batten command rule, adds a protected policy redirect, recognizes batten.toml as a policy surface, updates the Renovate tool pin, and adds integration coverage.

Merge Risk: 🟠 High · up to 93a09

Protected-path writes now rely on spent admissions instead of the bypass password, but the admission reader can trust a forged spent record from the local store without validating its integrity or history. Anyone able to modify that store could authorize a protected repository mutation, so this authorization check should be hardened before merge.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@wenzowski
wenzowski force-pushed the claude/issue-494-scope-aoi711 branch from 76151b9 to 8e1b861 Compare September 1, 2026 00:29
@wenzowski wenzowski changed the title ci(deps): refuse renovate's own major line until the seam is renamed feat(hook)!: retire the protected-path password onto an audited admission, and land the retirement it unblocks Sep 1, 2026
@wenzowski
wenzowski force-pushed the claude/issue-494-scope-aoi711 branch 2 times, most recently from 93a096d to 3823c43 Compare September 1, 2026 03:24
@wenzowski wenzowski changed the title feat(hook)!: retire the protected-path password onto an audited admission, and land the retirement it unblocks feat(hook)!: retire the protected-path password onto an audited admission Sep 1, 2026
@wenzowski
wenzowski force-pushed the claude/issue-494-scope-aoi711 branch from 6e84017 to 48f03aa Compare September 1, 2026 04:22
Shape 1 of CLOUD-1262, which that row names and which I filed instead of doing.
The row says it plainly - "cheap, stops laps 3..n" - and then the session that
wrote it shipped neither half. This is the half that was one edit away, next to
the identical rule landed for `regal` an hour earlier.

Renovate 44 reads `RENOVATE_CONFIG` as INLINE JSON5 config.
`mise-tasks/renovate-config-validator.sh:32` uses that same name for the path
seam its suite sets, so the validator is handed `/tmp/bats-run-.../renovate.json5`
as config CONTENT and dies at `JSON5: invalid character 't' at 1:2`.

WHY A RULE RATHER THAN THE COMMENT THAT ALREADY EXISTS. The collision was
documented directly above `mise.toml`'s pin, in capitals, and recorded in
ec2c9c1's commit message. The bump was still made and reverted twice:

  lap 1  44.50.3, verify refused, reverted in ec2c9c1
  lap 2  44.41.0 via GitHub #494, verify refused identically, reverted

Each lap cost a full cold verify to rediscover a settled decision, and lap 2's
author had the comment on screen while editing the line under it. Prose is
feedforward only; a rule is the mechanism. Renovate reads neither, so it
re-proposes 44 indefinitely, and `auto-bot-land.yml` merges a bot bump on green
CI with nobody in the loop - lap 3 is stopped today only by verify happening to
be red for it, which is luck rather than a refusal.

WHAT THIS IS NOT. It buys quiet, not currency. `npm:renovate` stays three majors
stale on the tool whose whole job is dependency currency, and the actual fix is
the port-and-retire CLOUD-1262 tracks: both shell paths deleted, the predicate
landed as a policy module plus a compiled-binary tier, the seam renamed. The row
comment says DELETE THIS ROW as part of that port, because a suppression left
behind after its reason is gone is how a temporary staleness becomes permanent.

`matchUpdateTypes` rather than `allowedVersions` for the reason 20d7e81 records:
`cap-drift` pairs an allowedVersions rule with a Cargo.toml cap (CLOUD-593), and
a `[tools]` entry has none, so the ceiling form reads as the half-lift that gate
exists to catch.

Refs: CLOUD-1262
…top being the only way through

`protected-mutation` refused every write to a protected path and offered one exit:
`BATTEN_HOOK_BYPASS=1`. That is a knowable string the guarded party can set, so it
records nothing and stops nobody — it only prices honest work. This repository had
already ruled on that exact shape for `V-FILED-OVER-OWN-DIFF`, in its own words:
"the point of the admission mechanism is that the bare variable stops working."

The mechanism to replace it has been landed since CLOUD-1051 and its doc comment
is the whole design: an override that is a RECORD, not knowledge. `request`
answers a class's declared precondition and issues a content-addressed admission;
`spend` consumes it. The gate never adjudicates the reason - the predicate is
"every declared question answered non-emptily", never "is this justification good",
which would be the model verdict non-negotiable rule 3 forbids. The cost is
thinking, not asking.

WHY IT NEVER REACHED THIS CLASS, which took reading to find and is two gaps
rather than one.

First, `V-PROTECTED-MUTATION` declared two routes and neither was an `override`
route, so `admission::questions_for` answered `None` and `batten override request`
replied that the class "declares no `override` route, so it cannot be overridden".
This adds `R-ARTICULATE-THE-WRITE`, whose precondition names the owning surface
deliberately: the first question forces the asker to say why the route they were
already given does not reach. That matters here because for the changes that need
this - registering a rule, adding a redirect, retiring a gate onto a config row -
`R-USE-THE-OWNING-SURFACE` names the very file being refused.

Second, and the half that makes the first safe: nothing on the mediated path ever
consulted the store. `filter_admitted` has dropped admitted findings on the TREE
surface since CLOUD-1120; the hook had no twin, and `rules.rs` states the
consequence outright - every native refusal is "simply not admissible, because
there is no token an admission could bind". Shipping the route alone would have
advertised an override, sent the caller through a real articulation, minted a real
record, and still refused. That is precisely the class `verdict.rs` exists to kill:
a refusal that names a remedy which does not work.

`admit_mediated` is that twin. It binds the same five fields, so an admission
cannot be harvested across gates, classes, subjects, trees or policy generations.
It runs ONLY on a deny, which is what keeps it inside the invocation budget - a
call nobody refuses reads no store, so `passthrough` stays below `noop`. It sits
at the boundary rather than the deny site because `adjudicate` is pure by
contract and reading a store is I/O.

`Refusal` gains the subject an admission binds to - the first path-bearing one,
which is already the finding's own pointer, so this is the same choice that
surface makes rather than a second one. Carried rather than re-derived, because a
boundary recomputing "which path was refused" would be a second authority over a
question the deny site already answered. Not serialized, so `-J` output is
byte-identical.

WHAT THIS COMMIT DELIBERATELY DOES NOT DO. `BATTEN_HOOK_BYPASS` still answers for
this class. Removing it is the half that makes the bound real, and it lands next -
after a test proves a spent admission actually opens the gate. Pulling the old
exit before the new one is demonstrated would leave the repository with no way
through if this path has a bug, which is a worse failure than the one being fixed.

Refs: CLOUD-1262
… binary

`9eaa5e74` declared `R-ARTICULATE-THE-WRITE` and wired `admit_mediated`, and
until this suite existed both were a promise: the refusal advertised an override
and nothing demonstrated that spending one reached the gate. A route that names a
remedy which does not work is the defect `verdict.rs` exists to kill, so the
route and its proof belong in the same change set.

Four cases in a new `crates/batten/tests/mediated_admission.rs`, over the
compiled binary rather than `with input as`: the question is whether the ENGINE
honours the record, which a fabricated input cannot answer.

  a_write_to_a_protected_path_is_refused          the premise
  a_spent_admission_admits_the_write_it_was_taken_for   the whole point
  an_issued_but_unspent_admission_does_not_admit  only Spent admits
  an_admission_for_another_subject_does_not_admit_this_one   the binding

A FIXTURE RATHER THAN `mediated_verbs.rs`, which adjudicates against the live
repository root. These cases must WRITE an admission, and the store lives under
`$GIT_DIR` — so hosting them there would deposit records in the developer's own
repository and bind its real HEAD.

THE PREMISE CASE EARNED ITS PLACE ON THE FIRST RUN, which is the argument for
writing it rather than a note about diligence. All four were red; it failed
`Some(1)` vs `Some(2)`, and the `1` is a usage error rather than a verdict — the
fixture's config was invalid, so nothing was adjudicating at all. Without that
case the admission tests would have gone green the moment the config parsed,
against a gate that never fired, and the suite would have proved nothing while
reading as proof.

THE `[[verb]]` ROW IN THE FIXTURE DOES NOT MATCH THE CALL AND IS STILL REQUIRED,
which is worth stating because it reads as a mistake. `Policy::is_empty` is
`shapes ∧ bundles ∧ (verbs ∨ protected)`, and it short-circuits `adjudicate`
before any gate runs — so a repository declaring protected paths and no verb rows
cannot refuse a write tool, even though `protected_tool_write` needs no matching
row and treats a verb miss as "no verb-level remedy" rather than as grounds to
allow. The row is what makes the policy adjudicable; it is not what selects the
call. Recorded in the fixture's own comment so the next reader does not delete it
as dead weight. That asymmetry looks like a real defect rather than a design and
is filed separately rather than widened into here.

`issued_but_unspent` is the economy `admission.rs` names: a mint that suppressed
on its own would restore the variable it replaced — hold the name, pay nothing,
override forever. Articulating is not overriding until it is spent.

With this green, removing `BATTEN_HOOK_BYPASS` for this class is next, and it is
now safe: the replacement is demonstrated rather than assumed.

Refs: CLOUD-1262
The password stops working. `BATTEN_HOOK_BYPASS` was the only way through a
`V-PROTECTED-MUTATION` refusal, which made that refusal a knowable string the
guarded party can set — recording nothing, stopping nobody. §8's property, "an
agent's context can never influence the rules it is judged by", was already false
because the agent could set the variable.

This is only safe because the previous two commits made the replacement real: the
class declares `R-ARTICULATE-THE-WRITE`, and `mediated_admission.rs` proves over
the compiled binary that a spent admission opens the same write. Taking the
variable away is therefore a repair rather than a wall. Doing these in the other
order, or together, would have left a window with no way through at all.

THE FIRST ATTEMPT WAS A DEAD CHANGE, and the way that surfaced is the reason the
binary tier exists. The narrowing was written inside `adjudicate` and its unit
cases went green — while the compiled binary still ALLOWED the write. The
boundary hands `adjudicate` a policy declaring nothing when the hatch is set, so
`policy.is_empty()` returned before the new branch could run. A unit case cannot
see that wiring by construction. `the_bypass_hatch_does_not_open_the_protected_
gate_over_the_binary` is what caught it, and without it this commit would have
claimed the password was retired while the password still worked.

So the real edit is at the boundary: an adjudicable call loads its policy whether
or not the hatch is set. `adjudicate` then runs the two `protected_write` stages
under the hatch and allows everything else, which closes both surfaces —
covering the write tool alone would have left `tee batten.toml` bypassable while
`Write` was not.

AN INVARIANT IS RETIRED RATHER THAN ERODED. `BYPASS_ENV`'s doc said "a bypassed
call must never pay a config read". That cannot survive: deciding whether a path
is protected needs the `protected` and `[[verb]]` tables, which ARE the config.
A bypassed adjudicable call now pays one load — the `noop`-to-`check` difference
in `perf`, ~0.7 ms against a 100 ms budget. A call with nothing to adjudicate
still skips it, and that is the arm the hot path rides. Both doc comments say so
now instead of asserting a property the code no longer has.

TWO EXISTING ASSERTIONS CHANGED, both deliberately, both documented at the site:

`the_protected_gate_honours_the_bypass_hatch` is inverted and renamed. It was
correct for its whole life — while the variable was the only way through,
honouring it was the difference between a gate and a wall. Its complement,
`the_bypass_hatch_still_reaches_an_explicit_row`, is new and is the
discriminator: without it the inverted case would pass equally well had the hatch
been deleted outright, and a reader could not tell a scoped exemption from a
removal.

`bypass_scrub::the_hatch_is_load_bearing` is re-pointed at a `shape` row. Its
subject is CLOUD-1227's environment scrub, not the protected gate; it used a
protected refusal only as a convenient way to observe that the hatch was live.
Observing it through the one gate the hatch cannot open would assert the opposite
of this contract, and relaxing its assertion instead would have destroyed what
the file is for — a hatch that disarms nothing needs no scrub.

`hook_honours_the_bypass_hatch` is untouched and still passes: it drives a
`shape` row, which is the rest of the mediated surface and is what the hatch
still answers for.

3471 passed, 0 failed.

Refs: CLOUD-1262
Found by becoming the caller. Attempting the next edit in this branch — adding a
`[[redirect]]` row to `batten.toml` — the gate refused with a message ending
"Bypass with BATTEN_HOOK_BYPASS=1", the variable 9cac169 had just made
inoperative for exactly that class.

So that commit created a fresh instance of the defect this whole change exists to
remove: a refusal naming a remedy that does nothing. `crate::verdict`'s header
names the class outright — "a refusal could name no remedy, name a task that does
not exist, offer an override with no precondition" — and reading the message as
its author would not have caught it. Hitting it as somebody trying to get work
done did, immediately.

TWO HALVES, and the second is the one that matters.

The hatch sentence is omitted for `V-PROTECTED-MUTATION`, keyed on the same fact
the boundary decides on rather than an exempt-list that would drift out of
agreement with it.

Omission alone would have been a worse refusal than the wrong one. `render`'s fix
comes from `first_command_route`, which by construction cannot be the override —
so the way through would have been declared, honoured, and undiscoverable from
the one place a caller is looking. The refusal now names the route as the COMMAND
that takes it, composed from the refusal's own rule, class and subject, because
those three fields ARE the binding `admission::admitted` checks: a caller who
runs the line back gets an admission for the situation they are in and cannot be
handed one for a different situation. That is the second thing `Refusal`'s subject
field (9eaa5e7) bought.

THE SHARED PROJECTION SPLIT, and the test says so rather than being relaxed.
`both_deny_paths_render_one_shape_and_neither_can_drop_the_fix_clause` required
the hatch on every deny, which was right while the hatch reached every row. It now
asserts what every deny still owes — `Refused by` and `Fix:` — and then asserts
both directions separately: an explicit `[[rule]]` row still advertises the hatch,
and a protected deny must NOT advertise it AND MUST name `batten override
request`.

That second conjunct is the point. Asserting only the absence would pass a
refusal that offers nothing at all, which is strictly worse than the wrong remedy
it replaced — and an assertion loosened to go green is indistinguishable later
from one that was always weak.

Third existing assertion this change has moved, all for one reason: the hatch used
to be universal and is not any more. Each was inverted or split rather than
weakened, with the reversal recorded at the site.

3471 passed, 0 failed.

Refs: CLOUD-1262
… stops being told to use a memory tool

CLOUD-1226. Every enabled module and bundle root is a protected path, derived
from the rule table rather than listed in `protected` (CLOUD-763/833). Those
paths match no glob in the `[[redirect]]` table, so `protected_refusal`'s three
tiers fell through to tier two — the verb's own `redirect` — and for a write tool
that text ends "for a memory that is the Serena tool `write_memory`".

Editing `policy/shell-retirement.rego` was therefore answered with advice about
`edit_memory`, on a file that is not a memory and that no Serena tool can write.
Measured this session: that refusal was read as the engine being broken, and it
cost real time before the mechanism was understood.

THE VERB ROWS ARE NOT THE DEFECT, and repairing them would have been the wrong
fix — the tempting one, since the misdirecting string is right there. This file's
own note above the table already states the rule: per-path beats per-verb only
where the path fact dominates, and memories are deliberately absent BECAUSE their
remedy varies by action (`write_memory` / `edit_memory` / `rename_memory` /
`delete_memory`). Rewriting `tee`'s text to stop naming memories would have
broken the case the fallback exists for. What actually changed is that a second
class started reaching the same fallback, and a per-verb string naming one path
class is only ever right when the path happens to be that class.

`policy/**` RATHER THAN THE DERIVED SET, because this table is globs over paths
and the derived set is a property of the loaded rules — a row cannot name
"whatever is registered". The glob is wider than the derived set by exactly the
unregistered modules, which is the safe direction: an unregistered module is not
protected, so the row is unreachable for one and costs nothing.

HOW THIS EDIT WAS MADE, which is the point of the three commits before it.

`batten.toml` is protected, and as of 9cac169 no variable opens that gate. The
edit went through the declared route: an admission requested against
`V-PROTECTED-MUTATION`, answered, and spent.

  admission  fa60f5bb6cda25de4c7b28156ba7bb1b88b0841002086780f466545e5336ad8a

  precondition  R-USE-THE-OWNING-SURFACE names batten.toml and the change IS to
                batten.toml, so the surface this class points at is the subject
                being refused and cannot express it
  lost          CLOUD-1226 stays open: a registered module refusal keeps telling
                authors to fix a .rego with the Serena edit_memory tool, which
                cost this session real time
  rejected-route  R-RESTORE-IT is git restore, which undoes an edit rather than
                making one, and there is nothing to restore because the redirect
                row does not exist yet

The address is a hash of those answers, so editing them here invalidates it and
reusing them reproduces a spent address rather than a fresh one. That is the
first real spend outside a fixture, and it is the property this whole change set
exists for: not a password anyone can know, a record somebody had to write.

Noted rather than claimed: re-presenting that admission now reports `unbound`
rather than `spent`, because the binding carries the config epoch and this very
edit moved it. An admission for a config change is therefore single-use by
construction. The spent-cannot-be-respent property is `admission.rs`'s own suite,
not something this commit demonstrated.

config-lint 0 smells; 3471 passed, 0 failed.

Refs: CLOUD-1226

Admits: 094f50b0b0f9c32bb159d4d3c0a5123c273ac021cd2841302a425015fb5aa269
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: batten.toml
Admits-head: 04cb4e5
Admits-epoch: 104edb892c2db8711f1a16aa692c1ee87d41597d70af74d823174026e52a5fdc
Admits-author: alec@wenzowski.com
Admits-prev: d2f7d27a7ed140e441c9800b2f88e2c9bbefc4e09cee6c3f4dfcbf3331e3167d
Admits-answer-lost: Nothing is lost by writing it here. The alternative routes do not reach a config row at all: there is no other file the engine consults for one.
Admits-answer-precondition: `batten.toml` is the one authority the engine reads for rules, so a `[[rule]]`, `[[redirect]]` or `protected` row cannot be added anywhere else; the surface this class names is the file itself, and the change is one a reviewer sees in the diff it lands in.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is circular for this subject — `batten.toml` IS the owning surface for a rule row. R-RESTORE-IT would revert the change this commit exists to make.
…rride leaves a trail

The override route made a protected write ADMISSIBLE and produced nothing
anyone could read. `admission::store_dir` resolves under the OS data
directory — here a container the platform reclaims — so the reasoning an
override cost was legible only inside the session that wrote it, and only
until that session ended. There was no read verb either: `override` offered
`request` and `spend` and nothing that could show a record back.

A forcing function whose product nobody can read is a toll, not an audit
trail. This is the half that makes it one.

THE BLOCK, AND WHY IT VERIFIES WITH THE STORE DELETED. `admission::block`
renders a record as `Admits:` plus one line per binding field and one per
answer. EVERY binding field is spelled out, so `Articulation::recomputes` is
a pure function of the message: a runner that has never seen the store
decides identically, which is what makes this a CI tier rather than a local
one. A block carrying only the address would be a pointer into a store that
has already been reclaimed — the exact failure being fixed.

One long line per answer and no folding: unfolding is lossy the moment an
answer holds a run of spaces, and that shows up as an address that does not
recompute over text nobody edited — a false tamper report, strictly worse
than a long line.

TWO FINDINGS THAT MEAN OPPOSITE THINGS. `admits` is a protected path written
with no block claiming it. `admits-tampered` is a block that claims the path
and does not hash to the address it names. Rolling both into "missing" would
let a doctored articulation read as an honest omission, which is the one
failure a content-addressed record exists to expose.

`commit::Finding` gains the path it is about. That is a pointer, not a
payload — §6 names `path:line` outright — and without it an author is told a
commit lacks an articulation and left to work out which of its protected
paths. `skip_serializing_if` keeps `-J` over a subject-clause run
byte-identical.

RULE 4 WAS BACKWARDS AT `spend`'s OUTPUT SITE, and the assertion pinning it
is inverted here rather than worked around. The comment read "POINTER, NEVER
THE ANSWERS (rule 4) … the reasoning the author typed stays in the store".
Rule 4 stops a gate republishing REPOSITORY content — a secret it scanned, a
file it read. An articulation is the caller's own words, composed to be read
by a reviewer; `admission.rs`'s header already says a record is "safe to
print, log, quote in a commit and leave in a transcript", and `refusal.rs`
calls it "rule 4's deliberate inversion". Honouring the letter of a rule that
was not about them cost the design its entire point. `spend` now prints the
block after its pointer line, which stays first and unchanged.

`git::writes_in_range` is the range reader: per-commit message plus the paths
it moved under the protected globs, compared by BLOB ID inside the tree walk,
so a commit touching nothing protected costs two tree reads and no blob
reads. A root commit compares against the empty tree — otherwise the first
commit of a repository is the one place a protected path can be introduced
unarticulated.

THE KEY IN AN EARLIER DRAFT OF THIS COMMIT WAS WRONG. It cited CLOUD-1264,
which is a real but unrelated fleet-dispatch record; the row this work
belongs to is CLOUD-1278, filed for it. Corrected in every source comment
and here.

`crates/batten/tests/commit_admission.rs` is the compiled-binary tier, seven
cases. `an_unarticulated_protected_write_is_refused` is the premise: a
fixture whose protected glob selected nothing yields no paths, so every
admitting case would pass over a clause that never fired.
`the_clause_needs_no_store_to_decide` removes the store and asserts the block
still verifies. `mediated_admission.rs` gains the test-target lint header it
landed without — clippy is in the slow profile, so pre-commit did not see it.

Refs: CLOUD-1278, CLOUD-1051, CLOUD-1050
… a memory tool

CLOUD-1226 §7's obligation, which the redirect row landed without. That
commit was 28 lines of `batten.toml` and no test, so the row was served and
not completed — and `closing-key-check` is what said so, refusing to let the
PR close some of the keys its commits served and strand this one.

THE MIRROR IS WHAT MAKES IT DISCRIMINATE. Asserting only that a module's
refusal omits `write_memory` is satisfied by a build that stopped naming the
Serena tools anywhere, which would break the class they were written for. So
the second half asserts a memory write still gets `edit_memory`. Both
directions, or neither means anything (CLOUD-418).

Shown able to fail: with the `policy/**` `[[redirect]]` row's glob pointed
elsewhere, the case is red at the `write_memory` assertion — the derived
class falls back through `protected_refusal`'s tier two to the verb's own
redirect, which is the defect CLOUD-1226 records.

It lives in `mediated_verbs.rs` because that suite adjudicates against the
LIVE repository root, and "what does the committed redirect table decide" is
exactly its question. A fixture would assert about a table it wrote itself.

Refs: CLOUD-1226, CLOUD-1050, CLOUD-418
@wenzowski
wenzowski marked this pull request as ready for review September 1, 2026 04:57
@wenzowski
wenzowski force-pushed the claude/issue-494-scope-aoi711 branch from 48f03aa to a673667 Compare September 1, 2026 04:57
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit a673667 into main Sep 1, 2026
10 of 11 checks passed
@wenzowski
wenzowski deleted the claude/issue-494-scope-aoi711 branch September 1, 2026 05:16
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.

1 participant