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
This commit is contained in:
tegwick 2026-09-10 08:02:10 +02:00
parent 9c01a8a212
commit 66db87e6ae
3 changed files with 257 additions and 2 deletions

View file

@ -98,6 +98,48 @@ def _candidate_row(entry: RouteEntry, score: int) -> dict:
}
#: Verbs that make a need a request to CHANGE custody rather than to read it
#: (WARDEN-WP-0038). Reported independently by key-cape (2026-09-08) and
#: railiance-platform (2026-09-09): a need saying "generate a successor secret and
#: CAS-write it to two custodians" scored well against the lane that READS that
#: path and inherited its `autonomous` verdict, answered with three read
#: transports.
#:
#: Deliberately not including "issue" or "sign". SSH certificate issuance is a
#: mutating act ops-warden owns outright, and the ownership test below is what
#: separates it from someone else's custody — not the absence of the verb.
MUTATE_TOKENS = frozenset({
"rotate", "rotating", "rotation", "rerotate",
"generate", "regenerate", "mint", "reissue",
"write", "rewrite", "put", "patch", "cas",
"provision", "reprovision", "create", "install",
"revoke", "revoking", "disable", "delete", "remove",
"replace", "successor", "reset", "restart", "update",
})
def _need_intent(need: str) -> str:
"""``mutate`` when the need asks to change a credential, else ``read``.
Token match, not substring: "update" must not fire on "updated docs" any
more than it already would, but "no-update" style hyphenation is normalised
the same way the scorer normalises it.
"""
tokens = {t.strip(".,;:()") for t in need.lower().replace("-", " ").split()}
return "mutate" if tokens & MUTATE_TOKENS else "read"
def _owns_write_authority(entry: RouteEntry) -> bool:
"""Whether ops-warden may itself perform a mutating act on this lane.
True only where ops-warden is the designed owner of the front door the SSH
signing lane, `delegation.mode: permanent`. Everywhere else ops-warden is a
pointer or a caller-identity proxy (`ADR-0002`, `ADR-0005`), so a write is
another component's custody act and no plan verdict here can authorise it.
"""
return entry.effective_delegation.mode == "permanent"
def _score_for(catalog: Catalog, entry: RouteEntry, need: str) -> int:
if entry.id == need.strip():
return 100
@ -294,6 +336,7 @@ def build_plan(
)
entry, score = matches[0]
intent = _need_intent(need)
# Draft-only top match without active alternatives → unroutable
if entry.status == "draft" and not include_draft:
@ -378,6 +421,80 @@ def build_plan(
domain=domain,
)
# --- mutating need on a lane ops-warden does not own (WARDEN-WP-0038) ------
#
# Must run before any branch that can return `autonomous`. A write is a
# custody act belonging to the lane owner, and `autonomous` is documented as
# the signal to proceed without the founder — so returning it here would let
# this front door authorise a mutation on someone else's custody. It would
# also answer a write with `--out`/`--exec`/`--wrap`, which are reads.
if intent == "mutate" and not _owns_write_authority(entry):
rotation = entry.rotation
owner = (rotation.owner if rotation else None) or entry.owner_repo
reasons = [
"need asks to change a credential, not read one",
f"write authority on this lane belongs to {owner}, not ops-warden",
]
if rotation is not None:
act = FounderAct(
kind="approve",
summary=(
f"Attended owner act required to change {entry.id}"
f"{owner} holds write authority"
),
details={
"lane_id": entry.id,
"write_owner": owner,
"rotation_method": rotation.method,
"rotation_automatable": rotation.automatable,
"wiki_ref": entry.wiki_ref,
"guidance_command": f"warden rotate-guide {entry.id}",
"desk_hint": f"warden desk --act approve --lane {entry.id}",
},
)
if not rotation.automatable:
reasons.append(
"lane records the rotation as not automatable — an executable "
"driver must not attempt it"
)
return AccessPlan(
need=need,
verdict="founder_required",
organization_posture=org,
policy_gate=gate,
lane_id=entry.id,
lane_title=entry.title,
match_score=score,
# Deliberately empty: every command this lane offers is a READ,
# and offering one against a write need is the reported defect.
commands=[f"warden rotate-guide {entry.id} # guidance, not execution"],
founder_act=act,
catalog=freshness,
candidates=candidates,
reasons=reasons,
actor=actor,
domain=domain,
)
stub = _ccr_stub(need)
stub["owner_hint"] = f"{owner} (write authority for {entry.id})"
reasons.append("lane records no rotation route — the act has no admitted transport")
return AccessPlan(
need=need,
verdict="unroutable",
organization_posture=org,
policy_gate=gate,
lane_id=entry.id,
lane_title=entry.title,
match_score=score,
commands=[],
ccr_stub=stub,
catalog=freshness,
candidates=candidates,
reasons=reasons,
actor=actor,
domain=domain,
)
if _lane_is_autonomous(entry):
return AccessPlan(
need=need,

View file

@ -119,3 +119,87 @@ def test_cli_plan_json():
assert payload["verdict"] == "autonomous"
assert payload["organization_posture"] == "build"
assert payload["lane_id"] == "agent-harness-forgejo-deploy"
# --- mutate intent (WARDEN-WP-0038) -------------------------------------------
#
# `warden plan` scored a need 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. Reported independently
# by key-cape (2026-09-08) and railiance-platform (2026-09-09), who added: "this
# is the same verdict your warden plan should have returned; until that is fixed,
# do not let a plan result stand in for this answer."
QONTO_ROTATION_NEED = (
"generate a successor client secret for rapp-qonto keycape client and "
"CAS-write it to OpenBao platform/workloads/rapp-qonto/keycape-client "
"and sso/keycape-rapp-qonto-client"
)
def test_reported_qonto_write_no_longer_returns_autonomous():
"""The exact need from the report. Regression, in the manner of WP-0033-T06."""
plan = build_plan(QONTO_ROTATION_NEED)
assert plan.verdict == "founder_required"
assert plan.lane_id == "rapp-qonto-keycape-client"
assert plan.founder_act is not None
assert plan.founder_act.kind == "approve"
assert plan.founder_act.details["write_owner"] == "key-cape"
def test_a_write_need_is_never_answered_with_a_read_transport():
"""The half that made the wrong verdict actionable rather than merely wrong."""
plan = build_plan(QONTO_ROTATION_NEED)
joined = " ".join(plan.commands)
for read_transport in ("--out", "--wrap", "--fetch", "bao kv get", "--exec"):
assert read_transport not in joined, read_transport
def test_mutating_need_names_why_it_escalated():
"""WP-0029's property: a verdict carries the reasons that produced it."""
plan = build_plan(QONTO_ROTATION_NEED)
assert any("change a credential" in r for r in plan.reasons)
assert any("write authority" in r for r in plan.reasons)
# The lane records automatable: false; a driver must be told so.
assert any("not automatable" in r for r in plan.reasons)
def test_ops_warden_still_proceeds_autonomously_on_the_lane_it_owns():
"""Signing IS a mutating act. The test is ownership, not the absence of a verb.
`delegation.mode: permanent` is the whole distinction if this ever fails,
the guard has started refusing ops-warden's own front door.
"""
for need in (
"sign an ssh certificate for agt-state-hub-bridge",
"issue a short-lived ssh cert for adm",
):
plan = build_plan(need)
assert plan.verdict == "autonomous", need
assert plan.lane_id == "ssh-cert-host-access"
def test_reading_the_same_lane_is_unaffected():
plan = build_plan("I need the npm token to publish whynot-design")
assert plan.verdict == "autonomous"
assert plan.lane_id == "whynot-design-npm-publish"
@pytest.mark.parametrize(
"verb", ["rotate", "revoke", "regenerate", "reset", "replace"]
)
def test_mutating_verbs_escalate_on_a_lane_ops_warden_does_not_own(verb):
plan = build_plan(f"{verb} the forgejo admin api token")
assert plan.verdict == "founder_required"
assert plan.founder_act.details["write_owner"] == "railiance-platform"
def test_intent_classifier_matches_tokens_not_substrings():
from warden.plan import _need_intent
assert _need_intent("rotate the forgejo token") == "mutate"
assert _need_intent("CAS-write to two custodians") == "mutate"
# "created" and "updated" are not the verbs; a need describing state is a read.
assert _need_intent("read the token created for whynot-design") == "read"
assert _need_intent("which subsystem owns the npm token") == "read"

View file

@ -4,14 +4,14 @@ type: workplan
title: "warden plan must distinguish reading a secret from mutating one"
domain: infotech
repo: ops-warden
status: proposed
status: finished
owner: ops-warden
topic_slug: netkingdom
planning_priority: P1
depends_on_workplans:
- WARDEN-WP-0029
created: "2026-09-08"
updated: "2026-09-08"
updated: "2026-09-09"
state_hub_workstream_id: "751ad530-e44b-52c4-b70e-cb47568a0179"
---
@ -70,3 +70,57 @@ 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.