> Markdown version of https://agledger.ai/docs/guides/approvals/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

# Approvals

Approvals hold a consequential action until someone with authority says yes. The pattern:

- An agent does the work and submits it as a **completion**.
- The record waits at `PROCESSING` until its **principal** renders a verdict.
- The verdict fires a **Settlement Signal webhook**, which the system that acts (a payment run, a
  deploy, a vendor activation) reads before it acts:
  - `SETTLE`: go ahead.
  - `HOLD`: do not.
  - `RELEASE`: a disputed verdict was overturned.

The examples use `principal-gate-generic-v1`, the gated example type every new org is seeded with, with two agents:

- `ap-controller` is the principal, with key `$PRINCIPAL_KEY`.
- `invoice-agent` is the performer, with key `$PERFORMER_KEY`.

A type of your own works the same way, as long as it declares a `completionSchema`. Registering
one is covered in [Define Custom Types](/docs/guides/schemas/).

## 1. Create a gated record

The principal creates the record with its own key and names the performer:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
  -H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "type": "principal-gate-generic-v1",
    "gateMode": "principal",
    "performerAgentId": "'"$PERFORMER_ID"'",
    "criteria": {
      "summary": "Release payment of USD 48,200.00 to Northwind Freight for invoice INV-20931",
      "amount": 48200, "currency": "USD", "vendor": "Northwind Freight", "dealRef": "INV-20931"
    }
  }'
```

```json
{ "id": "01a0dc06-b2cf-7283-8817-4bc1ececa6c2", "status": "CREATED", "gateMode": "principal",
  "nextActions": ["register", "propose", "cancel"] }
```

`gateMode: "principal"` is what makes the record wait for a person or an agent with authority,
rather than for rules alone. Step 4 covers the difference. State it explicitly on every create.

**Who creates it:**

- **The principal's own agent key.** Recommended: the chain attributes the record to the
  accountable agent.
- **An org-admin key, passing `principalAgentId`.** An agent key cannot name another agent as
  principal (403 `Agents can only create records as principal of themselves`).
- **Watch the org-admin shortcut.** An org-admin create that passes only `performerAgentId`
  makes that agent the principal as well (`selfPrincipal: true`), so the performer would
  approve its own work. Passing only `principalAgentId` does the same the other way round: the
  principal becomes the performer. Always pass both from an admin key.

**Skipping the performer's agreement.** Pass `"autoActivate": true` and the record goes
straight to `ACTIVE` with `acceptanceStatus: "BYPASSED"`, and the chain records that the
performer never opted in:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records" \
  -H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type":"principal-gate-generic-v1","gateMode":"principal",
       "principalAgentId":"'"$PRINCIPAL_ID"'","performerAgentId":"'"$PERFORMER_ID"'",
       "autoActivate":true,
       "criteria":{"summary":"Release payment of USD 12,750.00 to Contoso Metals for invoice INV-20944"}}'
```

## 2. The performer accepts or rejects

Without `autoActivate`, the principal proposes the record and the performer answers:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/transition" \
  -H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" -d '{"action":"propose"}'

curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/accept" \
  -H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" -d '{"message":"Invoice matched to PO; terms accepted."}'
```

| Answer | Result |
|---|---|
| Accept | `CREATED` with `acceptanceStatus: "ACCEPTED"`. The principal then activates it (`{"action":"activate"}` on `/transition`) and it becomes `ACTIVE`. |
| Reject (`POST /v1/records/{id}/reject`) | `REJECTED`, which is terminal. The terms cannot be renegotiated on the same record; the principal creates a new one. |

In either case the rationale (`message`) is kept on the record as `lastTransitionReason` and
delivered as `data.reason` on the `record.proposal_accepted` / `record.proposal_rejected` event.

The performer finds proposals waiting for it with `GET /v1/records/agent/proposals`, or with
`GET /v1/records?actionable=true`, which lists everything waiting on that agent.

## 3. The performer submits the completion

Once the record is `ACTIVE`, the record's `completionHint` names the evidence fields its type
requires (`summary` for this type). The performer submits:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/completions" \
  -H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"evidence":{
        "summary":"Payment file PMT-7781 prepared for USD 48,200.00 to Northwind Freight",
        "evidenceUrl":"https://erp.example.com/payments/PMT-7781",
        "notes":"Three-way match passed"}}'
```

