Arcus Docs

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.

json
{
  "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.

json
{
  "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.

json
{
  "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:

bash
curl -sS "$ARCUS_URL/v1/admin/messages/pending" \
  -H "Authorization: Bearer <clerk-session-token>"

Each entry carries what a reviewer actually needs to decide:

FieldWhy it is there
idThe transfer log id, used to approve or reject
sourceAgentWhich agent asked
targetUrlWhere it would go
payloadThe full retained body — the thing being approved
createdAtHow long it has been waiting
decisionReasonWhich policy or which guardrail held it
findingsFor a guardrail hold: field, condition, expected, observed, severity
policyIdFor 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#

bash
curl -sS -X POST "$ARCUS_URL/v1/admin/messages/clx8m2p4k0008abcd/approve" \
  -H "Authorization: Bearer <clerk-session-token>"

On approval:

  1. The transfer log moves from PENDING_APPROVAL to PENDING.
  2. The original retained payload is enqueued — not a re-derived one.
  3. message.approved is appended to your audit chain with the approver.
  4. 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#

bash
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:

python
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#

EventWritten when
dispatch.held_for_approvalA REQUIRE_APPROVAL policy held the dispatch
dispatch.held_for_parameterA hold_for_approval guardrail held it, with findings
message.approvedA person approved it, with the approver
message.rejectedA person rejected it, with the approver and the reason
delivery.succeeded / delivery.failedThe 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.