Three controls sit on top of the [reconciliation](/docs/flows#reconciliation-ledger-and-reconcile) nodes, the
[dead-letter queue](/docs/flows#error-branches) and the [posting mode](/docs/flows#mode-posting-switch) of the
`http` node. They all live in the **Operations** dialog of the editor (**Exceptions**, **Approvals**), in the REST
API, in the MCP server and in [apps](/docs/apps) (data sources `cases`, `approvals`, `posting`).

| Control | What it answers |
|---|---|
| [Exceptions workbench](#exceptions-workbench) | Who is working which reconciliation exception, and how old is it? |
| [Approve node](#approve-node-and-approvals-inbox) | Does a person have to say yes before this record continues? |
| [Go-live gate](#go-live-gate-for-posting) | Who signed off that this node now posts for real? |

## Exceptions workbench

Every exception of a stored reconcile report (`missing_left`, `missing_right`, `amount_mismatch`) becomes a **case**
in `reconcile.db`. The `reconcile` node opens them as it stores its report; `POST /api/cases/from-report/{id}`
does the same for an older report.

A case is identified by *(report name, business day, kind, key)*, so running a report twice never doubles anything.

### Lifecycle

```text
open ──► investigating ──► resolved  (matched manually | posted a correction | written off)
  │            │
  └────────────┴──────────► ignored
(a resolved or ignored case can be reopened)
```

| Move | Needs |
|---|---|
| `investigating` | a case that is `open`; the caller becomes the assignee when nobody is |
| `resolved` | a **resolution**: `matched` (checked, nothing to post), `corrected` (a correction was posted; give its `reference`) or `written_off` (a **comment** with the reason is required) |
| `ignored` | a **comment** with the reason (a test payment, a duplicate) |
| `open` | reopen from any other status |

Besides the status a case has an **assignee**, **comments**, and **links**: to a dead letter (`dead_letter_id`),
to a trace (`trace_id`), and, automatically, to the **ledger rows** behind the exception (every ledger side of the
reconcile node, looked up by key). The detail also lists the open dead letters that mention the key, with a
one-click *Link*. Every change appends an event (who, what, when, details) to the case; the API call is audited as
well (`case.assign`, `case.comment`, `case.status`, `case.link`, `case.bulk`, `case.from_report`).

### How reports keep cases up to date

- A new exception opens a case. A known one is refreshed (amounts, `seen` count).
- With `finance.cases.auto_clear` (default on) an `open` or `investigating` case that a **complete** re-run of the
  same report and day no longer lists is resolved as `matched` by `system`. A truncated report (more exceptions than
  `max_exceptions`) never clears anything.
- A case that was settled as `matched` or `corrected` and shows up again is **reopened** by `system`: the fix did not
  hold. `written_off` and `ignored` stay closed (they are decisions).

### Filters, ageing, bulk actions, export

`GET /api/cases` filters by `status` (comma list, `active` = open + investigating, the default, or `all`), `ledger`
(the report name or the ledger name of one of its sides), `kind`, `day` or `from`/`to`, `min_amount`/`max_amount`,
`assignee` (`unassigned` = the pool), `q` (key or correction reference) and `min_age_days`. `sort` is `oldest`
(default), `newest`, `amount`, `updated` or `day`.

`GET /api/cases/summary` returns the counts per status, the **ageing** buckets of the active cases (0-1, 2-3, 4-7,
8-30, over 30 days), unassigned count, amount at stake and the open work per report. `GET /api/cases/csv` downloads the
filtered list. `POST /api/cases/bulk` applies one action (`assign`, `comment`, `status`) to the listed `ids` or to
every case matching a `filter` (at most 500); cases that cannot take the action (a closed case cannot be assigned)
fail alone and are listed in `failed`.

### Daily digest

Once a day at `finance.cases.digest.time` in its timezone, one alert with the open cases (per report, oldest and
largest) goes through the configured alert channels (`telemetry.alerts`: webhook, Telegram, e-mail, Teams, WhatsApp).
A day without open cases sends nothing; a connector that was down at that time sends when it is back the same day;
a restart never repeats it. `GET /api/cases/digest` previews it, `POST /api/cases/digest` sends it now
(`case.digest`, operator). The alert is `critical` when the oldest case is a week old, else `warning`.

### API

| Method | Path | |
|---|---|---|
| GET | `/api/cases`, `/api/cases/summary`, `/api/cases/csv` | list / summary / CSV (viewer) |
| GET | `/api/cases/{id}` | the case, comments, events, ledger rows, linked and suggested dead letters, trace |
| POST | `/api/cases/{id}/assign` `{assignee}` | operator; `null` returns it to the pool |
| POST | `/api/cases/{id}/comment` `{text}` | operator |
| POST | `/api/cases/{id}/status` `{status, resolution?, comment?, reference?}` | operator |
| POST | `/api/cases/{id}/link` `{dead_letter_id?, trace_id?}` | operator; `null` clears |
| POST | `/api/cases/bulk` `{ids \| filter, action, ...}` | operator |
| POST | `/api/cases/from-report/{id}` | operator |
| GET, POST | `/api/cases/digest` | preview / send now |

MCP: `list_cases`, `get_case`, `case_summary`, `assign_case`, `comment_case`, `set_case_status`, `link_case`,
`bulk_update_cases`.

## Approve node and approvals inbox

The **approve** node parks a record until a person approves or rejects it.

```json
{ "id": "appr", "type": "approve",
  "title": "Pay ${payee} ${amount}", "summary": "invoice ${invoice}",
  "when": "metadata.amount > 5000000",
  "role": "editor", "users": ["fin.controller"],
  "timeout": "24h", "escalate_after": "4h",
  "key": "${invoice}", "amount": "amount" }
```

| Field | |
|---|---|
| `when` | DSL condition like `filter` (a business record's fields read as `metadata.<path>`). A record that does not match passes through untouched; without `when` every record waits. |
| `title`, `summary`, `key` | `${path}` templates (a missing placeholder fails the record). `key` makes parking idempotent: while an approval with the same key is waiting or being resumed, a redelivery is absorbed instead of parked twice. |
| `amount` | Dot path of the amount shown in the inbox. |
| `currency_path`, `currency` | Dot path of the record's ISO 4217 code (`USD`) for that amount; `currency` is a fixed code for records without one. With neither, the inbox shows the plain number: a currency is never assumed. App components read it back with `currency_path: "currency"`. |
| `role` | Minimum role of a decider: `operator`, `editor` (default) or `admin`. |
| `users` | Extra named deciders (user name, token name or e-mail; at least the operator role). |
| `timeout` | `30m`, `24h`, `7d` or seconds. After it the record fails into the node's `error` port. Without it the record waits indefinitely. |
| `escalate_after` | One alert (`approval_escalation`) through the alert channels if the approval is still waiting; shorter than `timeout`. |

### What happens to the record

1. **Parked.** The node writes the record (and the record as its source delivered it) to `<data_dir>/approvals.db`
   and then answers *swallowed*. **Ack semantics: the source record is settled when it is parked.** A NATS message
   is acked, an ingest or webhook call returns `done`; nothing is redelivered while a person decides. From then on
   the approvals database is responsible for the record. It is durable (SQLite, WAL; sealed with the data-column key
   when `OPEX_MASTER_KEY` is set) and survives a restart.
2. **Decided.** In the inbox (**Operations > Approvals**, `POST /api/approvals/{id}/approve|reject`, the MCP tools or
   an app) a caller who holds the node's role or is named in `users` decides. A rejection needs a comment. A decision
   is taken once (a second one gets 409).
3. **Resumed.** The record re-enters the flow **at the approve node**, which reads the decision from the database
   (not from the record) and acts on it:
- **approved**: the record continues from the next node with `_approval {id, decision, by, role, at, comment}`;
- **rejected** or **timed out**: the node fails and the record leaves by the `error` port with `_error` (`message`
     says who rejected it and why, or that nobody decided in time) and `_approval`. With no error edge it lands in
     the dead-letter queue like any unrouted failure.
4. **Resume is at-least-once up to the node, then normal.** A sweep every `finance.approvals.sweep_secs` (default 5 s;
   the decision call also hands the record over at once) puts every decided record back; the node claims it exactly
   once. If the flow asks for a retry (a sink or remote failure), the record is queued again (up to
   `max_resume_attempts`). If the connector stops **while** the record is past the approve node, the approval is
   marked `interrupted` and the inbox says so: the record may or may not have passed the next node, so a person
   checks the target system and then **Resume**s it (`POST /api/approvals/{id}/resume`). Beyond that point the usual
   delivery semantics apply (retry, error port, dead letters).

`_approval` is added only by the node. A record that arrives carrying one has it removed before it is parked, and a
resume marker that is not a live decision of that node is ignored.

A **dry run** (Test) shows what would be parked and parks nothing.

### API

| Method | Path | |
|---|---|---|
| GET | `/api/approvals?status=&node=&q=&resume=&mine=` | `{total, counts, items}`; `status` is a comma list of `pending,approved,rejected,timed_out` or `all` (default `pending`, oldest first); `mine=true` keeps what the caller may decide; each item has `can_decide` |
| GET | `/api/approvals/{id}` | with the parked record, masked like a dead letter |
| POST | `/api/approvals/{id}/approve` `{comment?}`, `/reject` `{comment}` | operator on the route; the handler checks the node's role / users (403 `not_an_approver` with `required_role`) |
| POST | `/api/approvals/{id}/resume` | put a `failed` / `interrupted` resume back in the queue |

Audit actions: `approval.approve`, `approval.reject`, `approval.resume`. MCP: `list_approvals`, `get_approval`,
`approve_approval`, `reject_approval`, `resume_approval`.

## Go-live gate for posting

An `http` node's `mode` decides whether it posts for real. With `mode: "${var:POST_MODE}"` the instance variable
`POST_MODE` (`live` | `read_only` | `sandbox_only`) decides, per environment. In a **protected** environment
(`environment.protected: true`) that variable cannot be switched to `live` directly, not even by an admin:
`PUT /api/vars/POST_MODE` answers `409 go_live_required`. The switch needs two people:

1. an **editor** files a request: `POST /api/golive/requests {var, value: "live", reason}`;
2. a **designated approver who is not the requester**, with `finance.go_live.approver_role` or above (default `admin`)
   or named in `finance.go_live.approver_users`, approves it with a comment
   (`POST /api/golive/requests/{id}/approve`). The variable is set at that moment;
3. the decision (requested by, approved by, role, time, comment) is stored in `approvals.db`, written to the audit
   log (`golive.request`, `golive.approve`, `golive.reject`, `golive.cancel`) and **shown on the node**: the posting
   modes list (**Operations > Approvals > Go-live for posting**, `GET /api/posting`, the app source `posting`) and
   the canvas badge say *live*, who signed it off, who asked, and when.

A request expires after `finance.go_live.ttl_hours` (72). The requester (or an admin) can cancel it; an approver can
reject it (a reason is required). Switching **away** from live (`read_only`, `sandbox_only`) needs no sign-off, so the
safe direction is always one call. Removing a variable file entry that would expose a live value from the config is
guarded like a set.

What the gate covers, and what it does not:

- It guards `${var:NAME}` modes of `http` and `soap` nodes. A node whose `mode` is a literal `live` in the flow
  document, or no `mode` at all (default live), is not gated: in a protected environment the document changes only
  through an approved promotion or a rollback, and `GET /api/posting` lists such nodes with `source: fixed`.
- A variable defined in the config file is changed in the config file (the file is the sign-off).
- Someone with shell access can still edit `vars.json`. `GET /api/posting` flags a gated node that posts live with no
  approved request behind it as `unapproved_live: true`.
- An unprotected environment needs no sign-off; `POST /api/golive/requests` answers 409 `not_protected`.

| Method | Path | |
|---|---|---|
| GET | `/api/posting` | every http / soap node: `effective` mode, `source`, `gated`, `signed_off`, `last_decision`, `pending`, `unapproved_live`; plus approver role / users and the requests |
| GET | `/api/golive/requests?status=&var=` | the requests |
| POST | `/api/golive/requests` `{var, value, reason}` | editor |
| POST | `/api/golive/requests/{id}/approve`, `/reject`, `/cancel` | approver (403 `not_an_approver`, 403 `second_approver_required` for the requester) |

MCP: `posting_status`, `request_go_live`, `approve_go_live`, `reject_go_live`.

## In apps

The Apps runtime reads and acts on these controls without a flow in between (see [Apps](/docs/apps)):

```json
"sources": {
  "cases":          { "type": "cases", "view": "list", "status": "active", "sort": "oldest" },
  "case_summary":   { "type": "cases", "view": "summary" },
  "flow_approvals": { "type": "approvals", "status": "pending" },
  "posting":        { "type": "posting" }
},
"actions": {
  "case_status":     { "type": "case", "op": "status", "id": "${input.id}", "status": "${input.status}", "resolution": "${input.resolution}", "comment": "${input.comment}" },
  "decide_approval": { "type": "approval", "id": "${input.id}", "decision": "${input.decision}", "comment": "${input.comment}" }
}
```

- `cases` views: `list` (rows = cases, `record` = counts), `summary` (rows = ageing buckets, `record` = the summary),
  `case` (`id`: rows = the case's history, `record` = the detail). Fields `status`, `ledger`, `kind`, `day`, `from`,
  `to`, `assignee`, `sort`; the client filters add `min_amount`, `max_amount`, `min_age_days`, and `q` searches the key.
- `approvals`: rows are the approvals with `can_decide`; feed them to the `approvals` component whose `approve` /
  `reject` actions are `approval` actions (`input.decision` is `approve` or `reject`).
- `posting`: rows are the posting nodes, `record` the full status.
- `case` actions: `op` is `assign`, `comment`, `status`, `link` or `bulk` (`ids` + `action`). `approval` actions run the
  same checks as the REST route, so the node's role still decides who may approve. Both default to the operator role.

The example app `finance-exceptions` has a **Workbench** page using all of them.

## Configuration

```yaml
finance:
  cases:
auto_clear: true             # resolve open cases a complete re-run no longer lists
digest:
  enabled: true
  time: "08:00"              # HH:MM in `timezone`
  timezone: Asia/Jakarta     # IANA, default UTC
  weekdays_only: false
  approvals:
sweep_secs: 5                # resume / timeout / escalation sweep
max_resume_attempts: 5
  go_live:
approver_role: admin         # operator | editor | admin
approver_users: [fin.controller]
ttl_hours: 72
```

State: `<data_dir>/approvals.db` (parked records, go-live requests) and the case tables in `reconcile.db`. Back them up
with `sqlite3 <db> ".backup out.db"` like the other stores.

## Roles at a glance

| Action | Role |
|---|---|
| Read cases, approvals, posting status | viewer |
| Work cases (assign, comment, status, link, bulk, digest) | operator |
| Decide an approval | operator or above **and** the node's `role` / `users` |
| Request a go-live | editor |
| Approve / reject a go-live | `finance.go_live.approver_role` or a named user, never the requester |
| Cancel a go-live request | the requester, or an admin |