| G1 mutated approval refused | An approval is bound to one action_hash; presenting it for any other action is refused, and the approval is not consumed. | ASI09:2026, ASI01:2026 (partly), ASI06:2026 (partly) | The approval a human granted is bound to the exact canonical form of the action they were shown, so an action that changed after the approval — by a hijacked goal or by anything else — has no approval to present. |
| G2 replayed approval refused | An approval is single-use; the second presentation is refused and does not execute. | ASI09:2026 | The approval record is consumed in the same transaction that admits it, so one human decision authorizes exactly one execution and a loop cannot spend it twice. |
| G3 duplicate effect refused | A second attempt on an effect key whose record is COMMITTED is refused, and the remote is not called. | ASI08:2026, ASI02:2026 | Effects are identified by a key derived from the action’s own arguments, and a committed key is refused rather than retried — so a retry loop cannot turn one intended effect into several. |
| G4 one winner under concurrency | Reservation is atomic across processes, not merely across threads. | ASI08:2026 | The reservation is taken inside a BEGIN IMMEDIATE against a unique constraint on the effect key, so two agents that picked up the same task produce one effect and one refusal. |
| G5 ambiguous blocks a blind retry | An executor that raises anything other than NotExecuted leaves the effect AMBIGUOUS, and the retry is refused rather than executed. | ASI08:2026 | A lost response is recorded as an unknown outcome rather than a failure, and an unknown outcome is a state only a human or a reconciliation hook may leave — so the failure does not cascade into a second execution of something that may already have happened. |
| G6 unknown action refused | Unknown action → DENY. There is no default-allow. | ASI02:2026, ASI01:2026 (partly), ASI06:2026 (partly) | The policy is the list of what an agent may do; anything not written in it is refused, so a tool an agent was talked into reaching for is refused whether or not the reasoning that reached for it was sound. |
| G7 no principal refused | An action proposed with no principal is refused, and no receipt and no events are written. | ASI03:2026 | Every action carries a principal or it does not run, so there is no path on which an action executes with nobody attributable to it. |
| G8 expired authority refused | A grant is authority only until its expires_at; after that the action it covered is denied, by name. | ASI03:2026, ASI10:2026 | Authority is evaluated on every action against the clock, not at the start of a session, so an agent still running after its grant lapsed is denied on its next proposal. |
| G9 delegation cannot escalate | A delegated grant is valid only if it is provably a subset of its parent on every dimension — and a child that drops a dimension its parent constrains is rejected rather than treated as unconstrained. | ASI03:2026, ASI10:2026 | Containment is checked at creation and again on every evaluation by walking the chain to its root, and omission is never inheritance — so an agent handed authority cannot mint itself more of it, and a revocation anywhere in the chain cuts everything beneath it. |
| G10 unknown exception is ambiguous | NotExecuted is the only outcome that means “the remote did nothing”. Everything else, timeouts included, is AMBIGUOUS. | ASI08:2026 | The mapping from an executor’s exception to an outcome is asymmetric on purpose: a timeout is not a failure, so a framework’s retry-on-error cannot be the thing that decides whether money moved twice. |
| G11 an altered receipt is detected | Each receipt carries the hash of the one before it. Altering, deleting or reordering one breaks the chain, and the break is reported by name — content_altered, hash_missing, link_broken, missing, head_mismatch, unchained — and by seq. | ASI09:2026 (partly) | The evidence an operator reads after an incident is the thing an attacker who got that far has the most reason to edit. This does not stop them: it makes changing what a receipt says, while keeping the receipts after it, cost a rewrite of all of them plus the head, rather than one statement. What it does not close is the end of the log — erasing a suffix, or appending to it, each cost two statements and are undetected, because the head is a row in the same database and not an external anchor. v0.6 has no anchor and claims none. It is not a signature and says nothing about who wrote the log; somebody who can rewrite every row including the head recomputes the chain and it verifies, and THREAT_MODEL.md still lists a malicious administrator as out of scope. |
| G12 a byte written is ambiguous | ctrlrun.transport classifies a transport failure from evidence rather than from an exception type: it raises NotExecuted only where a connection it opened was handed no request byte, in an executor run that had offered none. After one byte, a reset, a read timeout, a reused connection or a second connection in the same run is the original exception, and the effect is AMBIGUOUS. | ASI08:2026, ASI09:2026 (partly) | FAILED versus AMBIGUOUS is the one decision this library exists to get right, and the kernel does not make it: an executor does. Until v0.7 the correct rule lived only behind ctrlrun[gateway], so the surface most people use had a docstring and no implementation, and the obvious hand-written classifier maps ConnectionResetError to “nothing happened” after the whole request reached the remote. That is a licence to act twice, written by a well-meaning integrator. What this closes is the absence of a correct rule in core. What it does not close: the register sees only this library’s own sends, so an executor that sends part of the effect through another transport and then uses the classifier can be handed a claim that is true of these connections and false of the effect. No parameter, attribute or environment variable widens what counts as FAILED. |
| G13 clock divergence is named | The kernel measures how far this host’s clock disagrees with the store’s, on a store that has a clock of its own, and reports the divergence as CLOCK_SKEW_DETECTED when it passes the configured threshold. It changes no decision: every lease is still compared against the application clock exactly as before. | ASI08:2026 (partly) | Once the store is shared, each host brings its own clock, and the failure is fail-closed and therefore quiet. A host running ahead sees a live lease as expired and marks AMBIGUOUS a record whose real holder is mid-flight and about to succeed; a host running behind refuses for longer than it should. Neither says why. What this closes is the silence, not the skew: an operator reading a receipt learns that two clocks disagreed and by how much, so the response to the first failure is not itself the second one. It does not synchronize anything, and a skew below the threshold is not reported. |
| G14 token changes across a renewal | ctrlrun.idempotency_token() answers inside an executor with a token derived from (effect_key, attempt): stable within one attempt, including across a resume, and different after a renewal. Send it to a provider as its idempotency key. | ASI08:2026 (partly) | A provider handed the effect key alone would answer the one retry the kernel permits, permitted because the executor proved nothing happened, with the cached failure of the attempt that failed. A token that moves with the attempt keeps a provider’s cache from becoming a second source of stale outcomes. What it is for is reconciliation, a deterministic handle to ask a provider what became of an attempt whose outcome is unknown; it does not make a retry safe, and after AMBIGUOUS the kernel still refuses one. It is unique only as far as the operator’s effect keys are, and nothing here checks two stores sharing a provider account. |
| G15 renewal past the ceiling refused | An action entry may declare max_attempts; above it the executor is not called, the record is released as FAILED, a blocked receipt names the ceiling, and ActionDenied(reason="attempt_ceiling") is raised. The refused attempt number is spent. | ASI08:2026 (partly), ASI10:2026 (partly) | Without a ceiling a renewal after FAILED is unbounded, so an agent that keeps proposing an action that keeps failing keeps dispatching, and one human yes on an APPROVE action bought unlimited dispatches. The ceiling is the operator’s, not the kernel’s: an entry that declares none renews exactly as before, and the decision is taken on the attempt number the store assigned, after the reservation, so two callers cannot both pass a read taken before reserving. It bounds attempts on one effect key, not what an agent does across many. |
| G16 a moved fingerprint is refused | Under APPROVE, a preconditions= provider’s answer is kept as a sha256: fingerprint on the request; on the presenting pass it is computed again, strictly before the store call that consumes the approval, and a difference is refused with ApprovalMismatch(reason="precondition_changed"), reserving nothing and leaving the approval granted. | ASI09:2026 (partly), ASI01:2026 (partly) | A human’s yes was given against the world as it was; this refuses the action where the state the approval depended on has moved since. It narrows the window between approval and execution and does not close it: the comparison is a network call outside the atomic reservation write, and a change that lands between the comparison and the reservation is not refused. What the provider looks at is in the operator’s code, which verify does not read; verify grades the check with a provider of its own. |
| G17 an unentitled approver refused | A control in the registry may name the role that answers for it. An approval whose recorded entitlement does not cover the roles the request pinned is refused with ApprovalMismatch(reason="approver_unentitled"), naming the control and the role in the message, the exception and the APPROVAL_INVALIDATED event. Roles are matched byte for byte, and every cited control must be satisfied. | ASI03:2026 (partly), ASI09:2026 (partly) | Before this, any principal whose credential the provider verified could answer any request: a deployment could say who may act and not who may permit. What it closes is narrower than it sounds and is stated as such. The kernel refuses 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 or an embedding application. CTRLRun does not interpret the role, does not check that it exists anywhere, and makes no compliance claim on the strength of one. A control naming no role gates nobody, and a principal whose claims lack the role is not entitled: two omissions that mean opposite things. |
| G18 the requester cannot approve | The principal that requested an action cannot be the principal that approves it, compared on the resolved principal and never on the approver string, so two grants whose strings differ are still one principal. Refused with ApprovalMismatch(reason="approver_is_requester"), leaving the approval granted and reserving nothing. | ASI03:2026 (partly), ASI09:2026 (partly) | An agent that could approve its own action has an approval gate in name only. The comparison is on the principal an identity provider verified, which is what makes it real: approver was a free string until v0.8, and a check against it would have been defeated by typing a different one. It does not establish that the approver understood what they approved: a persuaded human gives a valid approval and the receipt records it as one, and no page here says otherwise. |
| G19 one principal counts once | An action entry may require several approvals. The threshold counts distinct verified principals: a second yes from a principal that already answered is recorded, moves that entry’s timestamp, and does not move the count. The count is decided inside the store’s own write on all three backends, never by a read followed by a write. | ASI09:2026 (partly), ASI03:2026 (partly) | M-of-N is worth nothing if one person can be N of it. What this closes is a miscount: two processes answering at the same instant produce two approvers or one, never a threshold reached twice, and the test that proves it opens the window between the count’s read and its write rather than starting two processes and hoping. A threshold above one in a deployment that verifies nobody is a denial, not a silent downgrade to one approval. It does not make several humans independent of each other, and it does not know whether they discussed it. |
| G20 revoked before its exp: no | Where a deployment configures a revocation feed, Security Event Tokens are consumed and a credential the issuer revoked is refused at resolution, before its exp, as an IdentityError. The match is against the token’s own iss, sub and jti and never against the principal’s agent name. | ASI03:2026 (partly), ASI10:2026 (partly) | Until v0.8 a verified token was valid until it expired, so a compromised credential stayed good for the rest of its lifetime and short lifetimes were the whole of the answer. Two things this closes less than it sounds, and both are stated wherever the feature is described. A revoked credential leaves a log line and no receipt: resolution happens before an action exists, where an expired credential leaves a full receipt. And a feed is worth what its source is worth: whoever can write it can refuse the operator’s own agents, which is a denial of service against them and is fail-closed. What they cannot do is admit a principal the issuer revoked, because the feed is only ever consulted to refuse. |
| G21 unapproved policy decides no | Where a deployment requires it, a decision is made only where a committed effect records that this exact policy hash was approved, and the policy in force must send its own change to a human. Otherwise every action is denied policy_unapproved, with an ACTION_DENIED event and a DENIED receipt. A policy change is an ordinary action, so the approval path applies to it in full. | ASI03:2026 (partly), ASI01:2026 (partly), ASI10:2026 (partly) | The policy is the one file that decides every other decision, and until v0.8 it was changed by editing it. The property is exactly a policy change that no verified principal other than the proposer approved decides nothing — not “a policy cannot be changed by whoever holds the file”. An administrator with write access can still widen who may approve the next change; what they cannot manufacture is the approving principal, whose credential is verified by the provider configured in code. An approval 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. |
| G22 held budget refuses next reserve | A grant may carry a consequence budget: a metric, a limit and a rolling window. The sum is consumed when the effect is reserved, in the same transaction, and an action that would take it past the limit is refused budget_exhausted with an ACTION_DENIED event and a DENIED receipt. Every grant in the delegation chain is charged, so a child cannot spend its parent’s budget over again. | ASI08:2026 (partly), ASI01:2026 (partly), ASI10:2026 (partly) | Until v0.9 the authority model bounded one action and never an aggregate: amount_lte: 5000 says nothing about the thousand actions that each pass it, so an agent acting entirely within its permissions could empty an account one permitted refund at a time. Three limits stated wherever the feature is described. A budget cannot recall an action already in flight: the window rolls forward and a reservation taken a second before it rolls is committed regardless. An AMBIGUOUS effect holds its charge until a human resolves it, which is deliberate and which means an agent that can manufacture ambiguity can exhaust a budget it cannot spend, a denial of service against its own principal that is fail-closed. And a budget counts a metric an operator named — an action argument — not a consequence: nothing here ranks, scores or classifies what an action means. |
| G23 a failing scope provider refuses | Where a deployment configures one, a scope provider answers is this record this principal’s? and the kernel matches the answer against the action’s resource, strictly before the reservation. Two distinct refusals, never conflated: scope_unavailable when the provider raises, answers the wrong shape, or answers something the canonicalizer refuses; out_of_scope when it answered and the record is not covered. Only the hash of the answer reaches the receipt. | ASI06:2026 (partly), ASI01:2026 (partly), ASI02:2026 (partly) | This is the bite on an identifier an attacker chose, which the ASI06 row below said nothing had: a grant permits records.read on customer:*, and until v0.9 nothing had an opinion about whose record customer:90210 is. Two limits. A scope provider is worth what its source is worth: it is the operator’s own code answering from the operator’s own system of record, and a poisoned source answers wrongly with the kernel none the wiser. And whether a deployment configures one is a fact about its code, which ctrlrun verify cannot read: verify grades the mechanism against a provider it supplies. The gateway and the ACS hook cannot name a provider at all. |
| G24 grant refused off its task | A grant may name tasks:, the unit of work the authority is for, attenuated by the same child ⊆ parent rule as actions, resources and environments. An action bound to a task the grant does not cover is refused by reason and not by exception type. A grant that names no tasks: authorises any task, so every grant written before v0.9 keeps its meaning. | ASI01:2026 (partly), ASI03:2026 (partly), ASI10:2026 (partly) | Task binding limits blast radius; it does not detect a hijack. The task id is supplied by the caller, so an agent that has been talked into a different goal will often still be operating inside the task it was legitimately given, and the kernel cannot tell a hijacked run from an honest one. What it stops is a credential or a delegation issued for one unit of work being spent on another, which is the shape a stolen grant most often takes. The task never enters the action hash, so binding one changes no existing receipt. |