4. Concepts
4.1 Tenants, users and roles
- A tenant is one customer organisation (
tenantstable). Every business row carries atenant_id, and PostgreSQL row-level security policies exist as a second layer behind the application's own filters. - A user belongs to one tenant and holds roles (
users.roles). - A token is a Loco JWT whose subject (
pid) is the user's id. The backend looks up the user's tenant and roles on every request. A user from another tenant gets "not found" for your threads, never "forbidden", so whether a thread exists is not revealed. - Roles and permissions come from
config/agents/rbac.yaml. A role can inherit from others:admininheritsapprover, which inheritsclerk.
| Permission | Needed for |
|---|---|
runs.start | starting a run (chat, AG-UI, or an upload that names a workflow) |
runs.read | GET /api/runs/{thread} |
documents.upload | POST /api/documents |
approvals.resolve | deciding an approval (as well as holding a role the approval node names) |
documents.read, config.read, tenants.manage | declared for future endpoints; nothing checks them yet |
4.2 Runs, threads and interrupts
- A run is one workflow execution. Its id is the AG-UI thread id, chosen by the client.
- The workflow's progress is checkpointed in PostgreSQL after every step
(
workflow_sessions). A run waiting for approval survives a server restart: the approver can resume it from a new process. - When a run reaches
human.approval, it pauses and opens an interrupt with id{thread}:{node}, for examplet1:approval. The interrupt is also stored inapprovals, with an expiry. - The run's status (
workflow_runs.status) is one of:
| Status | Meaning |
|---|---|
running | being driven, or about to be after a decision |
awaiting_approval | waiting for a person |
completed | ended at a terminal node other than reject (for invoice_v1, done) |
rejected | ended at the node named reject |
failed | a step failed (for example, model output that broke the schema) |
needs_reconciliation | an external call was interrupted mid-flight; see §9.3 |
4.3 The audit trail
Every run start, every step's outcome, every decision and every flagged reconciliation is written
to audit_log. Each entry stores the SHA-256 of the previous entry for the same tenant, so editing
or deleting any entry breaks the chain from that point on. A run's trail, read straight from the
database:
id | actor | action | step | hash
----+-------------------------------------------+-------------------+----------+----------------
82 | user:96ffd89e-9397-5834-94b3-c9b7c7d0a2eb | run.started | | 45a36e6671f9…
83 | system | task.completed | parse | a6218d673892…
84 | system | task.completed | extract | 14e13a49c7bc…
85 | system | task.completed | validate | a8e6e0fe3bde…
86 | system | task.completed | match | a90fced406c7…
87 | system | task.completed | approval | 398f77f14ce5…
88 | user:2ea44f3c-b0d9-5fa3-8b8f-a1e0eca3d1f9 | approval.approved | | 5c4be92cecee…
89 | system | task.completed | approval | 15b017c5f583…
90 | system | task.completed | post | aae4af695310…
91 | system | task.completed | done | 942b4d448d80…
The clerk started the run (82), the approver decided it (88), and everything else was the system.
PgStores::verify_audit_chain(tenant) in app/src/stores/pg.rs recomputes a tenant's chain and
reports the first broken entry; the HTTP test suite tampers with an entry to prove it.