ADR-0014 — qed governs its own admin operations, off by default, with a loud break-glass¶
Status: accepted · week 10 (2026-10-10) Implements: SPEC §17 (admin operations through the approval service with SoD) Relates to: ADR-0010 (rotation destroys the old key), ADR-0012 (pack bundles)
Context¶
Every rule in the baseline pack constrains an agent. The operator has been the one actor no rule touches — and the operator is who rotates the signing key, replaces the control catalogues, and edits the policy an agent is judged by.
"Who governs the thing doing the governing" is the first question an auditor asks about a governance product, and until this the answer was "nobody".
SPEC §17 fixes the intent: admin operations go through the approval service
with SoD and produce admin_action evidence. It leaves open how, when, and
what happens when the approver is asleep.
Decision 1 — Reuse the approval machinery, do not build a second one¶
An admin operation opens an ordinary
:class:~qed.core.approval.model.ApprovalRequest, is decided by the existing
qed approve, and goes through the existing
check_separation_of_duties. The only new state is a synthetic
proposal_id of the form adm:<operation>:<ulid>.
Two approval systems would be two places for the SoD check to live, and the one
that mattered would be the one nobody tested. This is also why the admin path
is not routed through the policy engine as a proposal: that would need a
delegation token for an operator at a terminal — so qed keys rotate on a
laptop would require an IdP — and AG-IDN-009 would BLOCK it, since it exists
precisely to stop anything targeting qed's own configuration. The agent path and
the operator path are different paths that meet at the same approval.
Decision 2 — Off by default¶
Self-governance needs a second person. The common case is one operator on a
laptop, and on by default would mean qed keys rotate fails on first use —
which teaches people that the feature is an obstacle to be removed rather than
a control to be configured.
What keeps that honest is that qed doctor reports it either way. An
instance with no gate says so, in yellow, with the sentence an auditor would
write: the operator is the one actor no rule constrains. A silence there would
have read as "fine".
And the admin_action record carries self_governed: false rather than
omitting the field, so an auditor can tell a rotation that was unapproved by
configuration from one that evaded a gate. A field that appears only when
something is wrong is a field nobody can distinguish from one this build never
wrote.
Decision 3 — Break-glass exists, and costs a permanent record¶
The reason to rotate a signing key is that it may have leaked. That is exactly when the approver is asleep.
A gate with no break-glass does not stop the rotation; it moves it outside the
tool. The operator edits qed.yaml, sets self_governed: false, rotates, and
sets it back — and nothing records any of it. Pushing people outside the
product to do the risky thing is worse than letting them do it inside, where it
leaves a trace.
So --break-glass "<reason>" proceeds. It costs: the reason is required, the
admin_action carries break_glass: true and the reason verbatim, the command
prints a red warning on stderr, and the record is permanent because
admin_action is in NEVER_PURGE.
Two ordering details, both deliberate. Break-glass is checked after the governed check, so passing the flag on an ungoverned instance does not write a false emergency into an immutable chain. And it is checked before the approval lookups, so an operator in an incident is not told to go and find an approval id first.
Decision 4 — An approval is bound to one operation and spent once¶
The operation is inside the synthetic proposal_id, which is inside the request
the approver saw and decided on — so it cannot be changed afterwards without
the approval becoming a different approval. An approval granted for importing a
catalogue cannot be spent on rotating a key: "approve the catalogue import" is
a smaller ask than "approve rotating the signing key", and spending one on the
other reads as an administrative slip and works as a privilege escalation.
Single use is read out of the chain, not from a column. The admin_action
carrying an approval_id is the receipt that it was used. A flag in a table
could disagree with the chain after a restore from backup, and admin_action is
in NEVER_PURGE so the receipt cannot age out from under the check. The scan
looks at admin_action records only — a real instance has a handful, and
somebody is standing at a terminal waiting for a key rotation, so this is a
human-latency path.
The requested: true flag is what separates "somebody asked to rotate the key"
from "the key was rotated". Both belong in the chain: a request that was never
approved is a thing an auditor should be able to see.
Decision 5 — The requester is self-asserted on the CLI, and we say so¶
qed approve --by you@example.com has always taken the approver's word for it,
because the CLI runs on the operator's own machine. Self-governance inherits
that, and this ADR states the consequence rather than leaving it to be
discovered:
On the CLI, self-governance buys that two separate invocations naming two separate subjects are required. That is a real obstacle to a careless action and no obstacle at all to a determined operator who lies about who they are. The identity is proven only on the API path, where a delegation token says who is asking.
Claiming cryptographic separation of duties here would be the same kind of
overclaim as a compliance mapping nobody checked — a "separation of duties"
control reported in an export while nothing enforces it, which
check_separation_of_duties' docstring names as worse than not having the
control at all. THREAT_MODEL.md carries this
where an auditor will read it.
Consequences¶
keys_rotateandpacks_importare gated.policy_reloadis in the operation enum with no command behind it: SPEC §17 names it, and a config naming an operation the enum did not know would be the quiet failure the validator exists to prevent.admin.self_governed: truewith noadmin.approversis refused at load. A gate nobody can open is not a gate; it is an outage waiting for the first rotation.- A misspelt operation name is refused at load too, for the same reason: a security setting an operator believes is on is worse than one that is off.
- Found while testing this: any invalid
qed.yamlprinted a pydantic traceback.mode: nonsenseproduced forty lines of frames with the answer in the middle.ConfigErrornow carries the file path and the field path, and the CLI's group handler prints it as a sentence. That was pre-existing and had nothing to do with self-governance; it was found because a validation test asserted on output that did not exist.