Approvals (UI surface)¶
The Approvals page is where a human reviews and decides every
require_approval decision the gate returns. It lives at
/control-center/approvals (sidebar badge counts pending requests)
and is gated by the approvals plan feature — Growth and above.
This page covers the UI surface — terminal-feed rows, the friction-level approve flow, the click-to-Dialog detail panel, and the history tab. The wire contract (action_digest, typed predicates, plan-tier gating) lives in Human approval. Programmatic decision-making (REST endpoints, idempotency, retry semantics) is at the bottom of this page; the API reference is in HTTP API → approvals.
Page layout — terminal feed¶
The pending queue renders as a terminal feed: hairline-divided rows in the spirit of the audit log + terminal-window vocabulary, not bordered cards. Each row reads as a continuous log line; the operator's eye locks onto the icon-prefix marker before parsing the rest of the row.
Status prefix markers¶
The first character of every row is a marker that encodes status and tone:
| Marker | Tone | Status |
|---|---|---|
● |
state-block | pending |
✓ |
state-allow | approved (history tab) |
✗ |
state-flag | denied (history tab) |
⌧ |
fg-muted | expired / consumed (history tab) |
Row anatomy¶
From left to right:
- Prefix marker + workflow name + actor label ("requested by X").
- Hero amount — for money-kind approvals, the spend line is on the row with the ▲ N× above $X limit relationship encoder so the operator sees both the value and why it's over the limit in one glance. Tabular-nums at 28px semibold.
- Why this needs approval — the rule label, deep-linkable to the rule's config page.
- Inline live countdown — a colour-shifting bar + pipe +
tabular
mm:sslabel that shrinks as the review window runs out. Colour flips green → amber → coral at 40% / 15% of the remaining window. - Action button(s) — see below.
For tool-call approvals (money kind = tool_call), the hero
amount is replaced by the operator-approved tool name + the raw
parameter bag, so the operator sees exactly what the SDK is about
to run. The action_digest is the tamper-evident binding, not a
display artefact — the dashboard shows the bag verbatim, never
reconstructed from the digest.
When the SDK forwarded tool_class="mcp" annotations, the row
also renders a class badge (MCP tool / builtin / custom /
unknown) plus a chip row for destructive, read-only,
open-world (each chip shows yes / no / unknown).
Friction-level approve flow¶
The action button label encodes the friction level — operators never fire an action without seeing the value they are approving:
- Low risk → single-click
[ approve ]. - Medium risk →
[ approve ]→[ type 499.00 to confirm ]. - High risk →
[ approve ]→[ type 1,000.00 ]→[ type refund_customer to confirm ].
The amount being approved is surfaced inside the button label itself, not only in the confirmation step. The deny path is a single click on every risk level — see the human-approval page for why deny is unconditional.
Click-to-Dialog¶
Clicking anywhere on a row (outside the action button) opens a Dialog with the full detail panel:
- Hero summary (amount / tool name + parameter bag).
- Why this needs approval — the matched rule's human-readable
predicate (
amount ≥ $50 USD,ANY(amount ≥ 5000, region IN [EU,US])). - Technical details accordion — open by default. Rows:
Action fingerprint, Execution ID, Rule + rule label, Tool
patterns, Per-call threshold, Rule priority (lower = higher),
Review window, Trust level chip (
typed impact/LLM-cost only), Rule created, and the rendered Action predicate.
The Dialog intentionally has no Approve / Deny controls — the friction-level flow lives on the row, and the Dialog is for review, not decision.
History tab¶
The history view is the same page at ?tab=history — a tab strip
in the page header switches between Pending (default) and
History.
History rows are filtered to the last 30 days by default and
support the same search / status filters as the pending feed.
Resolved rows are grouped by outcome (approved, denied,
expired, consumed) with the same prefix-marker vocabulary
(✓ / ✗ / ⌧) so an operator can scan a week of decisions in one
glance.
Bulk toolbar¶
A hairline-divided toolbar above the feed exposes Approve all and Deny all when more than one row is selected. Both bulk actions require the same friction-level confirmations as the single-row flow.
Page chrome¶
- Plan gate — the page itself renders a
TierGateupgrade prompt for plans without theapprovalsfeature. The sidebar link is also hidden for those plans. - SSE live update — every new approval request lands in the feed within a few seconds without refresh; the badge count in the sidebar updates in lockstep.
- Audit trail — every approve / deny decision is recorded in
the audit log (
Audit logunder Governance) with the decided_by UUID, decided_at timestamp, and the operator label (orSystemfor server-side expiry).
Programmatic approval¶
For CI bots and on-call rotations, the same endpoints are exposed via REST and the page chrome has no opinion:
curl -X POST "https://api.nullrun.io/api/v1/orgs/$ORG_ID/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer ***"
# Or deny explicitly
curl -X POST "https://api.nullrun.io/api/v1/orgs/$ORG_ID/approvals/$APPROVAL_ID/deny" \
-H "Authorization: Bearer ***"
The full endpoint catalog — idempotency rules (409
approval_already_decided), the post-approval /execute
binding, and digest-mismatch drift cases — is in
HTTP API → approvals.
Where to read next¶
- Human approval — wire contract, action digest, typed predicates, plan-tier gating.
- HTTP API → approvals — REST endpoints for programmatic decision-making.
- Audit log — every decision
lands in the hash-chained audit log; the operator +
decided_byUUID +decided_atare searchable.
Deep dive
The approval flow is two-phase. The gate returns
require_approval, the operator decides, and the waiting SDK is
resumed by a push once the decision lands. Each approval row is
bound to a digest of the action payload it was raised for, and the
post-approval execute re-check recomputes that digest and refuses
any call that does not match. An approved grant therefore cannot be
redirected at a different action before it is spent.
A decision is applied atomically: the outcome and the consumption stamp move together, so no reader can observe a half-decided row. Approving releases the pending reservation and decrements the outstanding-approval count as part of the same operation. Rows are scoped to the organization when they are read, so an identifier belonging to another organization reads as absent rather than forbidden, and the response gives away nothing about whether it exists.
Expiry runs on a sweep rather than on the reader clock. Pending rows past their window become expired and are attributed to the system; approved rows that are no longer usable are closed separately, and closing them preserves the original decider and timestamp so a later automated transition cannot overwrite an explicit human decision.
The push to the waiting SDK is best-effort, and a missed push is
recovered by a periodic reconciliation pass. Audit emission is
also best-effort: a transient failure there never blocks the
decision itself. The stored decision is a typed value rather than
free text, and the friction level the operator had to clear is
recorded alongside it. Denial is terminal, and the SDK raises
WorkflowKilledInterrupt. The auto-consume path reports consumed,
already-consumed, and not-approved distinctly, and the SDK treats
all three as success.