ctrlrun 0.8.0
Every guarantee shipped before this one verifies the principal that acts. G7 refuses an action
whose requester cannot be resolved; nothing whatever was asked of the principal that permits it.
approver was a non-empty string, ctrlrun delegate --as was an assertion typed at a shell, and
the operator MCP server authenticated who answered without checking they were entitled to. v0.8
asks the question all seven put only to the acting side: who may say yes, and can the kernel
tell?
Five guarantees answer it — G17 an unentitled approver, G18 the requester cannot approve, G19 one
principal counts once, G20 a credential revoked before its exp, G21 an unapproved policy decides
nothing — and one thing that is not a guarantee: break-glass, which is a grant and not a flag.
Opt in, then fail closed. A deployment that names no approver identity behaves exactly as
0.7.0 did, and a test drives the whole approve-and-execute path to prove it. One that names one has
no partial mode, no "resolve if you can", and no setting that puts the string back. There is no
skip_entitlement, no trust_approver, no allow_self_approval, no break_glass=True, no
ignore_revocations — and that sentence is a test, not a claim: the shipped package is grepped for
sixteen spellings a flag would take, and the control plants one and finds it.
What v0.8 does not close, in one place. A persuaded approver gives a valid approval and the
receipt records it as one. An entitlement check is against what the granting surface recorded,
not a re-derivation from a credential that no longer exists. A revoked credential leaves a log line
and no receipt. A feed is worth what its source is worth. And a policy change that no verified
principal other than the proposer approved decides nothing — which is not the same as saying a
policy cannot be changed by whoever holds the file.
Added
-
A policy change is a protected action (
docs/SPEC-v0.8.md§8). The policy is the one file
that decides every other decision, and until now it was changed by editing it. v0.6 made the
change evidenced: every receipt records the hash of the policy that decided it. v0.8 makes it
approved: a policy nobody approved decides nothing.ctrlrun policy propose --file new.yaml ctrlrun approve <request> # there is no `ctrlrun policy approve`Control(policy, store, require_approved_policy=True)
An ordinary action, which is why §8 adds no event type.
ctrlrun.policy.changehas an
ordinary action hash, an ordinary effect key (policy:<hash>), ordinary events and an ordinary
receipt, so §2, §3 and §4 apply with no second path to keep correct: an unverifiable approver is
refused, an unentitled one is refused, a proposer approving their own change is refused, and
M-of-N counts. A committed receipt for that action is the approval of that hash.The approval is per deployment, and that is not obvious. The hash folds in the effective
authority and the effective environment, so the same file instagingand inprodis two
hashes and needs two approvals — which is what an operator wants and what nothing else would say.
Comments, key order and whitespace do not move it.The name is reserved and declarable, and a first draft had that backwards. A document may
declarectrlrun.policy.changeunderctrlrun.policy/v6; nothing else may name it in a
resource:oreffect:template. Underrequire_approved_policythe policy in force must
declare it withdecision: approve— a policy that declares itallow, or omits it, decides
nothing, with the refusal naming the key. That is the rule that closes the obvious escape:
installing such a policy still needs an approval under the old one, and the moment it is
installed the deployment stops deciding anything.ctrlrun policy replay --file new.yaml --last Nreports which recorded decisions change
under a proposed policy. It writes nothing, executes nothing and reserves nothing, and it reports
what changes — never safer, riskier, too permissive, a score or a grade. A receipt whose action
cannot be rebuilt is named and skipped, never counted as unchanged.What it does not close, in full. An administrator with write access to the policy file can
still widen who may approve the next change. What they cannot manufacture is the approving
principal: the approver's credential is verified by the provider configured in code, and §4.1
refuses their own. So the property is exactly "a policy change that no verified principal other
than the proposer approved decides nothing", and not "a policy cannot be changed by whoever
holds the file". An approval also binds a hash and not an ordering, so any hash ever approved
stays approved and a superseded policy can be restored with nothing in the evidence saying so.ctrlrun verifygrades G21 with the flag set by verify, under a note rather than anN/A. -
A credential revoked before its
expis refused (docs/SPEC-v0.8.md§6).jwt_identity.py
used to say, in as many words, that a verified token is valid until itsexpand that nothing
polls. Both sentences are gone.from ctrlrun.revocation import FileRevocationFeed JWTIdentityProvider(..., revocations=FileRevocationFeed(path, issuers=[ISSUER]))
Security Event Tokens are consumed, from a file the operator's own transmitter writes or by RFC
8936 poll delivery. Nothing subscribes and nothing introspects: a subscription needs an
endpoint this project serves and an introspection call is a question it asks nobody. Consuming an
event is reading it.The match is against the token's own
iss,subandjti, never against
Principal.agent.agentis whateveragent_claimnames, which a deployment may set to
client_id, so matching aniss_subidentifier against it would compare two different things
and admit exactly the deployment the feature was bought for. The check runs inside the provider,
where the raw verified claims are still in hand, and nothing new is stored onPrincipal.Two things this closes less than it sounds, both stated wherever the feature is described. A
revoked credential leaves a log line and no receipt: resolution happens before an action
exists, so there is noaction_idto attribute a refusal to, where an expired credential
leaves a receipt. And a feed is worth what its source is worth: whoever can write the file can
refuse the operator's own agents, which is a denial of service against them and is fail-closed.
They cannot admit a principal the issuer revoked, because the feed is only ever consulted to
refuse. That asymmetry is the security property.max_stalenessis the operator's call. Unset means no bound, which is 0.7.0's availability.
Set, and every principal of a covered issuer is refused past it withrevocation_feed_stale,
because "has this been revoked" is exactly the question a stale feed cannot answer. Configuring
it makes the feed's availability part of the deployment's, and a kernel choosing that for an
operator would be choosing their outage budget.Behind
ctrlrun[identity], beside the provider it serves.import ctrlrunimports no part of
it.ctrlrun verifygrades G20 against a feed verify supplies, with a note saying so rather
than anN/Aclaiming something about a document that is silent on the subject.No standards claim. RFC 8935, RFC 8936, RFC 9493 and CAEP are consumed as code, and the words
compatible, conformant, aligned and certified appear nowhere. -
Break-glass is a grant, and there is no flag (
docs/SPEC-v0.8.md§5). An incident needs
authority nobody was granted in advance. The wrong answer is a setting: a setting leaves no
record, expires never, cannot be revoked and cannot be narrowed.authority.pyalready has
grants that are all five, so break-glass is a delegation beneath an envelope the policy
declared in advance.authority: break_glass: incident-payments: subject: {agent: "oncall-*"} # who a grant opened here may be FOR actions: ["payments.*"] constraints: {amount_lte: 50000} max_ttl: PT4H # the longest expiry a grant beneath it may carry controls: [incident-response] # whose approver_role gates who may OPEN it
There is no CLI command for it in 0.8.0. One was built and withdrawn before the release:
the CLI builds aControlthat wires no approver identity, and there is no configuration key
for one, soctrlrun break-glasscould not succeed in any configuration the CLI can load. It
failed closed, which is the right direction and not a reason to ship it — a command that cannot
work is a claim the CLI makes that the code does not honour. Opening an envelope in 0.8.0 is
reached from an application that built its ownControl; the shell surface returns in the
milestone that gives the CLI a way to verify an approver.docs/SPEC-v0.8.md§14.5 records the
two alternatives and why each was worse.The envelope decides nothing, by construction. It lives in
Authority.envelopes, a mapping
separate fromgrants, because the candidate set is every entry ofgrantsunconditionally: an
envelope living there would decide actions, which is the opposite of what it is for. The test
asserts it is absent from the candidate set rather than merely unmatched.It is covered by the policy hash,
max_ttlincluded. The argument for declaring the widest
authority an incident can reach in a file is that somebody reviewed it before the incident, and
that argument is only true if widening it moves every receipt.There is no
--as. Whoever opens one is the principal the deployment's approver identity
resolves, gated by the envelope'scontrols:; a deployment that names no approver identity
cannot open one at all. An assertion typed at a shell is exactly what break-glass must not
accept.What it is afterwards: recorded, with
created_via: break-glass; expiring, and bounded by
max_ttlfrom the moment it was opened; revocable, and revoking it stops everything beneath it;
attenuable, obeyingchild ⊆ parenton every dimension. A setting has none of those.Receipt.authority_grant_idnow names the grant that decided the action, for every
action decided by authority and not only under break-glass. A field exercised only on the rare
path is one nobody notices breaking.created_viagains its third value, which is a public change rather than an addition: the
vocabulary is a closed set and a record carrying an unknown value is unreadable, which answers
authority_unreadablefor every action in the deployment. It moves with every reader in one
commit.No guarantee id. The roadmap assigned five to v0.8 and G22 to G24 to v0.9, so inventing a sixth
would collide or renumber, and a renumber is the maintainer's change. Its evidence is its tests,
and one of them greps the shipped package for sixteen names a flag would be spelled as. -
M-of-N approvals (
docs/SPEC-v0.8.md§4). An action may require more than one yes, and what
the threshold counts is distinct verified principals: a second answer from a principal that
already answered is recorded, moves that entry'sgranted_at, and does not move the count.schema: ctrlrun.policy/v6 actions: payments.refund: decision: approve approvals_required: 2
The count is decided where the row is written, on all three stores, and never by a read
followed by a write: SQLite counts inside itsBEGIN IMMEDIATEtransaction, Postgres
compare-and-sets on the approver list it read and retries, and the in-memory store holds its
lock. Two processes answering at the same instant produce two approvers or one, never a
threshold reached twice.A yes that cannot be attributed does not count.
approvals_requiredabove 1 in a deployment
that names no approver identity is a denial, not a silent downgrade to one approval: the kernel
cannot tell two anonymous yeses apart, so it refuses rather than counting them.ctrlrun approve
records no verified approver and therefore never counts toward a threshold, which the CLI says
at the moment it is used rather than leaving to be discovered.ApprovalStore.grant_approvalnow returnsApproval | None, whereNonemeans recorded and
still short of N. Nothing is granted, noAPPROVAL_GRANTEDevent is written, and a consume
attempted below the threshold is refused aspendingwith nothing reserved.Needs
ctrlrun.policy/v6.ctrlrun verifygrades G19 underctrlrun.guarantees/v4,N/A
where every action in the document takes one approval. -
Entitlement from the control registry (
docs/SPEC-v0.8.md§3). A control may now name the
role that answers for it, and an approval whose recorded entitlement does not cover the roles
the request pinned is refused, with the control named in the message, the exception and the
APPROVAL_INVALIDATEDevent.schema: ctrlrun.policy/v6 controls: card-data-handling: title: Cardholder data changes are approved by a named owner approver_role: payments-owner
CTRLRun does not interpret the role. It does not know what
payments-ownermeans, does not
check that such a role exists anywhere, and makes no compliance claim on the strength of one,
exactly as it does not interpretsource:. What changed aboutSPEC-v0.6.md§7.3's
"attribution, not prevention" is one sentence: a control still decides no action, and now
decides who may answer an approval the decision already required.Omission is not entitlement, and a control naming no role gates nobody. Two sentences that
mean opposite things: a principal whose claims lack the role is not entitled, because a missing
claim is a statement about a person and the kernel refuses to invent one; a control with no
approver_rolegates nobody, because a missing role is a statement about the operator's
document and inventing one there would refuse every approval in every deployment that has
controls and has not heard of v0.8.Roles are matched byte for byte, in both claim shapes. No case folding, no trimming, no
prefix matching and no pattern grammar: a wildcard in a role would be an entitlement nobody
wrote. Where an evaluation cites several controls, every required role must be held, because
any-of lets the weakest control in the set decide who may answer.ClaimValuegains a tuple of strings, amendingSPEC-v0.3.md§2.1. A roles claim is a JSON
array at every issuer anybody deploys, and the old rule meant such a claim arrived absent, so
its holder was silently unentitled.JWTIdentityProvidercarries an array-of-strings claim now
instead of dropping it, and says so at WARNING rather than DEBUG when it drops anything else it
was asked to carry. Safe for hashes:v0.3 §2.2keeps claims out of an action's canonical form.The check is bounded and the bound is stated. What the kernel refuses is an approval whose
recorded entitlement does not cover the role; what entitled it was decided where the credential
was verified, which is the operator MCP server (ctrlrun mcp-operator --approver-roles-claim)
and an embedding application.docs/SPEC-mcp-operator.md§4.3 and §10 are amended to say that,
and §4.3 now carries a three-row table instead of one sentence, because the sentence covered
two unconfigured cases that behave in opposite ways: a control naming no role admits any
verified human, and a control naming a role in a deployment with no claim to read roles from
refuses everyone. The server warns about the second at startup rather than at the first
refusal.Needs
ctrlrun.policy/v6.ctrlrun verifygrades G17 underctrlrun.guarantees/v4,N/A
with a reason that is true of a document naming no approver role. -
The approver is a principal (
docs/SPEC-v0.8.md§2, §4.1).Approval.approveris a string
whose only check is that it is not empty, andadapter.pyhas always conceded what that string
often is: a channel, wherever the framework's primitive does not identify a person. A deployment
may now name anApproverIdentity, and where one is named an approval is consumable only if
the store holds aVerifiedApproverfor it: a principal the granting surface resolved,
recorded on the approval row, and carried onto the receipt.Opt in, then fail closed, which is
SPEC-v0.3.md§1.2's rule for authority applied to the
approver. AControlbuilt without one behaves exactly as 0.7.0 did, asserted field by field on
the whole approve-and-execute path. One built with it gets no partial mode: an approval whose row
carries no verified approver is refused withapprover_unverified, including one granted
before the provider was configured, including one granted through a surface that cannot
resolve, and including one held by a store that ignores the column.ctrlrun verifygrades seventeen guarantees now, G18 among them under
ctrlrun.guarantees/v4: an approval granted by the principal that requested the action is
refused, compared on the resolved principal and never on the string. Verify supplies the approver
identity it grades against, so what it reports is the kernel's refusal and never whether an
operator configured anything, which is a fact about their application and not about their
document.Which surfaces can produce a verified approver, stated plainly because it is narrower than the
feature's name suggests. The operator MCP server can, and now does: it has resolved a principal
for every request since it shipped and then discarded it intomcp-operator:<user>. An embedding
application can.ctrlrun approve, the webhook and the adapters cannot, and the approvals
they grant are refused wherever an approver identity is configured. A deployment whose approvals
arrive through one of those three turns its approval path off by configuring this, which is the
rule working rather than a defect, and §2.6's table is the thing to read before configuring.Observe mode now records a mismatch's own reason where it recorded one constant for all of
them.would_have.blocked_reasonsaidapproval_mismatchfor everyApprovalMismatch, so a
moved precondition and an approver who may not answer were one word in a report. Recording the
specific reason for the approver refusals alone would have left a vocabulary nobody can explain,
so every mismatch records its own. This reaches refusals that have nothing to do with v0.8, and
it is listed here rather than left for an operator to notice in a diff.Needs
ctrlrun.receipt/v5, which addsapproversandauthority_grant_id, and migration
0006_verified_approver. Every reader upgrades before any writer switches (SPEC-v0.3.md
§12.2). -
ctrlrun revoke --created-by PRINCIPALand--under ID(docs/SPEC-v0.8.md§7). During an
incident the operation an operator reaches for is everything this principal issued or
everything under this grant, and until now that was a script over the events file, written
under pressure. Both are queries over rows that already exist: no newStateStoremethod, no
bulk statement, and no transaction over the set. Each match is revoked exactly as one id is,
one at a time, so a run that stops halfway leaves the rows it reached revoked and the rest
untouched, and a second run finishes.--created-bytakesAGENTorAGENT/USER, splitting on
the first/asctrlrun delegate --asdoes;--underreaches the subtree at every depth and
is strictly beneath, so it leaves the id it names alone. A selector that matches nothing exits
non-zero and says what it searched for, because during an incident a mistyped name that exits 0
reads as a finished job. Still nounrevoke, in any costume.--byis unchanged and still means who performed the revocation. The roadmap called the new
selector--by <principal>, which is the opposite meaning on an option that already exists, so
the selector is--created-byand every script written against 0.7.0 keeps working.What a killed run can leave, stated because the tests bound it rather than assume it: a
revoked row whoseDELEGATION_REVOKEDevent was never written.Control.revokewrites the row
and then appends the event, with no transaction over the pair, so aSIGKILLbetween them leaves
one row unaccounted for in the log. That is 0.3 behaviour for a singlectrlrun revoketoo; a
selector only makes the window easy to land in.
Documentation
-
docs/SPEC-v0.8.md: the v0.8 "Oversight" contract, a delta over v0.1 to v0.7. No code lands
with it. It asks one question: who may say yes, and can the kernel tell? Seven milestones have
verified the principal that acts, and nothing has ever been asked of the principal that permits:
approveris a non-empty string,ctrlrun delegate --asis an assertion typed at a shell, and
the operator server authenticates who answered without checking that they were entitled to.
Seven items answer that: revocation by selector, the approver resolved as a principal,
entitlement from the control registry, M-of-N on distinct verified principals, break-glass as a
recorded expiring grant rather than a flag, credential revocation consumed from Shared Signals
and CAEP events, and a policy change as a protected action with a diff replay beside it. Tests
come from §10 (T272 onward); public names are frozen in §11; guarantees G17 to G21 join
ctrlrun.guarantees/v4, andctrlrun.receipt/v5andctrlrun.policy/v6each move once.The rule the whole document is built on is opt in, then fail closed, which is
SPEC-v0.3.md§1.2's rule for authority applied to the approver: a deployment that names no
approver identity behaves exactly as 0.7.0, and one that names one gets no partial mode, no
fallback to the string, and no setting that turns a check off.Reading the code changed nine things the plan had assumed, and §1.4 lists them: five from the
drafting and four from the independent review, which found the first draft unbuildable in four
places and is recorded rather than quietly fixed. Five matter beyond this document.Controlnever grants an approval, so the check that matters lives at the consumption and not at
the grant.Control._recheckreturns early on the default path, so a check added after it would
have been dead there, green, and invisible to a mutation table. APrincipalcould not carry a
list, and every issuer's roles claim is one, soClaimValuegains a tuple of strings. The
Postgres grant's compare-and-set was on a status that does not change at N-1, which is a lost
update and one principal filling two slots. And a reserved action name no document may declare
is a name every proposal is denied for, so the policy-change action is reserved and declarable.Smaller, and worth knowing before anyone scripts against it:
ctrlrun revoke --byalready means
who performed the revocation, so the new selector is--created-byand every script written
against 0.7.0 keeps working.What it does not close is in §1.1, before anything else: a persuaded approver gives a valid
approval and the receipt records it as one, and an administrator with write access to the policy
file, the store or the code is outside every guard here.