Skip to main content

4. Concepts

4.1 Tenants, users and roles​

  • A tenant is one customer organisation (tenants table). Every business row carries a tenant_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: admin inherits approver, which inherits clerk.
PermissionNeeded for
runs.startstarting a run (chat, AG-UI, or an upload that names a workflow)
runs.readGET /api/runs/{thread}
documents.uploadPOST /api/documents
approvals.resolvedeciding an approval (as well as holding a role the approval node names)
documents.read, config.read, tenants.managedeclared 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 example t1:approval. The interrupt is also stored in approvals, with an expiry.
  • The run's status (workflow_runs.status) is one of:
StatusMeaning
runningbeing driven, or about to be after a decision
awaiting_approvalwaiting for a person
completedended at a terminal node other than reject (for invoice_v1, done)
rejectedended at the node named reject
faileda step failed (for example, model output that broke the schema)
needs_reconciliationan 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.