ops-warden/workplans/WARDEN-WP-0038-plan-mutation-intent.md
tegwick 66db87e6ae feat: refuse to answer a write with a read (WARDEN-WP-0038)
`warden plan` scored needs by keyword overlap with no notion of what the
caller wanted to DO, so "generate a successor secret and CAS-write it to two
custodians" matched the lane that READS that path and inherited its
`autonomous` verdict -- answered with --out/--exec/--wrap.

Two counterparties reported it in two days. key-cape distrusted the output on
principle and was right to; railiance-platform, answering as the write
authority being wrongly bypassed, said plainly that `founder_required` is the
verdict it should have returned and that until it is fixed a plan result must
not stand in for the owner's answer.

A mutating need on a lane ops-warden does not permanently own can no longer
reach any branch returning `autonomous`: it becomes `founder_required` with
an approve act naming the write owner, or `unroutable` with a CCR stub when
the lane admits no rotation route. Commands carry no read transport either
way, which is the half that made the wrong verdict actionable.

The ownership test does the work a verb list cannot. SSH certificate
issuance is itself a mutating act, so `delegation.mode: permanent` -- not the
absence of a verb -- separates ops-warden's own front door from someone
else's custody. A regression asserts `warden sign` still proceeds; a guard
that refused our own lane would be worse than the defect it fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EPuTc18FjU5WFqoSEKH3C

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1276224@bnt-lap001
Assistant-Session: 426ec497-e1c4-4dd3-b417-dfce1ca1dbc3
2026-09-10 08:02:10 +02:00

126 lines
5.8 KiB
Markdown

---
id: WARDEN-WP-0038
type: workplan
title: "warden plan must distinguish reading a secret from mutating one"
domain: infotech
repo: ops-warden
status: finished
owner: ops-warden
topic_slug: netkingdom
planning_priority: P1
depends_on_workplans:
- WARDEN-WP-0029
created: "2026-09-08"
updated: "2026-09-09"
state_hub_workstream_id: "751ad530-e44b-52c4-b70e-cb47568a0179"
---
# WARDEN-WP-0038 — Read versus mutate intent in the policy front door
## The defect
`warden plan` scores a need against catalog lanes by keyword overlap and returns
a verdict. It has no notion of what the caller wants to *do*. So a need that
says *generate a successor secret and CAS-write it to two custodians* matches the
lane that **reads** that path, scores well on the shared nouns, and returns:
```
verdict: autonomous
commands: warden access <lane> --exec / --out / --wrap
```
Three read transports offered as the answer to a write, under the verdict that
means "proceed without the founder". Reproduced 2026-09-08 against
`rapp-qonto-keycape-client` (`ADHOC-2026-09-08-T04`).
## Why this is worse than a bad suggestion
`warden plan` is the documented first step before drafting any founder credential
step (`.claude/rules/credential-routing.md`), and `autonomous` is consumed as an
authorization signal by agents that have no other check. `WARDEN-WP-0029` shipped
the verdict *because* instinct was not auditable; a verdict that is confidently
wrong in the permissive direction is worse than no verdict.
key-cape hit this and refused to rely on it — "we are not treating a generic
match as authorization" — which is the only reason it surfaced as a design
question rather than as an unadmitted custody mutation. The control that caught
it was a counterparty's caution. That is not a control.
`WARDEN-WP-0033-T06` is the same shape (rapp-postgres, generic
`openbao-api-key` match → `paste_once_provision` for an act that could not work)
and was fixed by adding one exact lane plus one regression. That fixed the
instance. This is the class.
## Shape of the fix (to be designed, not assumed)
- Classify need intent — read / mutate / establish — before scoring, and never
answer a mutate need with a read lane's transports.
- A mutate need against a lane ops-warden does not own is `unroutable` with a CCR
stub naming the custody owner, not `autonomous`. `ADR-0003`: cover gaps, never
silently own them; a permissive verdict on someone else's custody act is a
silent claim of ownership.
- Consider whether `rotation.steps` should be reachable from `plan` at all, given
they are advisory prose and several are explicitly not automatable.
- Regression from the reproduced case, in the manner of T06.
## Open question for design
Whether intent belongs in the matcher or in the lane. A lane could declare which
acts it admits (`read`, `rotate`, `provision`) and `plan` could refuse any act a
lane does not declare — stricter, and it fails closed on lanes that say nothing,
which is the majority today. That is a catalog schema change and needs its own
argument.
## Confirmed independently, then fixed — 2026-09-09
`railiance-platform` hit the same defect from the other side while answering the
Qonto custody routing question (msg `7c7228ac`). Having stated that the correct
verdict is `founder_required` — attended OIDC via `netkingdom` `role=platform-admin`,
through the governed `openbao-platform-admin-login` lane — they added:
> Note this is the same verdict your `warden plan` should have returned; it
> returning `autonomous` and offering read transports for a write is a real
> defect and WARDEN-WP-0038 is the right place for it. Until that is fixed, do
> not let a plan result stand in for this answer.
Two counterparties reporting the same wrong verdict in two days, one of whom is
the write authority being wrongly bypassed, moved this from proposed to shipped.
### What shipped
`_need_intent()` classifies a need as `mutate` or `read` by token match, and
`_owns_write_authority()` tests `delegation.mode == "permanent"`. A mutating need
on a lane ops-warden does not permanently own can no longer reach any branch that
returns `autonomous`:
- lane declares a `rotation``founder_required`, with an `approve` act naming
the write owner, the rotation method, whether it is automatable, and
`warden rotate-guide` as guidance;
- lane declares none → `unroutable` with a CCR stub whose `owner_hint` names the
write authority.
**Commands are empty of read transports either way.** That is the half that made
the wrong verdict actionable rather than merely wrong: `--out`, `--exec`,
`--wrap` and a bare `bao kv get` were being offered as the answer to a write, and
a test now asserts none of them appears.
### The design question in the plan, answered by use
The plan asked whether intent belongs in the matcher or in the lane. It went in
the matcher, with the *ownership* test doing the work that a verb list cannot:
SSH certificate issuance is itself a mutating act, and `warden sign` must stay
`autonomous`. `delegation.mode: permanent` is what separates ops-warden's own
front door from someone else's custody — not the absence of a verb from a list.
A regression asserts the SSH lane still proceeds, because a guard that refuses
our own lane would be a worse defect than the one it fixes.
The stricter alternative — lanes declaring which acts they admit, `plan` refusing
anything undeclared — is not built. It is a catalog schema change that would fail
closed on the majority of lanes, which say nothing today, and it needs its own
argument rather than arriving as a side effect of this fix.
### Not claimed
This narrows what `warden plan` will assert; it does not make the tool an
authority on custody. railiance-platform's instruction stands and is the right
standing posture: a plan result does not substitute for the owner's answer.