Skip to content

Finance controls

The exceptions workbench for reconciliation, the approve node and approvals inbox, and the go-live sign-off for posting in a protected environment.

Updated View as Markdown

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, 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.

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

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

"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

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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close