Approvers
For the person at your organisation who decides who may approve an agent's high-risk actions, and for the approvers themselves.
For the person at your organisation who decides who may approve an agent's high-risk actions, and for the approvers themselves.
Read docs/onboarding/policy-ci.md first if you have not published a policy
yet. Nothing on this page takes effect until you do.
0. The one sentence to take away
Enrolling a key does not make anyone an approver. What an enrolment produces is one line of policy. Until you sign that line into your own policy and publish it, an approval made with that key resolves to no enrolled identity and counts toward nothing at all. That is not a limitation of the enrolment page; it is the design. The people who decide who may approve are you and your reviewers, in a signed document, and no service of ours can add somebody to it.
1. What an approver is
An approver is a person holding an authenticator — a passkey on their phone or laptop, or a hardware security key. When one of your agents proposes an action your policy grades as high risk, the action is held and the people your policy names are asked to approve it. What each of them does is unlock their authenticator; what the engine verifies is a signature that only that authenticator could have made, over the exact request they were shown.
Two things follow from that, and both matter more than they look:
- The signature is over the request, not over a session. A link, a login, a chat button or a reply-to-approve email proves that somebody had access to a mailbox. This proves that a specific enrolled authenticator was present and unlocked for this specific action.
- The page is bound to one origin. An authenticator will not produce a signature for a site it was not enrolled on. A page served anywhere else cannot get one out of it, however convincing the page is.
The assurance ladder
Your policy can require a minimum level, and every approval is checked against it.
| Level | What it means | Typical |
|---|---|---|
| AS0 | a software key file | not a person. Refused for an approver. |
| AS1 | an authenticator that verified the user | a passkey on a phone or laptop, unlocked by biometric or PIN |
| AS2 | a hardware-bound authenticator, proven | a security key whose model your operator has placed metadata for |
The level is read off the authenticator during registration, never claimed. Neither the person, nor their browser, nor our enrolment service can say what level a key is; it is derived from what the device proves about itself.
AS2 needs placed metadata. Telling a hardware key apart from a phone requires the authenticator's attestation chain to verify against a metadata file your operator has placed on the approval installation. Where no metadata is placed, AS2 cannot be reached at all and an invitation asking for it is refused by name. That is the safe direction: a level that defaulted upward would be a phone satisfying a floor nobody vouched for.
2. Inviting somebody (you, once per approver)
deploy/bin/invite-approver.sh \
--tenant <your-tenant> \
--role approver \
--name jane_o \
--assurance AS1 \
--addr <approval-host>:<port> \
--cert <your-admin-leaf>.crt.pem \
--key <your-admin-leaf>.key.pem \
--cacert <ca>.crt.pem \
--san <approval-door-name>It prints one link and the instant it dies.
- The link is single use and good for 24 hours.
--roleisapprover(signs for authorisation) orconfirmer(gives the separate non-operator acknowledgement an irreversible action needs). One person may hold both.--tenantpicks which credential to present. It is not sent: the approval door reads your organisation out of your own certificate, so you can invite approvers for your organisation and for nobody else.--assuranceisAS1orAS2.AS0is refused: an approver at AS0 is a key file, and a key file is not a person.
You send the link yourself. This deployment has no invitation channel, on purpose — every outbound message belongs to the notification service, and giving the approval door its own would undo the separation that makes a display lie expensive. Send it however you already send things that matter.
What the person does
Thirty seconds:
- They open the link on the device holding the authenticator they want to use.
- They press Register this device and unlock it.
- The page shows the line of policy their key produced, and offers it as a download.
If their authenticator does not reach the level you asked for, the page says so by name and the link still works — they can try again with the right device. Nothing was enrolled.
3. Signing them into your policy (you, once per change)
The enrolment produces one entry, like this:
{
"alg": "webauthn-es256",
"credential_id": "TFm0V4o2h1...",
"kind": "webauthn",
"public_key": "pQECAyYgASFYIH...",
"role": "approver",
"rp_id": "approve.example.com"
}Put it into attesters/registry.json under the same name you passed to
--name, and record their level in the assurance map beside it. The two
have to be one string: when that person approves from a browser, the approval
door composes an attestation naming the identity it was given at the
invitation, and an identity your registry does not hold is an approval the
Policy Engine refuses as an attester nobody enrolled. It is the name that
appears in receipts and audit records, so choose it here and use it in both
places:
{
"schema_version": "1",
"quorum_k": 2,
"min_attester_assurance": "AS1",
"attesters": {
"jane.okafor": {
"alg": "webauthn-es256",
"credential_id": "TFm0V4o2h1...",
"kind": "webauthn",
"public_key": "pQECAyYgASFYIH...",
"role": "approver",
"rp_id": "approve.example.com"
},
"robot-approver": {
"kind": "hybrid",
"role": "approver",
"classical": "3jXbQ...",
"pq": "kO2r..."
}
},
"assurance": {
"jane.okafor": "AS1",
"robot-approver": "AS0"
}
}Four things worth knowing about that document:
kindis required on every entry, including the machine ones. An entry without it is refused. A key kind guessed from which fields happen to be present is exactly the guess a tagged field exists to prevent.- A
webauthnentry with no recorded level, or one recorded at AS0, is an invalid policy. Record the level the enrolment page showed you. - No two entries may carry the same key or the same credential. Two names on one credential is one person satisfying a two-person quorum alone.
quorum_kis how many DISTINCT people must approve. Whoever proposed the action is never one of them, whatever the registry says.
Then publish it through your own CI exactly as you publish any other policy
change (docs/onboarding/policy-ci.md). The change takes effect when your
publish workflow prints active — every process that reads policy is then
serving the new bundle. That is seconds, not an operator's working hours.
k of n, and what happens when somebody is away
quorum_k is a THRESHOLD, not a rota. Any k of the n people in
attesters satisfies it, in any combination, with no order and no nomination. A
registry with quorum_k: 2 and four approvers needs any two of the four.
There is no delegation and no out-of-office, and there is not going to be
one. Delegation means a credential that approves on somebody else's behalf,
which is the one thing an approval is supposed to prove did not happen. What
covers a holiday is arithmetic: enrol at least k + 1 approvers per role, so
one person being away still leaves a quorum. The onboarding checklist asks for
that in item 1.4, and it is the only answer we have.
If everyone who can approve is genuinely away, the action is not approved. It waits, and when the attestation window closes the request expires: the approvers and your notice target are told, and the action is refused rather than executed. Nothing is silently dropped and nothing runs unapproved.
When an approver leaves
Two commands, and the second is the one that matters. Their enrolment file, the browser credential on their laptop, and any link they were sent are all inert the moment your signed registry stops naming them — the registry is the authority, not the device:
# 1. remove their entry AND their assurance line from attesters/registry.json.
# Both, in one edit: an assurance line for a name that is not an attester is
# a leftover, and an attester with no assurance line is an invalid policy.
# 2. re-sign and publish it, which is your ordinary policy change:
ziffer sign policy/ --key <your key>
git commit -am "remove <name> from the approver registry" && git push
# ...or, outside CI, the same publish your workflow makes:
ziffer publish policy/ --pubkey policy-signing.pub.json \
--now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--url "$ZIFFER_PUBLISH_URL" --cert client.pem --key client.key \
--cacert ziffer-ca.pemThree consequences worth stating before somebody asks:
- Check
quorum_kagainst the people who are left. Removing the fourth of four withquorum_k: 2is fine; removing the second of two is a policy that cannot form a quorum, and every floor-HIGH action waits for an approval that can never arrive. The publisher does not refuse it for you —quorum_kabove the number of enrolled approvers is a rule you are allowed to write. - Their pending approvals do not transfer. Anything already waiting on them
waits out its window and then expires, with the approvers and your notice
target told. Re-propose it after the new registry is
active. - Nothing is revoked at their device, because nothing was ever granted to it. A passkey that no signed registry names produces an attestation nobody will count.
4. What an approver sees
Twice, and they are two different messages about two different moments. This is worth reading before you answer a question about it, because until 2026-09-08 the product sent only the second one and called it the first.
Both links open locked
Neither page shows anything about the action until the person has used their key. The link opens a page that says only "prove who you are": not what the action does, not what it acts on, not when its window closes, not how many approvals are needed. One tap of the enrolled authenticator opens it; anybody else — a forwarded link, a mailbox somebody else reads, the wrong organisation's approver — gets that same locked page back and nothing else.
That first tap is not an approval and cannot be turned into one. It signs different bytes on purpose, the control plane refuses it if it is offered as an approval, and it is accepted once: opening the page again asks for the key again. Tell your approvers to expect two taps, because they will ask.
Both arrive on whichever channel your operator placed for you — email, or a Slack channel. Neither changes what approving is: the message carries the request and a link, and the signature is always made on the page that link opens, by the enrolled authenticator. There is no approve button in Slack, deliberately: a chat button would prove somebody had a Slack account, which is the thing section 1 says is not an approval.
First: "Approval needed"
Sent when an action needs a quorum and does not have one yet. It goes to the people in your signed registry who hold a passkey, and to nobody else — a robot approver polls the approval service and has no inbox.
Its link opens a page with:
- what they are authorising, in the approval service's own words, over the exact request that was signed — never a re-rendering of the agent's proposal;
- when the request expires, after which the page offers nothing;
- how many approvals are required, when more than one is;
- Approve.
There is no Deny on this page, and the absence is deliberate. At this moment declining is simply not signing: the request runs out its window and the action never happens. A button would record an act with no consequence and let somebody leave believing they had stopped something.
Second: "Held for release"
Sent once the quorum has been collected and the action is being HELD before it runs. It goes to everybody who signed for it and to the person who proposed it. Its link opens a different page, offering:
- Confirm — the action runs as soon as the hold window allows;
- Stop this — the action does not run, and a fresh proposal and a fresh quorum are needed to try again.
Doing nothing is not neutral. An irreversible action that nobody confirms is not carried out.
The page asks for the name they are enrolled under before either button works. That is not an identity check — anybody can type a name — it is because what a person signs here is the acknowledgement itself, and the acknowledgement has to say who is making it before it can be signed. A name that is not theirs produces a signature their key does not verify, and the answer they get back is the refusal, in the words the control plane used.
On this page the key is asked for twice, and that is one more tap than the approval page. Once to show the door that the name typed above is the one that key was enrolled under, and once to sign the answer itself. Neither signature can stand in for the other. The reason is worth telling an approver who asks: the step that composes an acknowledgement used to answer for any name typed by anybody holding the link, which made the page an index of who approves at your organisation — send a name, learn whether there is somebody by that name here. It asks for a key first now, so the only person who can ask about a name is somebody already holding the key enrolled under it.
Both pages tell them plainly that the link on its own does nothing. It does, and you should repeat it: a forwarded link is not an approval and cannot become one without the enrolled authenticator.
"What you have approved"
Both pages carry a line at the foot linking to a third one: everything the approval service recorded for that key, over the last 90 days, newest first, at most 200 entries. It opens locked like the other two, and one tap of the same key opens it.
It lists three things per entry: when, the one line describing the action that
was on the page they approved, and the control plane's own word for what
happened: ALLOW when their approval completed the quorum, ATTEST when it
counted and more were still needed. An approval that was refused puts no
entry there, deliberately: an entry saying otherwise would tell somebody they
approved something that never counted.
Nobody sees anybody else's. The list is filtered to the credential that just proved itself, so one approver cannot learn what another has been approving.
It is not the audit record, and the page says so. It is the approval service's own note of what it relayed, kept for the person who tapped the key. The record is the audit chain your control plane writes and anchors, which is written somewhere else by something else. A line can be missing from this list (the write failed, the process restarted mid-request, the installation replaced its volume) and its absence proves nothing at all. When an approver needs to know what actually happened rather than what they remember doing, the answer comes from the audit record, not from this page.
Two limits to know before somebody asks:
- It starts the day you deploy it. Approvals made before this page existed are not in it; nothing backfills them from the audit chain.
- It lives on the approval installation. It is not replicated and it is not backed up by us. If you rebuild that installation from scratch, your approvers' lists start empty, and nothing about what they approved is lost, because that is in the audit chain.
5. Lost, stolen or replaced device
Treat it as a policy change, and do both halves in the same one:
- Invite the person again (section 2) and have them enrol the new device.
- In one change to
attesters/registry.json: remove the old entry and add the new one, with its level. Publish it.
Removing the old entry is the half that matters. Until it is gone and the new bundle is activated everywhere, the old credential is still an enrolled approver — and if the device was stolen rather than lost, somebody else has it.
Do not leave the old entry in place "in case". An approver with two enrolled credentials is one person who can be counted twice by a registry that cannot tell them apart.
6. What this does not do
Stated here rather than discovered later.
- We do not send the invitation. Section 2 says why.
- A stop travels now, and it did not before. Until 2026-09-08 a refusal was signed and written down on the approval installation and went no further, because the component that acts on it admitted only the notification service. It reaches that component directly now. If your installation was placed without an address for it, the page says so in those words and nothing is recorded — tell your operator, and the action still runs its window out unanswered, which for an irreversible action is a refusal.
- Authentication is not comprehension. A signature proves an authenticator was present and unlocked. It does not prove the person read the screen or understood it. Nothing here should be read as claiming otherwise.
- A human approval is classical. No authenticator in existence produces a post-quantum signature. What binds a person's decision against a future adversary is the receipt and the anchor made at the moment they approved, not the assertion itself. If your risk statement needs a post-quantum human signature, you do not have one, from us or from anybody.
Synced from ziffer docs/onboarding/approvers.md at 32bc4db; edit the source, never this page.