#Core concepts
#Decision
The root object. A decision is a human approval requested at a point in a run. It's keyed by the caller's own ids (engine, externalRunId, stepRef), not by a Ratifia-executed step. It carries the reviewer context, the assignee/approvers, the quorum thresholds, the SLA (timeoutAt), the surface (below), and the verdict. The verdict lives on the decision and nowhere else; it's the single source of truth.
Status flows PENDING → APPROVED | REJECTED | EXPIRED.
#Caller
Whatever asked for the decision. You bring your own. Ratifia tracks the decision; it never runs the work. Three kinds of caller are supported today, recorded on the decision as engine, and they differ only in how they wait for the verdict:
- an agent over the MCP server (
engine: mcp), which blocks onawait_decision; - your own code over the REST API (
engine: api), which polls for the verdict; - a durable workflow through an engine adapter (
engine: inngest; Temporal and Trigger.dev planned), where the function parks on the engine's own wait-for-event primitive and Ratifia fires the resume event.
The decision-first model is what makes this clean: Ratifia never pretends to run steps it doesn't own. A claim processed on Inngest, or an agent about to deploy, creates zero Ratifia step rows, just the decision when a human is needed. Ratifia is not a durable execution engine and doesn't try to be, because that space is solved. (A built-in "bundled" engine existed early on; it was removed.)
#The Inbox
The reviewer-first surface, and the dashboard's home. It lists decisions awaiting a human, sorted by SLA urgency, each showing its context. A reviewer opens one, sees what they're deciding (the AI output, the fields, the diff), and approves or rejects, which resolves the decision and releases the caller. Because the caller owns execution, there are no run/step/DAG views to wade through; Ratifia leads with the decision, not the machinery.
#The app is the floor: notifications and surfaces build on it
Hold one model in your head and the rest follows. There are three layers, and only the first is required:
- The app (the Inbox) is the floor. With nothing configured, an approver logs into Ratifia, sees the decisions waiting on them, each with full context, and approves or rejects. This always works and needs zero setup. Every approval can happen here; it's the baseline the rest extends.
- Notifications reach the person. A notification doesn't change where you approve. It gets the right person's attention that something is waiting, and often that it's time-sensitive: "you have an approval, come act on it." Notifying, not deciding. Each approver controls how they're reached, from their own settings. With nothing configured the decision still sits safely in the Inbox; a notification just means nobody has to go looking for it.
- Surfaces let you act without the app. A surface moves the binding approve/reject outside the app (a signed email link, Slack buttons, a DocuSign envelope) for when logging in isn't convenient or possible. It's a convenience layer on top of the floor: some decisions can be settled off-app, but not all (some need the full context, or legal weight), and the Inbox is always there as the fallback.
In short: Inbox = always · Notification = get told · Surface = act elsewhere. Notifications and surfaces are independent (below) and both optional; the app is the one thing that's always on.
#How the approver is reached
Two things people conflate are actually independent, and Ratifia keeps them separate: how someone is notified that a decision is waiting, and how they record a binding verdict (the surface).
A policy names who decides. Everyone it names is notified at once, by email, in Slack or Discord when that's the surface, and by push if they use the mobile app. See Notifications.
Whatever the notification, the decision still waits in the Inbox until it's decided, so a missed or misconfigured notification delays a decision but never loses one. Delivery is durable and idempotent through the outbox, SLA reminders re-nudge before the deadline, and if a decision is still unanswered at timeoutAt the SLA sweeper auto-expires it: the caller sees EXPIRED, i.e. the reject branch, so nothing wedges.
#Surface: how the approver approves
A surface is where the binding verdict is produced, and it's a different axis from the notification that announced it. The same decision can be announced one way and approved another:
- Inbox. The reviewer clicks Approve/Reject in the dashboard (today's default)
- Email. A signed action-link in the notification itself; approve or reject without logging in (available today)
- Slack. Yes/no buttons in the message itself, for low-ceremony approvals (available today)
- Discord. Approve/Reject buttons on a channel post (available today)
- DocuSign. A signed envelope, when the approval needs legal weight (e.g. large payouts)
- API. A signed call from your own app (roadmap)
Whether an approver can bind on a given surface depends on their verification state: inbox and email are zero-config, while slack and docusign must be verified first. A policy that names a surface its approvers can't bind on is rejected at save time.
Because notification and surface are independent, the same decision can be announced one way and decided another:
| Notified via | Surface (approve) | What it means |
|---|---|---|
| Slack | Slack (yes/no) | pinged in Slack, they click right there; fast, low ceremony |
| Slack | DocuSign | pinged in Slack, but the binding approval is a signed envelope |
| Inbox | emailed a link, they decide in the dashboard | |
| email + SMS | DocuSign | reached two ways, sign to approve |
That last distinction is the whole reason these are separate concepts: DocuSign is not "another channel," it's a surface, meaning how the human commits the decision, with legal weight, regardless of how they were notified. A policy like "$50k+ payouts must be signed, not clicked" is a statement about the surface, not about how the person heard.
The surface is set per policy via the surface field; how each person is notified is their own setting.