Approvers
For the person at your organisation who decides who may approve an AI agent's high-risk actions, and for the approvers themselves.
For the person at your organisation who decides who may approve an AI agent's high-risk actions, and for the approvers themselves.
Read 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 AI 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: an invitation asking for it still issues,
its page says AS2 cannot be reached here, and every registration against it is
refused by name (AssuranceBelowInvitation). 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)
Your administrator creates the invitation with invite-approver.sh, a command
in the same release as your ziffer CLI (install.md section 11 downloads and
verifies it), and the admin certificate we sign for this and for nothing else:
./invite-approver.sh \
--tenant <your-tenant> \
--role approver \
--name jane_o \
--assurance AS1 \
--addr <the ZIFFER_APPROVAL_ADDR row of your handover sheet> \
--cert tenant-admin.crt.pem \
--key tenant-admin.key.pem \
--cacert <ZIFFER_CA, our root> \
--san <the ZIFFER_APPROVAL_NAME row of your handover sheet>The admin certificate. Your administrator makes its key and a signing
request on their own machine, with the two openssl lines your handover sheet
prints, and sends us only the request. We sign it and hand back
tenant-admin.crt.pem. The key never reaches us. The certificate names your
organisation and can create invitations for it and nothing else: it cannot
approve, and it is not the certificate your CI publishes with.
The address and the name are two different things. --addr is where the
command connects: a public host name and port, reachable from your
administrator's machine over the internet. --san is who must answer: the
invitation service's certificate is checked against that name, never against
the address, so a wrong address fails the connection instead of reaching
somebody else. The service accepts only an admin certificate we signed for your
organisation, and the organisation an invitation is for is read from that
certificate, never from anything you type.
What an answer means.
403: the certificate is not an admin certificate for an organisation this installation serves (for example, it is your approval device's certificate, or your organisation's certificate has expired and been replaced).429namingInviteRateTenant: your organisation has created 20 invitations in the last minute. Wait a minute and run it again.InviteRateGlobalis the same limit across every organisation on the installation.no answer from the approval door: the connection or the certificate exchange failed before any answer arrived. The line above it iscurl's own reason. Check--addr,--sanand that--certand--keyare the pair we signed.
We do not create invitations for you, because an invitation we could create is an approver we could add.
It prints one link and the instant it dies. The invitation carries three things and nothing else:
-
The name (
--name) the approver will carry in your registry, your receipts and your audit records. Section 3 puts the same string in your registry. -
The role (
--role):approver(signs for authorisation) orconfirmer(gives the separate non-operator acknowledgement an irreversible action needs). One person may hold both. -
The level (
--assurance):AS1orAS2.AS0is refused: an approver at AS0 is a key file, and a key file is not a person. -
The link is single use and good for 24 hours from the moment it is created. It is spent only when an enrolment succeeds.
-
--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.
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)
You are sent a patch, and you apply it. The enrolment produces one registry entry, and turning that entry into a line of your signed policy takes three agreeing edits: the entry, under the identity the invitation used, and that person's assurance level in the map beside it. Getting any of the three wrong costs a day and shows up as an approval that never completes. So it is one command on our side and one on yours:
git apply approver-jane_o.patchReview it like any other change and merge it; your pipeline signs it under your key. The rest of this section is what that patch contains and why, because you are signing it.
We make it with:
approver-patch.sh --tenant <your-tenant> --assurance AS1 --name jane_o --registry <your-registry>It refuses, by name and before it writes anything, every mistake a by-hand
edit makes silently: a level of AS0, a credential or a public key already
in your registry under another name, a name you already hold with a different
entry, and a level below your own min_attester_assurance. We cannot apply it
for you. Anybody who could add an entry to your registry could approve their
own actions, so nobody can, including us.
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"
},
"sam.okonjo": {
"alg": "webauthn-es256",
"credential_id": "b2tvbmpvLWNy...",
"kind": "webauthn",
"public_key": "pQECAyYgASFYIG...",
"role": "approver",
"rp_id": "approve.example.com"
}
},
"assurance": {
"jane.okafor": "AS1",
"sam.okonjo": "AS1"
}
}Five 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.- Do not put a sandbox robot in here. A sandbox tenant's registry holds its
robot and nobody else; yours holds people and no robot. The reason is that
door_identities.json— one of the three files we hand you — names the ONE service that shows an approver what they are authorising and signs what they were shown, and a robot is shown decisions by a different service from the one a person uses. A registry holding both would be a bundle where one of them is told the quorum is complete and the action is refused anyway. We refuse such a bundle when you publish it. - 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 (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. publish it, which is your ordinary policy change: your workflow raises the
# epoch, signs and publishes on merge:
git commit -am "remove <name> from the approver registry" && git push
# ...or, outside CI, what that workflow does: raise bundle_epoch in
# policy/manifest.json above the integer `ziffer epoch` prints, then sign
# and publish:
ziffer epoch --url "$ZIFFER_PUBLISH_URL" --cert client.pem --key client.key \
--cacert ziffer-ca.pem --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ziffer sign policy/ --key <your key>
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.
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, a Slack channel, or a webhook into the ticketing or incident tool your operators already watch. None of them 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. The same holds one step further out — there is nothing in the webhook body that acts, and a receiver that put an "Approve" button beside the message would have replaced your authenticator with whoever can reach its inbox.
If you take these on a webhook
The body is a JSON object with exactly six members and no others:
{
"tenant": "your-tenant",
"kind": "approval-request",
"proposal_hash": "sha256:…",
"summary": "what the agent asked to do, in the door's own words",
"link": "https://act.<your operator's origin>/a/sha256%3A…",
"expires_at": "2026-09-21T09:30:00Z"
}kind is one of approval-request, hold, notice, critical-alert or
expired-unanswered. link and expires_at are null for the kinds that carry
none — the set is always the same six, so your validator does not have to guess.
Two headers tell you the message is ours and has not been edited:
X-Ziffer-Timestamp: 1700000000
X-Ziffer-Signature: v1=<hex hmac-sha256(secret, timestamp + "." + raw body)>The secret is one your operator generated and placed on both sides; nobody at Ziffer has it. Verify it over the raw body bytes, before you parse them — a receiver that re-serialises the JSON and signs that agrees with us only by luck — and compare in constant time. The timestamp is inside the preimage, so rejecting an old one is rejecting something the signature covers.
Then: check the member set is exactly those six, treat summary as text and
never as markup (it describes something an AI agent asked for, and rendering it as
markup in a ticket is how a link nobody wrote appears in it), and answer 2xx.
We follow no redirect and we do not retry.
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 AI 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: asking for the key first means the only person who can ask about a name is somebody already holding the key enrolled under it, so the page is never an index of who approves at your organisation.
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.
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. Good to know
- We do not send the invitation. Section 2 says why.
- A stop reaches the component that acts on it. 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.
- What an approval establishes. A signature proves that the enrolled
authenticator was present and unlocked for this exact action. For actions
where that is not enough, your policy adds more: several distinct people
(
quorum_k), and for an irreversible action a hold in which it is confirmed or stopped (section 4). - The receipt carries the 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. The authenticator's own assertion is a classical signature, as every authenticator's is.