```json
{ "id": "01a0dc06-b40c-7f8c-b354-b99e122fdb0d", "structuralValidation": "ACCEPTED",
  "validationErrors": null, "recordStatus": "PROCESSING" }
```

**Check `structuralValidation`, not the status code.** A completion that does not match the
type's `completionSchema` is also answered with a 201:

```json
{ "structuralValidation": "INVALID",
  "validationErrors": [ { "keyword": "required", "params": { "missingProperty": "summary" },
                          "message": "must have required property 'summary'" } ],
  "recordStatus": "ACTIVE" }
```

- **What an `INVALID` completion does.** The record stays `ACTIVE`, nothing is written to its
  chain, and the event is `record.completion_invalid`. Fix the fields `validationErrors` names
  and submit again. A fix spends no revision, because the `INVALID` completion was never
  evaluated. Each attempt counts toward `maxSubmissions` when the record sets one; once it is
  reached, the response stops offering the resubmit and the record waits on its principal.
  Where the org runs `enforcement.maxSubmissionsMode: "advisory"`, a submission past the cap is
  accepted instead, and its `warnings` carry an entry with `keyword: "maxSubmissions"` whose
  `params` name the cap and this submission's number; its `COMPLETION_SUBMITTED` chain entry
  carries the same as `submissionCapAdvisory`.
- **What an `ACCEPTED` completion does.** The record moves to `PROCESSING` with
  `awaitingActor: "principal"`, and the chain gains `COMPLETION_SUBMITTED`.
- **Listing them.** `GET /v1/records/{id}/completions` lists every completion, with
  `?structuralValidation=` to filter.

## 4. Dry-run the gate and read its status

Two checks decide a gated record:

- **Phase 1** is structural: does the evidence match the schema?
- **Phase 2** runs the type's rules, if it has any.

In principal mode, phase 2 is advisory: the engine records what the rules say, and the
principal makes the call.

The engine evaluates each completion on its own when it is submitted. `POST /v1/records/{id}/evaluate`
reads what it found, per completion, and changes nothing; a `phase2Result` of `null` means that
evaluation has not finished yet:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/evaluate" \
  -H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' -d '{}'
```

On a type with a deterministic rule, the result names the rule and the value it saw. The
example is `terminal-outcome-v1`, another seeded example type, whose rule compares the reported
`terminalState` with the expected one. The record was created with `gateMode: "principal"` (the
type defaults to `auto`), and the performer reported the batch as `failed`:

```json
{
  "completions": [{
    "phase1Result": { "status": "ACCEPTED" },
    "phase2Result": {
      "outcome": "reject",
      "recommendation": "HOLD",
      "checks": { "string:exact": { "pass": false, "expected": "settled", "actual": "failed",
                                     "reason": "Expected exactly \"settled\", got \"failed\"" } }
    }
  }],
  "overallStatus": "PROCESSING"
}
```

On a type with no rules, such as `principal-gate-generic-v1`, phase 2 has nothing to check. The
advisory reads `accept` / `SETTLE` whenever phase 1 passed, and the decision is entirely the
principal's.

`GET /v1/records/{id}/gate-status` is the poll:

```json
{ "phase1Status": "passed", "phase2Status": "failed", "pendingRules": [],
  "verdict": null, "recommendation": null, "gateMode": "principal" }
```

`recommendation` stays `null` until a verdict renders the signal. Branch on `recommendation`,
not on `phase2Status`: an advisory can fail while the principal accepts, and the reverse.

The difference between the two modes is who decides:

- **`gateMode: "auto"`.** The rules render the verdict themselves the moment the completion
  lands, and the record settles to `FULFILLED` or `FAILED` with no principal step. Use it
  when the rule is the whole decision.
- **`principal` mode.** Use it when a principal's judgment is part of the approval.

## 5. The principal renders the verdict

Only the record's principal, or an org-admin key, can render it. The performer is refused
(403 `WRONG_STRUCTURAL_ROLE`). Render it before the record's `deadline`: past it the verdict is
refused with a 422 and the record expires in the same call (`EXPIRED`, `terminalReason:
"TIMED_OUT"`), as the expiry sweep would expire it. Once the deadline has passed the record reads
`awaitingActor: "system"` and its `nextSteps` stop offering the verdict.

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/verdict" \
  -H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"completionId":"'"$COMPLETION_ID"'","verdict":"accept",
       "checks":{"threeWayMatch":true,"approvalPolicy":"AP-7"}}'
```

```json
{ "verdict": "accept", "recommendation": "SETTLE", "reporterType": "principal",
  "recordStatus": "FULFILLED" }
```

