Three controls sit on top of the reconciliation nodes, the
dead-letter queue and the posting mode 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 (data sources cases, approvals, posting).
| Control | What it answers |
|---|---|
| Exceptions workbench | Who is working which reconciliation exception, and how old is it? |
| Approve node | Does a person have to say yes before this record continues? |
| Go-live gate | 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
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,
seencount). - With
finance.cases.auto_clear(default on) anopenorinvestigatingcase that a complete re-run of the same report and day no longer lists is resolved asmatchedbysystem. A truncated report (more exceptions thanmax_exceptions) never clears anything. - A case that was settled as
matchedorcorrectedand shows up again is reopened bysystem: the fix did not hold.written_offandignoredstay 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.
{ "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
- Parked. The node writes the record (and the record as its source delivered it) to
<data_dir>/approvals.dband then answers swallowed. Ack semantics: the source record is settled when it is parked. A NATS message is acked, an ingest or webhook call returnsdone; 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 whenOPEX_MASTER_KEYis set) and survives a restart. - 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 inusersdecides. A rejection needs a comment. A decision is taken once (a second one gets 409). - 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
errorport with_error(messagesays 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.
- approved: the record continues from the next node with
- 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 tomax_resume_attempts). If the connector stops while the record is past the approve node, the approval is markedinterruptedand the inbox says so: the record may or may not have passed the next node, so a person checks the target system and then Resumes 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:
- an editor files a request:
POST /api/golive/requests {var, value: "live", reason}; - a designated approver who is not the requester, with
finance.go_live.approver_roleor above (defaultadmin) or named infinance.go_live.approver_users, approves it with a comment (POST /api/golive/requests/{id}/approve). The variable is set at that moment; - 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 sourceposting) 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 ofhttpandsoapnodes. A node whosemodeis a literallivein the flow document, or nomodeat all (default live), is not gated: in a protected environment the document changes only through an approved promotion or a rollback, andGET /api/postinglists such nodes withsource: 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/postingflags a gated node that posts live with no approved request behind it asunapproved_live: true. - An unprotected environment needs no sign-off;
POST /api/golive/requestsanswers 409not_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):
"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}" }
}casesviews:list(rows = cases,record= counts),summary(rows = ageing buckets,record= the summary),case(id: rows = the case’s history,record= the detail). Fieldsstatus,ledger,kind,day,from,to,assignee,sort; the client filters addmin_amount,max_amount,min_age_days, andqsearches the key.approvals: rows are the approvals withcan_decide; feed them to theapprovalscomponent whoseapprove/rejectactions areapprovalactions (input.decisionisapproveorreject).posting: rows are the posting nodes,recordthe full status.caseactions:opisassign,comment,status,linkorbulk(ids+action).approvalactions 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
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: 72State: <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 |