The asymmetric kill switch
Something is wrong. An agent is calling a tool it has never called, a detector is
firing, a customer is on the phone. The question is not “who else should agree
that we stop?” — it is how fast can this stop.
So: one admin freezes delegation, immediately, alone. No approval, no second
signature, no waiting for a colleague at three in the morning.
Restarting is the opposite operation. It is exactly the moment when an attacker —
or an operator who mostly wants the alarm to go away — has an interest in being the
only decision-maker. That is the side that gets the friction.
freeze → 1 admin, iam:delegations.manage
lift → N distinct admins, iam:delegations.unfreeze
Two independent axes, not one. The quorum is the obvious half; the separate
permission is the half people forget. iam:delegations.unfreeze is granted to
fewer people than iam:delegations.manage, so “who can stop the fleet” and “who
can restart it” are different lists even before anyone counts signatures.
Freezing
POST /api/iam/v1/delegation-freezes
{ "scope": "global", "reason": "Anomalous tool calls from agt_01J…" }
reason is required. A kill switch with no reason is a kill switch nobody knows
when to remove — three days later someone finds a frozen fleet and has to guess.
Three scopes, because in an incident the real question is how much to switch off:
| scope | stops |
|---|---|
global |
all delegation, everywhere |
organization + scope_id |
every agent of one organization |
agent + scope_id |
one agent |
Pressing the button twice during an incident is a normal reflex, so a second
freeze of the same scope returns the first one rather than creating a second
thing to unlock.
What a freeze actually stops
| Token exchange | refused — no delegated token is issued |
Delegated decisions (checkDelegated) |
denied — tokens already in circulation stop deciding |
| JIT scope elevation | refused — a frozen fleet does not widen its own authority |
| Revoking a grant, suspending an agent | allowed, always |
The second row is what makes this a kill switch rather than a pause on new
issuance. Delegated tokens live at most five minutes, but “at most five minutes of
a frozen fleet still acting” is not what anyone means by stop.
The last row is a rule, not an oversight: if freezing also blocked revocation, the
kill switch would block the incident response that caused it.
Lifting
POST /api/iam/v1/delegation-freezes/frz_01J…/approve-lift
{ "note": "Root cause found and patched, see INC-412" }
Each call records one approval and answers with how many are still missing:
{ "lifted": false, "approvals": 1, "required_quorum": 2, "remaining_approvals": 1 }
When the last one lands, the freeze lifts inside the same transaction and
delegation resumes.
Three decisions worth knowing
The quorum is photographed at freeze time. required_quorum is written onto
the freeze row when it is created and never re-read from config afterwards. If it
were re-read at lift time, anyone who can edit configuration would set it to 1
and unfreeze alone — and a control that the person you are defending against can
turn off is not a control.
Whoever froze may approve, like anyone else. Excluding them adds no security:
the attacker who wants to lift a freeze is not the person who set it. It only
removes a signature from the one person actually handling the incident. Two-person
control comes from lift_quorum >= 2, not from exclusion. What is enforced — at
the schema level, with a unique constraint, not just in code — is that the
approvals come from distinct identities.
The check is not cached. A kill switch that takes thirty seconds to kill is not
a kill switch. It costs one indexed lookup on a tiny table per delegated decision,
which is cheaper than the two PDP checks it precedes.
Configuration
'kill_switch' => [
'lift_quorum' => env('IAM_AGENTS_FREEZE_LIFT_QUORUM', 2),
],
1 is allowed and means “no quorum” — the permission asymmetry still applies. It
is not the default, because the default should be the control and not the
convenience. Do not set a quorum larger than the number of people who actually
hold iam:delegations.unfreeze, or the fleet cannot be restarted at all.
When the state cannot be read
If the freeze table exists but cannot be queried, every delegated action is denied
with freeze_state_unavailable. For a kill switch, “I could not check” is not
“it is fine”.
The one case treated differently is a table that does not exist yet — the
minutes between deploying this version and running php artisan migrate. That is
“not installed”, not “unreadable”, and it does not deny anything. Run the migration.
Audit
Everything lands in the delegation stream, on the server’s tamper-evident hash
chain:
| event | risk |
|---|---|
iam.delegation.freeze.applied |
high |
iam.delegation.freeze.lift_approved |
medium — recorded for every approval, including duplicates |
iam.delegation.freeze.lifted |
high |
Every approval is audited, not only the one that completed the quorum: “who wanted
the agents running again” is a question you answer with the full list, not with
the name of whoever happened to sign last.
Refused exchanges carry delegation_frozen: frz_… (global) as their refusal
reason. The client never learns why — a refusal must not explain to an agent that
it has been stopped.