**What goes in the body:**

- **`completionId`** names the completion being judged. It is on the record as
  `latestCompletionId`.
- **`checks`** is your structured rationale. Its shape is yours; it is signed into the chain
  and delivered on the webhook.
- **`notes`** is free text, and becomes the signal's `reason`:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/verdict" \
  -H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"completionId":"'"$COMPLETION_ID"'","verdict":"reject",
       "notes":"Remit-to bank account changed 2 days ago; hold for callback verification.",
       "checks":{"bankChangeVerified":false}}'
```

```json
{ "verdict": "reject", "recommendation": "HOLD", "reporterType": "principal",
  "recordStatus": "FAILED" }
```

The signal itself, with the reason, is on the record (`GET /v1/records/{id}`):

```json
{ "status": "FAILED",
  "settlementSignal": { "recommendation": "HOLD", "outcome": "reject", "reasonCode": "PRINCIPAL_REJECT",
                        "reason": "Remit-to bank account changed 2 days ago; hold for callback verification." } }
```

**Asking for rework instead of ending there.** A rejected record can go back to the performer:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/revision" \
  -H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" -d '{"reason":"Resubmit after the vendor callback confirms the new account."}'
```

- **What revision does.** The record goes to `REVISION_REQUESTED`, the performer submits a new
  completion, and the principal renders a new verdict on that one.
- **The cap.** `maxRevisions` bounds the loop (3 unless the record or the org set another
  value). Each revision cycle spends one, including a performer's refresh of evidence the
  principal is holding; fixing a schema-`INVALID` completion does not. At the cap the call is
  refused, and the record stays `FAILED`.
- **Each verdict is its own signal.** A HOLD and then a SETTLE are two signals on one record.

### What the Settlement Signal webhook carries

Subscribe the acting system with an org-admin key:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/webhooks" \
  -H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://payments.internal.example.com/agledger","eventTypes":["signal.emitted","dispute.opened","dispute.resolved","dispute.withdrawn"]}'
```

**How it is signed.** A subscription that lists `signal.emitted` is signed with the Server's
own signing key by default (`signingAlg: "ed25519"` on the response). Receivers verify it
against `GET /v1/verification-keys`, and no shared secret is issued. Verifying is covered in
[Webhooks](/docs/guides/webhooks/).

The `signal.emitted` delivery on the two verdicts above:

```json
{ "type": "signal.emitted", "recordId": "01a0dc06-b2cf-7283-8817-4bc1ececa6c2",
  "data": { "recommendation": "SETTLE", "outcome": "accept", "reasonCode": "PRINCIPAL_ACCEPT",
            "reason": null, "failingRuleIds": null, "performerAgentId": "01a0dc06-b223-7321-b060-a38d6951c29f",
            "type": "principal-gate-generic-v1", "platformRef": null } }
```

```json
{ "type": "signal.emitted",
  "data": { "recommendation": "HOLD", "outcome": "reject", "reasonCode": "PRINCIPAL_REJECT",
            "reason": "Duplicate of INV-20911, already paid.", "failingRuleIds": null } }
```

`record.gate_complete` carries the same outcome together with the principal's `checks` and the
`completionId`.

## 6. Disputes

Either party can dispute a verdict on a `FAILED`, `FULFILLED` or `REMEDIATED` record. The
performer disputes the rejection of its batch:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/dispute" \
  -H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"grounds":"verdict_disagreement","context":"The 3 returned items were re-sent and settled; the batch settled in full on 2026-09-26."}'
```

```json
{ "dispute": { "id": "01a0dc06-b649-74ce-91b2-021cd4e3c020", "status": "EVIDENCE_WINDOW",
               "initiatedByRole": "performer", "grounds": "verdict_disagreement",
               "evidenceWindowClosesAt": "2026-09-28T04:43:42.541Z" },
  "autoReadjudication": { "autoResolved": false, "reason": "not_configured" } }
```

**Dispute rules:**

- **`grounds`** is one of `equivalent_item`, `fraudulent_completion`, `record_ambiguity`,
  `pricing_dispute`, `quality_issue`, `verdict_disagreement`, `other`.
- **The record moves to `DISPUTED` while the dispute is open.** A type registered with
  `flipRecordStatusOnDispute: false` keeps its status instead; the dispute is then visible
  only as `disputeStatus` on the record and through the `?hasDispute=` and
  `?disputeStatus=` search filters.
