Approval gate
An approval gate is a policy layer that turns an agent's send into a pending request a named human must decide, returning a held result instead of a message ID until somebody approves the exact content reviewed.
A review step is a promise: what the reviewer read is what leaves the building. An approval gate is the machinery that keeps the promise, and most implementations labelled “human in the loop” keep a weaker version of it. They show a preview, record a click, and then send whatever the draft holds when the job runs, which is not necessarily what was on the screen.
Where the gate sits
Two independent things decide whether a send transmits, and they are not
alternatives. The first is the credential. A principal holding drafts:submit and
not messages:send is always held, with reason code draft_only_principal, even on
an inbox configured for direct sending. The second is inbox policy. An
approval_mode of always on any policy layer holds everything, however powerful
the credential is.
Policy is evaluated at workspace scope, then project, then inbox, and matched reasons
accumulate rather than short-circuit, so one held message can carry
["always", "attachments"]. Two further reasons exist that the console cannot set,
new_recipient_domain and attachments. They are operator-configured and returned
in unmanaged_approval_rules rather than hidden, so a rule you cannot edit is at
least a rule you can see.
The held response is HTTP 202, the same status as a successful queue. The body
carries "result": "approval_required" where a direct send carries "result": "queued" and a message_id. Code that reads the status and assumes transmission
drops every message that needed review, silently. This is the single most expensive
detail on this page.
What the gate binds to
An approval binds to content, not to an agent and not to an intention.
When a send is held, the canonical payload, meaning the inbox, action, reply target,
normalized To, Cc and Bcc, subject, text, HTML and attachment IDs, is hashed with
SHA-256 and the digest is written onto both the draft version row and the approval
row. At approval time the two are compared and a mismatch is refused with
version_conflict. That is what turns “the reviewer saw it” from an assumption into
an invariant.
Approval does not freeze authority either. When a human approves, the system re-reads
the current stored grant of the principal that asked, re-checks its project, inbox
and drafts:submit scope, re-evaluates the whole policy chain, and separately checks
that the approver is a human principal holding approvals:decide there. Revoke the
agent’s key while its draft waits and approving fails. Block the recipient while its
draft waits and approving fails with policy_denied. The reasoning behind each of
these is laid out in
how a human approval step should work.
Approving is itself idempotent. The endpoint requires its own Idempotency-Key,
namespaced separately from the original send, and the approval is marked consumed
in the same transaction, so a double-clicked approve button cannot produce two
emails. Approval records expire seven days after they are created.
The events, stated exactly
The event type list declares four: approval.requested, approval.approved,
approval.rejected and approval.invalidated. The current build emits
approval.requested and approval.rejected. The other two are declared and not yet
produced, which is worth knowing before you write a handler that waits on
approval.approved and blocks forever. Read the approval record rather than waiting
for a callback.
What the gate does not do
It does not review content for quality. The gate decides whether a human sees a message before it leaves, not whether the message is any good, and a reviewer who approves everything has bought nothing but latency.
It does not protect the reviewer’s own browser. The approval record carries both
review_html, the server’s sanitized projection, and payload.html, the canonical
content the hash was taken over. Only the first is safe to render. A review UI that
renders the second has turned its own approval queue into an attack surface.
And it is not a substitute for a narrow grant. An approval queue that fills with requests nobody wanted is a symptom of a credential that was too wide to begin with, which is what approvals and permissions treats as the first control rather than the last.