Docs

#Policies

A policy says who approves, how many of them must agree, and whether a signed document is required. Your code references it by key and inherits all of it, so no approver email or quorum is ever hardcoded in the agent.

This page explains how the pieces fit, what happens when a policy changes, and what stops a policy drifting away from the code that depends on it. For the basics of referencing one, see Human-in-the-loop.

#Three parts, one job each

An approval in Ratifia has three parts. Each one answers a single question, and none of them answers another's.

Your code decides when. Whether a human is needed at all is a rule in your own code: an amount over a limit, a first-time recipient, an attachment. You send the answer as required.

The policy decides who. Which people are asked, and how many must agree. Where they answer isn't a policy setting: yes/no decisions can be answered wherever you're notified, and anything more opens the app (where people answer).

The decision is the record. What was asked, who could answer, who did, and the exact version of the policy that applied.

text
your code        if (amount >= $5,000) → required: true                          │  policy: { key: "refunds" }                          ▼Ratifia          policy "refunds" v3: dana, sam · 1 of 2                 decision created, pinned to v3                          │                          ▼approvers        dana approves in Slack                          │                          ▼your code        verify + consume → send the refund                 (approved under refunds v3)

In code, the rule and the policy key sit side by side:

ts
const needsReview = refund.amountCents >= 500_000   // your rule, your code
await flowplane.awaitApproval(ctx, {  id: 'refund-review',  policy: { key: 'refunds' }, // who, how many, the expiry, the facts required  required: needsReview,  facts: { refundId: refund.id, amountCents: refund.amountCents },})

Change who approves refunds in the dashboard and nothing in your code changes. Change the $5,000 limit and it ships with your code, reviewed like any other change.

Keep the decision out of the agent's reach

This works because the code that decides required, and the code that performs the action, is code the agent can't change or route around. If an agent can choose required: false for itself, pick a weaker policy, or call the payment API directly with the same credentials, no policy can protect you. Keep the credentials for the action in the component that checks the approval. See Approvals that can't be skipped.

#What a policy decides

Who is asked. An approver group, and the quorum they must reach.

When a decision expires. timeoutSec; unset means it waits indefinitely.

Which facts every request must carry. An optional context contract.

Whether a signed document is required. One optional setting; see below.

A request under a policy can't override any of these: sending approvers, approvalsRequired or timeoutSec with a policy is refused with 422 field_owned_by_policy. See Two ways to ask for complete requests.

#Context contract

A policy can say which facts every request under it must carry, as a JSON Schema (draft 2020-12) over the plain values of context.data:

json
{  "key": "refunds",  "defaults": {    "approvers": ["risk@acme.com", "cfo@acme.com"],    "timeoutSec": 86400,    "contextSchema": {      "type": "object",      "required": ["refundId", "amountCents"],      "properties": {        "refundId": { "type": "string" },        "amountCents": { "type": "integer", "minimum": 1 }      }    }  }}

A request whose data doesn't satisfy it is refused, and no decision is created:

json
{  "error": "context_contract_violation",  "message": "context.data does not satisfy policy \"refunds\" version 3.",  "policy": "refunds",  "violations": ["/ must have required property 'amountCents'"]}

The schema checks the value of each fact, not its source: for an enriched field like { "value": 125000, "src": "tool" }, it sees 125000. The contract is checked even when required is false, so a malformed request is caught on either branch.

A contract checks shape (is the amount there, is it a number), never whether a value calls for approval. That rule stays in your code. The contract is part of the policy's versioned definition: changing it creates a new version, and a schema that isn't valid JSON Schema is refused when you save the policy.

#Requires a signed document

Tick Requires a signed document (DocuSign) on a policy, or send "requiresSignature": true, and every decision under it is approved by signing a specific document. Each request must attach the document to sign, e.g. a DocuSign envelope. The signature is the approval.

json
{ "policy": { "key": "contracts" }, "docusign": { "envelopeId": "8f2c…" } }
  • Without the document the request is refused with 422 artifact_required and no decision is created. It fails closed, like an unknown policy key, instead of waiting for a signature that can never arrive.
  • With it, only the signature decides. The app, email, Slack and Discord show the decision and link to the envelope, but can't approve or reject it.
  • The decision view says what decided it: Signed in DocuSign · envelope 3f2a… (open).

That's the only channel setting a policy has. For everything else the rule is the same for every policy: yes/no decisions can be answered wherever you're notified; anything more opens the app.

defaults.surface from older policies is no longer used. "surface": "docusign" is read as "requiresSignature": true; any other value is ignored and dropped the next time the policy is saved.

What a policy does not decide is how each approver is notified. That is their own setting. A policy says a verdict must come from Dana; Dana decides whether she hears about it by email, Slack or a push to her phone.

#Keys are permanent

A policy has three identifying fields, and only one is load-bearing:

  • key is what your code passes. Permanent, and unique in your org. Use the same key in staging and production and the same code works in both.
  • name is the human label. Rename it freely.
  • description is optional.

To move to a different key: create the new policy, deploy the code that references it, then archive the old one once it has been unused for 30 days.

#Every change is a version

Each time a policy's definition changes (its approvers, quorum or signature requirement), Ratifia records a new version: refunds v1, v2, v3. Versions are never edited or removed. Saving a policy with nothing changed adds no version, so writing your policies on every deploy is safe. Renaming its label doesn't add one either.

Every decision records the version it was created under, as policyVersion (the number) and policyVersionId. You'll find them:

  • on the decision, from GET /v1/decisions/:id and the worker API;
  • on the result of verifyApproval and consumeApproval, and in the SDK's ApprovalRecord;
  • in the MCP tools check_decision and await_decision, as policy_version and policy_version_id.

Six months from now, when someone asks who was allowed to approve a refund, the answer is the version the decision recorded, not whatever the policy says today.

Versions are immutable in the database itself, not just by convention: a stored version can't be edited or deleted, by the dashboard, the API, or a stray query.

#Policy history

Every policy has a History page in the dashboard (Policies, then the history icon next to Edit). It lists each version: who changed it, from where (dashboard, API or repository) and when, with the content hash. Open any version to see exactly who could approve under it, or compare two to see what changed:

text
v2 → v3  Approvers added             cfo@acme.com  Approvers removed           risk@acme.com  Requires a signed document  — → Yes

On a decision, policy refunds v2 links straight to the version it ran under. Archived policies keep their history.

The same history is available from the API, with a session or an API key:

bash
curl https://api.ratifia.com/v1/worker/policies/refunds/versions \  -H "Authorization: Bearer $RATIFIA_API_KEY"
curl https://api.ratifia.com/v1/worker/policies/refunds/versions/2 \  -H "Authorization: Bearer $RATIFIA_API_KEY"
curl "https://api.ratifia.com/v1/worker/policies/refunds/versions/compare?from=2&to=3" \  -H "Authorization: Bearer $RATIFIA_API_KEY"

#Previewing a change

Because every change is permanent, you see it before you make it. Saving a policy in the dashboard first shows what will change and which version it creates:

text
Saving creates version 4 of refunds. Decisions already waiting keep version 3.  Approvers added     sam@acme.com

Confirm to save. If nothing in the definition changed, it says No changes: no new version will be created.

To preview from code or CI, send the same body you'd upsert to the preview endpoint. It returns what saving would do and writes nothing:

bash
curl https://api.ratifia.com/v1/worker/policies/preview \  -H "Authorization: Bearer $RATIFIA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "key": "refunds", "defaults": { "approvers": ["risk@acme.com", "sam@acme.com"] } }'
json
{  "preview": {    "key": "refunds",    "exists": true,    "currentVersion": 3,    "nextVersion": 4,    "noop": false,    "diff": { "approvers": { "added": ["sam@acme.com"], "removed": [] }, "changes": [] },    "labels": []  }}

A preview is refused for the same reasons a save would be, so a clean preview means the save will go through.

#When a policy changes while a decision is waiting

A decision keeps the version it was created under, whatever happens to the policy afterwards.

The approvers are fixed when the decision is created. Only the people listed in that version can vote on it. Remove Dana from the policy tomorrow and she can still answer the decisions that were already waiting for her. Add Sam and he can't vote on those; he's asked from the next decision onwards. The quorum and signature requirement are fixed the same way.

Anyone else is refused. A vote from someone who isn't one of the decision's approvers is refused with 403 not_an_approver and isn't counted:

json
{ "error": "not_an_approver" }

That includes an API key. Automation can ask for an approval, but it can't give one; a person has to. The refused attempt is recorded on the decision's timeline and in the audit log, so you can see who tried. In Slack and Discord the person sees a private message instead of an error; an email action link opens a page saying they're not an approver on this decision.

An archived policy's decisions still finish. If a policy is archived while decisions are waiting, they complete as normal under the version they recorded. New requests using that key are refused.

The dashboard tells you. A decision shows the policy and version it was requested under, and notes when the policy has changed or been archived since.

An ad-hoc decision (no policy, only approvers) is restricted the same way: only the approvers it names can answer it, and an API key is refused with 403 not_an_approver.

#Approvals bound to the action

A policy decides who approves. It's the action itself that must check the approval happened, right before it runs, for exactly what was approved.

Send facts (the recipient, the amount, the account) when you ask. Before the action runs, consumeApproval checks that the approval is granted, that the facts match what you're about to do, and that it hasn't been spent on something else. Edit the approved email and the check fails. Retry the same send with the same consumer and it passes without a second approval, so a retry can't send twice. The full pattern is in Approvals that can't be skipped.

#Refusals that protect you

Several things fail loudly rather than quietly. Each exists because the quiet version caused a real problem.

#An unknown policy key is refused

Passing a policy key that doesn't exist, or belongs to an archived policy, returns 422 unknown_policy, and no decision is created.

json
{  "error": "unknown_policy",  "message": "No approval policy with key \"refnuds\" exists in this org.",  "policy": "refnuds"}

That holds whether required is true or false. A typo in a policy key stops the decision instead of silently sending it without the approvers and quorum you meant it to have.

#A request that names nobody, or names two ways, is refused

Every decision says who can answer it: the policy's approvers, or the approvers an ad-hoc request lists. A request with a policy and approvers (or approvalsRequired, or timeoutSec) is refused with 422 field_owned_by_policy: the policy decides those. A request that names nobody, or whose policy lists no approvers, is refused with 422 approver_required.

#A policy that changed since you built against it is refused

Send policy.version and, if the policy has moved on, the request is refused with 409 policy_version_mismatch (with currentVersion) rather than running under rules you haven't reviewed. Leave version out to always use the current one.

#Data that breaks the contract is refused

See Context contract: 422 context_contract_violation, with the list of violations.

#A policy that could never reach quorum is refused

Every policy write runs a satisfiability check and rejects with 422 unsatisfiable_policy if the policy could not resolve, naming what was required and what is actually available.

A quorum of 3 over a group of 2, or a policy that requires a signature where only one approver is verified to sign in DocuSign, can't ever complete. Without this check, every matching decision would wait forever and look like a stuck integration. Nothing is saved on a rejection.

#Archiving a policy in use is refused

Deleting a policy archives it: its key stops resolving and it leaves the list, but its versions stay so past decisions still report the definition they ran under. Upserting the same key later restores it.

Archiving a policy with pending decisions, or one used in the last 30 days, returns 409 policy_in_use, because code almost certainly still references that key and would immediately start failing with unknown_policy. Pass force=true to archive anyway. The override is recorded in the audit log.

#A vote from outside the approver list is refused

See above: 403 not_an_approver, recorded on the timeline.

#Verify policies in CI before you deploy

bash
npx ratifia policies check refunds claims-eu

Exits 0 when every key exists and can reach quorum, 1 when any can't. Wire it into your pipeline and a missing or unsatisfiable policy fails the deploy, rather than failing the first real approval at 2am.

yaml
- name: Verify Ratifia policies  env:    RATIFIA_API_KEY: ${{ secrets.RATIFIA_API_KEY }}  run: npx ratifia policies check refunds claims-eu

Run it against the environment you're deploying to. A policy that exists in staging and not production is exactly the drift this catches.

#Two ways to write a policy, one schema

RouteAuth
Policy-as-codePOST /v1/worker/policiesAPI key with policies:write (roles)
Dashboard/policiessigned-in session

Both go through identical validation and both create versions, so a policy written one way behaves the same as one written the other. Upserts are idempotent on (org, key), which is what makes policy-as-code safe to run on every deploy.

To change one field without overwriting the rest, read the policy first with GET /v1/worker/policies/:key, which returns its current definition. Your tooling can then, say, seed approvers only when none are set, and leave anything someone changed in the dashboard alone. GET /v1/worker/policies lists keys and labels only, for building a picker.

Policies with a floor

Older policies can carry a floor: conditions on the plain values of context.data that force a review even when required is false. Existing floors keep working, and can be set only through the API. We recommend putting that rule in your code instead, next to the data it reads. A floor refers to fields by name, so if a field is renamed or missing, its condition doesn't match and the review it was meant to force doesn't happen. A context contract that requires the field closes that gap.

#Examples

Insurance claims. A claims workflow checks the claim amount in code and asks for review above its limit, referencing the claims policy. The policy lists the adjusters who approve, so changing the adjusting team is a dashboard edit, not a deploy. The claim page shows who could approve and "Approved under claims v3", and the payout step consumes the approval with the claim's own facts before paying. The payout pattern is the running example in Approvals that can't be skipped.

n8n. Workflows that ask for approval with the Ratifia node or plain HTTP Request nodes, and a protected send that refuses any email that wasn't approved exactly as written. See n8n.