Enforcement
Approval workflow
Some requests should not be refused and should not be automatic. Arcus holds them — payload retained, nothing delivered — until a person approves or rejects, and records the decision with the approver's name.
Human in the loop, without the polling#
A held dispatch is the middle option between ALLOW and BLOCK. The agent gets an immediate, honest answer: accepted, not delivered, waiting for a person.
{
"ok": true,
"status": "PENDING_APPROVAL",
"transferLogId": "clx8m2p4k0008abcd",
"queued": false
}202, queued: false. The payload is retained in full. Nothing reaches your target. If nobody ever decides, nothing is ever delivered — the hold has no timeout that quietly turns into a delivery.
`queued: false` is the field that matters
A hold and a successful dispatch both return 202. If your agent treats 202 as "sent", it will report success for a request that is sitting in a queue. Check queued, or check status.
The two ways a dispatch gets held#
A REQUIRE_APPROVAL policy#
Route-level. Every dispatch from this sender to this endpoint needs a person.
{
"senderAgentId": "billing-agent",
"targetEndpoint": "https://payments.example.com/transfers",
"action": "REQUIRE_APPROVAL"
}Audit event: dispatch.held_for_approval.
Right for a route that is consequential regardless of content — anything that moves money, deletes a customer, publishes externally, or touches production configuration.
A hold_for_approval guardrail#
Content-level. Only dispatches whose values match the rule.
{
"label": "Cap single transfers",
"field": "data.amount",
"condition": "greater_than",
"value": 10000,
"action": "hold_for_approval",
"senderAgentId": "billing-agent"
}Audit event: dispatch.held_for_parameter, and the findings are attached to the held record so the reviewer sees why without having to reconstruct it.
Right when the route is routinely fine and only the outliers need a person. A £200 transfer goes straight through; a £50,000 one waits.
Combine them by consequence, not by caution
Policy-level holds for routes where the destination is the risk. Guardrail-level holds for routes where the value is the risk. Holding everything produces a queue nobody reads, which is functionally the same as holding nothing.
What still runs before a hold#
A held dispatch has already passed gates 1 and 2 — it is not quarantined and its identity checks out. Gates 4 and 5 are evaluated too, which has a consequence worth stating:
A REQUIRE_APPROVAL route carrying a credential is refused with 422, not queued. DLP does not defer to a human. This is deliberate — an approval queue that fills with credential leaks invites someone to approve one by mistake, and a leaked secret is not a judgement call.
The same is true of a block guardrail. If one rule says hold and another says block, block wins — see strongest action.
Reviewing the queue#
In the dashboard: the approval queue on the Policies page. Via the API:
curl -sS "$ARCUS_URL/v1/admin/messages/pending" \
-H "Authorization: Bearer <clerk-session-token>"Each entry carries what a reviewer actually needs to decide:
| Field | Why it is there |
|---|---|
id | The transfer log id, used to approve or reject |
sourceAgent | Which agent asked |
targetUrl | Where it would go |
payload | The full retained body — the thing being approved |
createdAt | How long it has been waiting |
decisionReason | Which policy or which guardrail held it |
findings | For a guardrail hold: field, condition, expected, observed, severity |
policyId | For a policy hold: the exact rule |
The payload is shown in full. An approval is a decision about a specific request, and a reviewer approving a summary of a request is not reviewing it.
Approving#
curl -sS -X POST "$ARCUS_URL/v1/admin/messages/clx8m2p4k0008abcd/approve" \
-H "Authorization: Bearer <clerk-session-token>"On approval:
- The transfer log moves from
PENDING_APPROVALtoPENDING. - The original retained payload is enqueued — not a re-derived one.
message.approvedis appended to your audit chain with the approver.- The delivery worker delivers it with the normal retry behaviour.
Delivery outcome events (delivery.succeeded / delivery.failed) follow as usual, so the chain records both the human decision and what happened as a result of it.
Rejecting#
curl -sS -X POST "$ARCUS_URL/v1/admin/messages/clx8m2p4k0008abcd/reject" \
-H "Authorization: Bearer <clerk-session-token>" \
-H "Content-Type: application/json" \
-d '{ "reason": "Amount unverified — no matching invoice in the ledger" }'The status becomes REJECTED, nothing is delivered, and message.rejected is appended with the approver and the reason.
reason is optional and capped at 500 characters. Write one anyway. Six months later, "why did we not send this" is a question your audit chain can answer only if somebody answered it at the time — and a rejection reason is the single highest-value free-text field in the system.
What the agent sees#
Nothing further, automatically. Arcus does not call the agent back.
The agent holds a transferLogId and can check the status:
def dispatch_and_wait(session, arcus_url, body, poll_seconds=30, timeout_seconds=3600):
"""Send a dispatch; if it is held, poll until a human decides."""
r = session.post(f"{arcus_url}/v1/dispatch", json=body, timeout=15)
r.raise_for_status()
result = r.json()
if result.get("status") != "PENDING_APPROVAL":
return result # delivered or refused outright
log_id = result["transferLogId"]
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
time.sleep(poll_seconds)
logs = session.get(f"{arcus_url}/v1/admin/logs?limit=200", timeout=15).json()
entry = next((e for e in logs["logs"] if e["id"] == log_id), None)
if entry and entry["status"] not in ("PENDING_APPROVAL",):
return entry # SUCCESS, REJECTED or FAILED
raise TimeoutError(f"{log_id} still awaiting a decision")Status polling needs a control-plane credential
/v1/admin/logs requires a Clerk session token — an API key cannot read it. An agent that needs to observe its own held dispatches must do so through a service you control, not with its ark_live_ key. This is the credential separation working as intended.
The better pattern in most systems is not polling at all. Design the agent to treat PENDING_APPROVAL as a terminal state for that unit of work — report "submitted for approval", release the worker, and let the eventual delivery to your target drive whatever happens next. Your receiving endpoint is already a webhook; approval simply changes when it fires.
Operational notes#
Held payloads are retained. That is what makes approval possible, and it means a held dispatch containing sensitive business data sits in your database until decided. Reject and clear old holds rather than leaving them indefinitely.
There is no expiry. A hold does not lapse into a delivery or a rejection. If your workflow needs a deadline, enforce it on your side — reject anything older than your SLA.
There is no approval routing or escalation. Anyone with control-plane access to the account can approve. Arcus records who did, in the sealed chain; it does not implement approver roles, multi-party approval or notification rules. If you need those, build them around /v1/admin/messages/pending — the queue is a plain, pollable API.
Approvals are per tenant. The queue only ever contains your own account's held dispatches.
The record#
| Event | Written when |
|---|---|
dispatch.held_for_approval | A REQUIRE_APPROVAL policy held the dispatch |
dispatch.held_for_parameter | A hold_for_approval guardrail held it, with findings |
message.approved | A person approved it, with the approver |
message.rejected | A person rejected it, with the approver and the reason |
delivery.succeeded / delivery.failed | The outcome after approval |
Every one of these is hash-chained and cannot be edited afterwards. For a regulated process this is the artefact that matters: not "our policy is that large transfers are reviewed", but a sealed sequence showing this specific transfer was held, who reviewed it, when, what they decided, and what happened next. See Audit chain and Compliance reporting.