- **One dispute per record** by default (`maxDisputes`).

**Add evidence** while the window is open:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/dispute/evidence" \
  -H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
  -d '{"evidenceType":"external_lookup","payload":{"source":"bank portal","batch":"B17","settledItems":212,"settledAt":"2026-09-26T14:02:00Z"}}'
```

**Read the dispute and list them:**

- `GET /v1/records/{id}/dispute` returns the dispute and its evidence.
- `GET /v1/disputes?status=PENDING_RESOLUTION` is the org-wide queue of disputes waiting on a
  decision (org-admin key).
- A dispute moves from `EVIDENCE_WINDOW` to `PENDING_RESOLUTION` when its window closes.

**Resolve it.** The record's principal, or an org-admin key, decides; the performer is refused
(403). The path takes the **dispute** id:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/disputes/$DISPUTE_ID/resolve" \
  -H "Authorization: Bearer $PRINCIPAL_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"outcome":"OVERTURNED","rationale":"Bank portal confirms all 212 items settled."}'
```

| Outcome | Record | Signal |
|---|---|---|
| `OVERTURNED` | a `FAILED` record settles at `FULFILLED` | `RELEASE` with `reasonCode: "DISPUTE_OVERTURNED"` |
| `UPHELD` | returns to the status it held before the dispute | none |

The engine records the decision; it does not weigh the evidence. After the overturn:

```json
{ "status": "FULFILLED", "disputeStatus": "RESOLVED",
  "settlementSignal": { "recommendation": "RELEASE", "reasonCode": "DISPUTE_OVERTURNED", "reason": "verdict_disagreement" } }
```

**Withdraw it.** The initiator or an org-admin can withdraw a dispute until it is resolved, and
the record returns to its pre-dispute status:

```bash
curl -s -X POST "$AGLEDGER_API_URL/v1/records/$RECORD_ID/dispute/withdraw" \
  -H "Authorization: Bearer $PERFORMER_KEY" -H 'Content-Type: application/json' \
  -d '{"reason":"Surcharge billed separately on INV-20958."}'
```

## 7. Check clearance before acting

**Why the webhook alone is not enough.** The system that acts on a verdict should treat each
delivery as a prompt to look, not as the state itself. Ordering is best-effort: a subscription's
waiting deliveries go out lowest `eventSequence` first, but a failed attempt waits out its backoff
while later events go out, a dead-letter redelivery arrives after everything since, and under a
backlog two deliveries can be in flight at once. `eventSequence` is the order the events were
queued, so a receiver can drop a delivery whose `eventSequence` is lower than one it already
handled for the same `recordId`.

Read the current signal from the record at the moment you act:

```bash
curl -s "$AGLEDGER_API_URL/v1/records/$RECORD_ID/gate-status" -H "Authorization: Bearer $ACTING_SYSTEM_KEY"
```

```json
{ "recommendation": "SETTLE", "verdict": "accept", "phase1Status": "passed", "phase2Status": "passed" }
```

`$ACTING_SYSTEM_KEY` can be an `admin-observer` key: read-only, minted with
`POST /v1/admin/api-keys` and `"scopeProfile": "admin-observer"`. A read by an org-admin key
of a record it is not party to is logged in the Server's read transparency log
(`GET /v1/audit/org-reads`).

Act only on `SETTLE` or `RELEASE`, read at the moment you act. While the record is `DISPUTED`,
`recommendation` and `verdict` read `null`: treat that as "not cleared". A type registered with
`flipRecordStatusOnDispute: false` never reaches `DISPUTED`, so its `gate-status` keeps the
pre-dispute signal while a dispute is open; for those types also read `disputeStatus` on the
record and hold while it is open. To poll without webhooks, run the same call on a schedule, or list records waiting on a principal with
`GET /v1/records/search?status=PROCESSING`.

Deduplicate deliveries on the `X-AGLedger-Idempotency-Key` header, which is stable across
retries.

Field shapes for every call above are in the [API reference](/api/).

## Air-gapped

Every step runs against your own Server, and nothing calls out:

- **Webhook receivers** are on your network. Private addresses need `SSRF_ALLOW_CIDRS`, and a
  plain-HTTP receiver behind a TLS-terminating load balancer needs `SSRF_ALLOW_PLAIN_HTTP`.
- **Signatures** verify against the Server's own `GET /v1/verification-keys`.
- **Audit exports** of approved records verify offline with the verifier from your internal
  package mirror.
