Appearance
Admin console
The admin console (apps/admin-console) is the web app for the people who run a tenant: analysts work cases, subjects and runs; authors define rules, rule sets, lists, workflows and collection flows; admins install apps, read the audit trail and manage the tenant's settings. This page covers signing in, the console's areas and the case-management API behind them; Admin console: Define covers authoring, versions and approvals. Every screen calls the documented API with the signed-in user's token: the API enforces every permission, and the console only hides what a role cannot use.
Packages:
apps/admin-console— the console (React, served by Vite on port 5176 in development).@aletheia-dev/core—Case,CaseView(a case plus its derivedslaState),caseSlaState,CaseFilter,CaseQueue,CaseNote,DirectoryUser, the list and batch shapes (CaseListItem,SubjectSummary,CaseCountsInput,ClaimNextCaseInput,BulkCaseInput),subjectDisplayNameand the tenant settingcases.defaultSlaHours.@aletheia-dev/auth— theUserDirectoryinterface,createZitadelUserDirectoryandStaticUserDirectory; permissionscases:read,cases:decide,cases:assign,cases:claim,cases:notes.@aletheia-dev/db—repos.cases(list/count/countManyoverCaseFilter, guardedtransition,claimNextwithSKIP LOCKED),repos.caseNotes,repos.caseQueues(tablescase_notes,case_queues).@aletheia-dev/workflow-engine—createCase(setsdueAt), the SLA race in the interpreter,escalateCase, and the automatic close when a run completes.apps/api—/cases,/cases/:id/notes,/case-queues,/users.
Running it
- Development.
pnpm devserves the console on http://localhost:5176. The browser only talks to that origin: Vite forwards/api/*to the API (ADMIN_CONSOLE_API_TARGET, defaulthttp://localhost:4000) without the page'sOrigin,Refereror cookies, so the API needs no CORS entry for it. Sign-in reads theVITE_ZITADEL_*values in.env(Get started). - Deployment. The web image serves the console on port 5176 behind the
console.host and forwards/api/*toADMIN_CONSOLE_API_UPSTREAM(defaultAPI_URL) (Deployment). For attachments,STORAGE_CORS_ORIGINSmust list its origin.
Signing in
The sign-in page asks for the organization domain (one Zitadel organization is one tenant; the device remembers up to five) and continues to Zitadel's own login page, which pnpm auth:seed gives the console's look (its colours, the Archivo font and the Aletheia wordmark): an authorization-code flow with PKCE, scoped to that organization. Tokens are kept in memory only; a reload goes back through Zitadel, silently while the Zitadel session lasts. Switching organization means signing out first. Once signed in, the console reads GET /me for the principal, the tenant and the permissions. After 30 minutes without input in any tab, the console drops its tokens and Zitadel asks for credentials again; unsaved note and decision drafts stay on the device. Sign out (avatar menu) deletes them and ends the Zitadel session in every tab.
Layout and permissions
The top bar holds the areas, the search palette (⌘K or Ctrl+K), the health indicator, the notifications bell and the avatar menu. An area shows when the role can open one of its tabs; a page the role cannot open shows a 403 page that names the missing permission.
| Area | Tabs | Permission |
|---|---|---|
| Home | none | any signed-in user |
| Operate | Cases, Subjects, Runs, Approvals | cases:read, subjects:read, runs:read; Approvals: definitions:approve or definitions:write |
| Define | Rules, Rule sets, Workflows, Collection flows, Lists | definitions:read; Lists: lists:read |
| Connect | Apps, Webhooks, API keys | Apps: apps:read; Webhooks: webhooks:read; API keys: users:manage |
| Audit | Trail, Exports | audit:read; Exports: audit:export |
| Settings | General, Team, Roles, SLA, Queues, Reason codes, Tenant | tenants:settings; Queues also opens with cases:assign |
Admins hold every permission. Analysts work cases (every cases:* permission and documents:write for attachments), read subjects, runs, definitions, lists and the audit trail, and run backtests: they see Define read-only and reach Settings › Queues from the workbench. The integration role is for machines and has no use for the console. Settings › Roles shows the whole matrix; "What can I do?" in the avatar menu lists your own permissions.
Notifications
The bell in the top bar (notifications:read, which admins and analysts hold) shows how many notifications are unread and opens the ten newest; clicking one marks it read and opens what it is about, and Mark all read clears the count. It asks again every minute. The worker raises notifications from the same events as outbound webhooks, a few seconds after they happen, and nobody is told about what they did themselves:
| Kind | When | Who |
|---|---|---|
assigned | a case is assigned to someone other than the assigner | the assignee |
sla_breached | a case passes its due date while open or in review | its assignee, or everyone with cases:assign while it has none |
approval_requested | a definition version waits for approval | everyone with definitions:approve |
app_degraded | the workers open an app's circuit | everyone with apps:write |
webhook_exhausted | a webhook delivery is given up after five attempts | everyone with webhooks:write |
GET /notifications?unread=&limit= answers { items, unread }: the caller's notifications newest first (limit 20 by default, at most 100), each with text, href (a console path) and readAt, and the unread count (up to 1000). POST /notifications/read takes { ids } or { all: true } and answers how many were marked; read marks are per person.
Search
The palette sends what is typed to GET /search?q=&scope=&limit= (any of subjects:read, cases:read, runs:read, definitions:read or lists:read) and adds the console's actions, filtered here; > first searches actions only. The API searches every scope the caller may read at once, five results each by default (limit, at most 20, and repeated scope to narrow):
- subjects by external id (exactly or as a prefix) or by name or legal name (as a prefix);
- cases by the start of their id or by their subject, runs by the start of their id, their correlation id or their subject;
- rules, rule sets, workflows, collection flows (
definitions:read) and lists (lists:read) by key or name anywhere, newest version only, exact and leading matches first.
A full id, with or without #, looks the subject, case or run up directly. A scope that fails or takes over a second comes back empty with partial: true (the palette says "some sources failed"). Each result carries a title, a subtitle and the console path it opens (href). Records opened from the palette are remembered on the device; the text typed before opening one is kept with the person's preferences (recentSearches, ten at most), and both are offered while the input is empty.
Health
The indicator reads GET /health/summary (anyone signed in but applicants) every minute: the database, Temporal and object storage (ok, error, or off when the deployment has none); with apps:read, the apps (off when the deployment runs none, otherwise the apps with failed calls in the last hour and the open circuits); with webhooks:read, the webhook deliveries given up in the last 24 hours and to how many endpoints. state is down when the database is unreachable, degraded when anything else is failing and ok otherwise. While it is not ok, a banner under the sub-tabs names what is failing, with links to the app's page or to Connect › Webhooks (webhooks:read). The public GET /health/ready stays for load balancers.
Home
Home opens after sign-in unless a device preference (Settings › General) picks My cases or Runs. With cases:read it shows four figures from GET /stats/overview, refreshed every 30 seconds: Open cases (with the unassigned count), Breached SLA (with how long the longest-overdue case is past due), Due today (open cases due before local midnight, overdue ones included, and how many are yours) and, for admins, Runs · 24h (with the share decided automatically and the failures). Below them come My cases (the six of yours due first) and Recent activity (GET /activity, below). Next case → (cases:claim) claims the oldest free case of All open and opens it. Admins also get the Setup checklist (GET /setup/checklist, below), hidden once every step is done or always, as Settings › General says, and Needs your attention, the overview's list of pending approvals, of apps disabled, missing secrets or failing in the last hour, of webhook endpoints with deliveries given up and of scheduled exports failed in the last day; a tenant with no case and no published workflow gets a first-run page with Import a policy pack →.
GET /activity?limit= (cases:read; 5 by default, at most 50) answers the newest case.decided and case.sla.breached events and, with definitions:read, the publishes and the approval requests, approvals, rejections and withdrawals, each as a sentence (text, the actor named from the reviewer directory) with a console path (href) and a tone (alert for a breach or a rejection). Payload values beyond the outcome, keys and versions are left out.
GET /setup/checklist (tenants:settings) computes six steps on each call: identity (always done), team (someone besides the first admin holds a role, from the reviewer directory), app (one is installed and enabled), workflow (one is published), sla (a default SLA is set) and api_key (an enabled service user holds a key). A step the API cannot tell (no directory or management credential) has done: null; the card says "not tracked".
GET /stats/overview?dueBefore= (cases:read) answers { cases, runs24h?, attention }:
cases:open,unassigned,breached(past due, or stamped breached, while open or in review),dueToday(due beforedueBefore, overdue ones included; the console sends its local midnight, the API defaults to the next midnight UTC),dueTodayMineandoldestBreachMs.runs24h(withruns:read): the runs started in the last 24 hours,total,completed,waiting,failed,p95MsandautoDecidedPct, the share of the decided ones decided without a reviewer.attention: what the caller may act on.approvals(count and the oldest three) withdefinitions:approve;app_disabledandapp_unconfigured(the secrets missing) withapps:write;app_failing(calls, failures and timeouts in the last hour, or an open circuit) withapps:read;webhook_exhausted(an endpoint, how many of its deliveries were given up in the last 24 hours and the newest one's event) withwebhooks:read;export_failed(a scheduled export whose newest run in the last 24 hours failed, with the error) withaudit:export.
Every figure is computed on the request.
Cases
A case is a manual-review item the workflow opens (create_case step) when a run needs a human decision. Operate › Cases is the workbench where reviewers find, claim and decide cases; each case has a page with the evidence, the decision controls, notes and attachments.
The workbench
- Queues (left rail). Every tenant has the system queues: All open (oldest first), Unassigned, My cases (the default, by due date), Breached SLA, High priority (high and critical) and Decided, awaiting close, plus All cases (every status, newest first). The first five hold open and in-review cases only. Team queues lists the tenant's queues everyone (or, for admins, admins only) sees, and My queues the caller's personal ones. Counts refresh every 30 seconds.
- Filters narrow the current view and never change the queue: search (the start of the case id, or the subject's external id or name), status, priority, type (with the types in use as suggestions), country, routing label, reason code (picked from the catalogue when the tenant keeps one), waiting for the applicant, SLA state, assignee and sort. They live in the URL (
queue,status,priority,sla,type,country,label,reason,waiting,assignee,subjectId,dueBefore,search,sort,order,offset), so a view can be bookmarked. Save as queue, + New queue and Edit queue open Settings › Queues with the editor filled in (cases:assign). - Table: case, subject, type, priority, status, assignee, SLA and created, 25 rows a page. The SLA column reads "Breached · 6h over", "Due in 1h 20m", "Due" and a date, or "No deadline". Assignee names come with the rows (
expand=assignee). - Bulk actions (
cases:assign) on the selected rows: Assign to… a reviewer or Unassigned, and Claim (cases:claim), throughPOST /cases/bulk. - Claim next → (
cases:claim) claims the next free case by the queue's own filter, not the on-screen one, when the queue is one "Claim next" uses; otherwise, and from Home's Next case, it draws from the queues marked for it, in their order, and opens the case.
The case page
The header names the subject, the case's priority, status and type, and the workflow version that opened it. Its actions: Reassign (cases:assign; a reviewer or Unassigned), Claim (take over a case assigned to someone else), Request info, and Claim to decide while the case is unassigned or Decide → once it is claimed.
The page reads, top to bottom: Why it's here (the rules that failed, then every rule result), Who (the subject's attributes), Requests for information (once there is one: what was asked, where it stands and, once answered, which answers the applicant changed; needs submissions:read), Documents, Vendor checks (the run's app calls, with apps:read, each with a result a reviewer can read, such as "Match found" or "No match"; the raw response needs runs:context), Workflow run (the run and case context, key by key), Events, Decisions on this subject and History (the case's audit events). Documents, vendor checks and events come in one request, GET /cases/:id/evidence. A block the role cannot read names the permission it needs. The rail holds the SLA line, the decision buttons, the assignee, queue, run and first response, and the notes. Note and decision text is kept as a draft on the device until it is saved or emptied.
- Decide takes the outcome, optional reason codes and a note, which is required when the outcome differs from the subject's latest automated decision or a chosen code requires one. With a catalogue the codes are picked from it, those that apply to the outcome; without one a code and a message are typed. & next → decides and opens your next case in the queue, claiming one when you have none.
- Request info (
cases:decide) asks the applicant for more information without deciding. You write what they should change or add;POST /cases/:id/request-infosends the form the case's run collected again, as a follow-up with their answers (files included) filled in and your message on top, and the dialog shows the link to send them. The case staysin_review, marked Waiting for applicant, with its SLA paused; the rail shows when it was asked, until when, and (submissions:write) Copy applicant link and Withdraw request. When the applicant sends the form, the run repeats the checks it made after that form (itscall_appandevaluate_rulessteps) on the new answers and the case comes back with the rest of its SLA. A request still unanswered when its link expires (the form's link lifetime) rejects the case with the reasoninfo_not_provided; deciding or closing the case meanwhile withdraws it. The request needs a run waiting on the case that collected a form (409 otherwise). - Close case closes a decided case; Close without deciding closes an undecided one with an optional reason (see the status model).
Case routes
| Route | Permission | Notes |
|---|---|---|
GET /cases | cases:read | CaseFilter + queueId, sort, order, limit, offset, expand; { items, total } |
GET /cases/:id/evidence | cases:read | rule hits and score, documents, vendor checks, events, decisions; a section is null without its permission |
GET /case-types | cases:read | the types published workflows open and open cases have, with their workflows and open counts |
GET /reason-codes, PUT /reason-codes | cases:read, tenants:settings | the reason-code catalogue; PUT replaces it (audited reason_codes.updated) |
POST /cases/counts | cases:read | { filters: { <name>: CaseFilter } } (20 at most) → { counts } |
POST /cases/claim-next | cases:claim | { queueId }, { filter, sort?, order? }, or {} for the claim-next queues; 404 nothing_to_claim |
POST /cases/bulk | cases:assign | { ids, action, assigneeId?, reason? } → { updated, failed } |
GET /cases/:id | cases:read | { case, subject, run, decisions } |
POST /cases/:id/assign | cases:assign | { assigneeId } (null unassigns) |
POST /cases/:id/claim | cases:claim | assigns the caller |
POST /cases/:id/decide | cases:decide | { outcome, reasons?, note? }; manual decision, signals the run; 409 case_not_assigned on someone else's case; reasons checked against the catalogue |
POST /cases/:id/request-info | cases:decide | { message } → { case, submission, link, expiresAt }; the run waits |
POST /cases/:id/close | cases:decide | { reason? }; rejects first when undecided |
GET /cases/:id/notes | cases:read | oldest first, paged |
POST /cases/:id/notes | cases:notes | { body } (10 KB max) |
PATCH /cases/:id/notes/:noteId | cases:notes | author within 24 h, or an admin |
DELETE /cases/:id/notes/:noteId | cases:notes | author or an admin; 204 |
GET /case-queues | cases:read | the queues the caller sees, by position |
GET /case-queues/counts | cases:read | { items: [{ queueId, count }] }, me resolved |
POST /case-queues/preview | cases:read | { filter, sort, order } → { total, items } (the first five) |
PUT /case-queues/order | cases:assign | { ids }: the new order |
POST /case-queues, PUT /case-queues/:id, DELETE /case-queues/:id | cases:assign | names unique per tenant (409 queue_name_taken); a system queue keeps its name and filter and cannot be deleted |
GET /users?role= | cases:assign or cases:claim | the reviewer directory; empty without a PAT |
A decision's note is stored as one more reason, with the code manual.note. Admins hold every permission above; analysts hold cases:read, cases:decide, cases:assign, cases:claim, cases:notes and documents:write (for attachments); the integration role holds none of the case permissions. A route may declare several permissions, in which case holding any one of them is enough.
Status model
open ──assign / first note──▶ in_review ──decide──▶ decided ──close / run completes──▶ closed
│ ▲
└──────────────────────── close (reject with code case_closed) ──────────────────────┘open -> in_reviewon the first assignment or the first note. Both stampfirstResponseAtonce; later assignments and notes leave it alone.in_review -> decided(alsoopen -> decided) onPOST /cases/:id/decide: the decision is recorded (source: manual),decidedAtanddecisionIdare set and the waiting run receives themanualDecisionsignal. Only the assignee decides: a case assigned to someone else answers 409case_not_assigneduntil the caller claims it, and an unassigned case is assigned to the caller with the decision (audited ascase.assigned), as the console's claim-then-decide does.- Waiting for the applicant is not a status:
POST /cases/:id/request-infokeeps the casein_review(anopenone moves there) withinfoRequestedAtset anddueAtempty, and the run sends theinfoRequestedsignal's follow-up through its checks once answered, then clearsinfoRequestedAtand setsdueAtto now plus what was left of the SLA. An unanswered request moves the case todecidedwith an automatedreject(info_not_provided) when it expires. decided -> closedbyPOST /cases/:id/closeor automatically when the run completes (closedReason: run_completed).open|in_review -> closedbyPOST /cases/:id/close { reason? }: the case first receives a manualrejectdecision with reason{ code: 'case_closed', message: reason ?? 'closed without decision' }so the run never waits forever, then closes withclosedReason = reason ?? 'closed'.open|in_review|decided -> closedwithout a decision when its run is cancelled (POST /workflow-runs/:id/cancel,closedReason: run_cancelled): nothing waits for it any more.- A closed case conflicts (409) with assign, claim, decide and close. Reopening is deferred.
Transitions run through repos.cases.transition(ctx, id, from[], patch), an UPDATE ... WHERE status = ANY(from), so two reviewers racing on the same case cannot both win.
Queues and filters
Queues are saved filters, not an ownership model: case_queues { name, filter, sort, order, position, createdBy, systemKey, visibility, claimNext }, unique by name per tenant. Every tenant has the six system queues (systemKey all_open, unassigned, mine, breached, high_priority, decided), made on its first GET /case-queues; a queue of the tenant's that already had one of their names becomes it. Their name, filter and sort are fixed; their position, visibility and claim-next choice are not. A queue's visibility is everyone, admins (callers with tenants:settings) or personal (its creator): a queue the caller does not see is left out of the lists and is a 404 by id. claimNext marks the queues POST /cases/claim-next {} draws from, in position order. A case carries assigneeId and, when its run gave it one, a routing label (queue, see SLAs) that queue filters select on.
GET /cases takes the same CaseFilter a queue stores:
| Param | Values |
|---|---|
status | open, in_review, decided, closed (repeatable) |
priority | low, medium, high, critical (repeatable) |
assignee | a reviewer id, me (the caller) or unassigned |
type | the case type (kyb_review, ...) |
subjectId | one subject |
slaState | none, ok, due_soon, breached (repeatable) |
dueBefore | ISO-8601 instant |
queue | the routing label, exactly |
reasonCode | a reason code of the case's decision |
country | the subject's country, as GET /subjects?country= reads it |
waiting | true: waiting for the applicant; false: not |
search | the start of the case id, or the subject's external id (exactly or as a prefix) or the start of its name |
sort | createdAt (default, desc), dueAt (undated last), priority |
order | asc, desc |
Repeated params build the arrays (?status=open&status=in_review). assignee: me is resolved to the caller on every request, which keeps stored queue filters user-independent; GET /case-queues/counts runs each queue's filter through repos.cases.count the same way.
queueId applies a queue the caller sees: its filter, with any filter given alongside replacing the queue's of the same name, and its sort unless one is given.
The response is { items, total, totalIsEstimate }: total counts the cases matching the filter, ignoring limit and offset (one count(*) with the same conditions, no ordering), and stops at 100,000, where totalIsEstimate turns true and the console shows "100,000+". expand=assignee adds assignee: { id, name, email } from the reviewer directory (name null for someone it no longer lists, null for an unassigned case). expand=subject adds the subject of each case as subject: { id, externalId, kind, country?, displayName }, read for the whole page in one extra query; displayName follows subjectDisplayName in @aletheia-dev/core (name, legalName, fullName, displayName, then firstName lastName, then the external id) and country follows subjectCountry. Nothing else from the subject data is returned, and names are not masked.
POST /cases/counts counts several filters in one request: the body names up to 20 filters, each the CaseFilter above as JSON (assignee: me resolved the same way), and the answer has a count under each name. The console reads its system queues, the Cases badge and the Home figures this way, one request every 30 seconds.
Assignment and the reviewer directory
assigneeId is the Zitadel user id. Names come from the directory: GET /users lists the human users of the tenant's organization that hold a role on the Aletheia project, as { id, name, email?, roles[] }, read live from Zitadel's user and authorization APIs and cached in memory for 60 s per organization (a failed refresh serves the stale entry and logs a warning).
The directory needs ZITADEL_DIRECTORY_PAT: the PAT of the machine user aletheia-directory, which pnpm auth:seed creates with the organization role ORG_OWNER_VIEWER (reads users and grants, writes nothing) and writes to docker/zitadel/bootstrap/directory.pat. Without it the API logs "reviewer directory disabled", GET /users is empty and assignees are not validated.
POST /cases/:id/assign { assigneeId }(cases:assign) requires the user to be in the directory when one is configured (404not_found"user" otherwise), sets the assignee, stampsfirstResponseAton the first assignment and moves an open case toin_review.{ assigneeId: null }unassigns without touching the status.POST /cases/:id/claim(cases:claim) does the same for the caller, without the directory check (the caller is authenticated already; service users with the admin role may claim too).POST /cases/claim-next(cases:claim) claims for the caller the next free case of a stored queue ({ queueId }: the queue's filter and sort) or of{ filter, sort, order }(oldest first by default). Only unassignedopenandin_reviewcases qualify, so a filter that names an assignee or only decided cases finds nothing. The case is picked and assigned in one transaction withSELECT ... FOR UPDATE SKIP LOCKED: two reviewers asking at once get two different cases. Bookkeeping and audit are those of/claim; when no case is free the answer is 404 with the codenothing_to_claim, which the console shows as "Nothing to claim." rather than as an error.POST /cases/bulk(cases:assign) appliesassign(withassigneeId,nullunassigns),claim(also needscases:claim) orclose(with an optionalreason; also needscases:decide) to up to 100 cases. Each case goes through the single-case logic in order, with its own audit events; anassigneeIdoutside the directory fails the whole request with 404. A case that cannot change (closed, not found) is listed infailedwith its id, error code and message while the others still change: the answer is 200 with{ updated, failed }.
SLAs
- The
create_casestep takes an optionalslaHours; the tenant settingcases.defaultSlaHours(PUT /tenants/me/settings, Settings › SLA in the console) applies when the step sets none. Either way the activity setsdueAt = createdAt + hours; without both the case has no deadline. - The step may also take
priorityFrom, a context path whose value (lowtocritical) replaces the step'sprioritywhen it is one, andqueueFrom, a context path whose text (trimmed, 100 characters at most) becomes the case's routing labelqueue. - The interpreter races the
manualDecisionsignal against the deadline. On expiry it callsescalateCase, which bumps the priority one level (low -> medium -> high -> critical), stampsslaBreachedAt, appendscase.sla.breached, and keeps waiting for the decision. The escalation is idempotent (guarded byslaBreachedAt) and replay-safe. slaStateis derived at read time bycaseSlaState(case, now)(SQL twin incase-query.tsfor filters and counts):none: nodueAt;breached:slaBreachedAtset, ordueAtpassed while the case isopen/in_review;due_soon: within the case'sslaWarnPct% of the SLA window beforedueAt(the tenant'scases.warnAtPctwhen the case opened), or without one the larger of 20 % of the window and two hours, capped at half the window so a one-hour SLA isdue_soononly in its last 30 minutes;ok: otherwise, including every decided or closed case with a deadline.
- A breach notifies the case's assignee, or everyone with
cases:assignwhile it has none (notifications), and goes to the webhook endpoints subscribed tocase.sla.breached(outbound webhooks); reviewers also see the SLA column, the Breached SLA queue and the priority bump. No email is sent. GET /stats/sla?days=(cases:read, 1 to 90 days, default 30) measures the window:decidedcases,withinPct(of the decided cases with a due date, the share decided by it and never stamped breached),breached(cases stamped breached in the window, decided since or not),medianDecideMs(opening to decision),openPastDue(open and in-review cases past due now), andbyWorkflow, the window's figures per workflow of the case's run (workflowKey: nullfor cases opened without one), busiest first. Settings › SLA shows them.
Reason codes
Settings › Reason codes (admins) keeps the tenant's catalogue: each code (lower case letters, digits, _, . and -) with a label, the outcomes it may justify (any when none is ticked) and whether it needs a note. GET /reason-codes (cases:read) lists it in order and PUT /reason-codes (tenants:settings) replaces it, audited as reason_codes.updated. With codes in it, POST /cases/:id/decide accepts only those codes, each for the outcomes it allows, and a code that needs a note only with the decision's note (400 validation_error otherwise); an empty catalogue lets reviewers type their own codes. The codes the API gives itself (case_closed, info_not_provided, manual.note) are not checked.
Notes
case_notes { caseId, authorId, body, createdAt, editedAt? }, plain text or Markdown, 10 KB max, listed oldest first. A first note is a first response (firstResponseAt, open -> in_review). The author may edit a note within 24 hours and delete it at any time; an admin (a principal holding tenants:settings) may edit or delete any note. Notes are addressed through their case: a note id under another case's path is a 404. Mentions and rich text are deferred.
Attachments
Attachments reuse documents: the case page's Upload attachment (JPEG, PNG, WebP or PDF up to 20 MB; analysts hold documents:write) calls POST /documents { subjectId, caseId, fileName, contentType, sizeBytes }, then the presigned upload and POST /documents/:id/finalize as for any other file; the worker scans it the same way. GET /documents?caseId= lists a case's attachments. The case page's Documents block lists every document of the subject, applicant uploads included; a clean image or PDF shows the grayscale thumbnail the scan rendered (GET /documents/:id/thumbnail), and preview and download (a fresh 60-second link each time) work once a file is clean. See Documents.
Audit actions
Every transition appends an audit event on resourceType: case:
| Action | Payload |
|---|---|
case.created | by the workflow activity |
case.assigned | { assigneeId, previousAssigneeId, status } (assign, claim, claim-next, bulk assign and claim) |
case.unassigned | { assigneeId: null, previousAssigneeId, status } |
case.note.added | { noteId, length } |
case.note.edited | { noteId, authorId } |
case.note.deleted | { noteId, authorId } |
case.decided | { decisionId, outcome, reasons, subjectId, workflowRunId } |
case.sla.breached | by the workflow activity |
case.closed | { reason, decisionId, workflowRunId } (API; reason: run_cancelled when the run is cancelled) or { caseId, reason: run_completed, workflowRunId } (worker) |
GET /audit-events?resourceType=case&resourceId=<id> returns them newest first; the case page's History block reads this.
Subjects
Operate › Subjects lists every subject, 25 a page, with search (name or external id), kind, status, risk-score and country filters; with cases:read each row adds its open cases and last decision, and the list filters on the last decision too. Export CSV downloads every subject matching the filters (up to 10,000, from GET /subjects/export). New subject → (subjects:write) creates one with POST /subjects. A subject's page shows when it was first and last seen, risk over time, its open and past cases, its decisions, 20 a page (tick two, on any pages, and Compare → to see how the score, the rule results and the reasons changed), runs, submissions, events and the timeline, with the attributes, tags, flags and linked subjects in the rail. Start run ▾ (runs:start) starts a published workflow for the subject and opens the run. Edit attributes (subjects:write) changes the status, the tags and the attributes (as JSON); it sends only what changed, so values your role reads masked stay as stored unless you edit them.
Flags are derived on each request (GET /subjects/:id/flags): a score of 90 or more, each rule that failed on the newest decision (not shadow rules), a new device (the newest event came from a device none of the earlier events used), a device or card shared with other subjects, and a case waiting for the applicant; rule hits and the waiting case need cases:read. Linked subjects (GET /subjects/:id/links) are those sharing a device id, card fingerprint, IP address or email with the subject's newest 200 events, the same data.address object, or a referral (data.referredBy holding the other's external id), most recent first, at most 50; the shared values are not returned.
Submissions (submissions:read) lists every collection submission of the subject, 20 at a time, newest first: when it started, its id, flow and version, how far the applicant got (the current step, or when it was submitted), the case it answers (a request for information), the run it belongs to or "no run", and its status (in progress, submitted, withdrawn or expired). One still in progress offers Copy link (submissions:write). GET /collection-submissions takes subjectId, workflowRunId, caseId, status (repeated), limit and offset and returns { items, total }; without subjects:pii the personal data in each submission's data is masked. Applicant tokens cannot list. POST /collection-submissions/:id/link (submissions:write) issues a new applicant link to one in progress, never outliving a request's expiry, and POST /collection-submissions/:id/withdraw withdraws one the applicant no longer needs (not the form a run is waiting for); its link then opens a "no longer needed" screen.
GET /subjects takes search, kind, status, riskScoreMin, riskScoreMax, country, lastDecision, limit and offset. search matches externalId exactly (case-insensitive) or as a prefix of externalId, data.name or data.legalName; the expression indexes on those columns cover the prefix form, a contains match would scan. country matches the subject's country (the first non-blank of data.country, countryCode, nationality, countryOfResidence and address.country) case-insensitively, on an expression index; lastDecision (approve, reject, manual_review or none) the outcome of its newest decision. The response is { items, total, totalIsEstimate } (total ignores paging and stops at 100,000, where totalIsEstimate turns true). expand=openCases adds the number of open and in_review cases of each subject and expand=lastDecision its newest decision as { outcome, createdAt } (null when there is none); each is one extra query for the whole page, both are repeated params, and both, like the lastDecision filter, need cases:read (403 otherwise). GET /subjects/export takes the same filters and returns the matches as CSV (X-Export-Rows, X-Export-Truncated), masked like the list and audited as subject.exported.
GET /decisions?subjectId= (cases:read) pages the subject's decisions newest first: from/to bound createdAt (inclusive), limit (default 50, max 500) and offset page, and the response is { items, total }.
GET /subjects/:id adds lastSeenAt, the time of the newest event about the subject. PATCH /subjects/:id (subjects:write) takes data as a JSON merge patch (RFC 7396: objects merge, null removes a key), tags (the whole list) and status, and writes subject.updated with the paths that changed (data.address.city, tags, status), never their values.
The subject timeline
GET /subjects/:id/timeline (subjects:read) merges the subject's history into one stream of TimelineEntry ({ id, kind, at, title, summary?, resourceType, resourceId, actor?, data? }) newest first, from the entity tables and the audit log; nothing is stored. Query: from, to (bounds on at), kinds (repeated), limit (default 50, max 500) and cursor. The response is { items, nextCursor }: pass nextCursor as cursor for the entries after the page, until it is null. Entry ids are <kind>:<resourceId>[:<suffix>]. The subject page filters it by kind and loads 50 entries at a time, as far back as the history goes.
| Kind | Needs | Entries (title) | data |
|---|---|---|---|
decision | cases:read | Decision: <outcome> (+ (manual)), summary = score and reason count, actor = decidedBy | the decision |
case | cases:read | Case created, Picked up (firstResponseAt), Decided, Closed: <reason>, SLA breached | the CaseView (with slaState) |
note | cases:read | Note by <authorId>, summary = first 120 chars | the note (has caseId) |
run | runs:read | Run started: <definitionKey> and, once terminal, Run <status> at finishedAt | the run with context: { keys } and caseIds |
submission | submissions:read | Submission started: <flowKey>, Submission submitted: <flowKey> | the submission |
document | documents:read | Uploaded <fileName> and, once clean/infected/rejected, Document <status> | the document (has caseId) |
event | events:read | Event: <type> at occurredAt, summary = amount and currency | the event |
audit | audit:read | the action, for the subject and its cases, runs, submissions and documents (100 refs at most) | the payload, as on GET /audit-events |
A kind appears only when it was requested (all kinds when kinds is absent) and the caller holds its permission; the integration role, for instance, gets runs, submissions and events only. Run data never carries context values, only the key names, and caseIds is filled only for callers with cases:read. Decision, submission and event data is masked and redacted as on the entity routes, and audit data as on GET /audit-events (see below). Audit actions that an entity entry already represents (case.created, case.decided, case.closed, case.sla.breached, decision.*, document.created/uploaded/scanned/rejected, collection.submission.created/submitted, workflow.run.*) are left out of the audit kind so nothing appears twice; workflow.rules.evaluated is not one of them. Each source reads a page of rows at or before the cursor before the merge. When a source fills its page, the entries from the instant of its oldest row on wait for the next page, so rows sharing one instant (audit events of one transaction) are never split; when that leaves the page short, the sources are read again with a larger fetch, up to 500 rows each.
Runs
Operate › Runs lists the tenant's workflow runs, newest first, with search (run id prefix, correlation id, subject name or external id), status, workflow, outcome, trigger and time range filters (the last 24 hours by default) under the completed, waiting and failed counts and p95 duration of the runs the filters match, with the share of the decided ones decided automatically in the header. Live reloads it every 5 seconds; Start run → (runs:start) runs a published workflow for one subject, with an optional correlation id, and records the trigger console.
A run's page lists the steps in definition order from the trace the worker records: each step's status, duration, a summary (score 40 · 2 hits → review, waiting for the applicant, → review) and its input and output, from its latest visit (a step the checks repeated after a request for information shows its round). Without definitions:read the page lists the steps the run visited; a run started before the trace existed shows steps derived from its evaluations, app calls and context. The rail holds the trigger, the correlation id, the Temporal ids, app attempts, the error, the run's Forms (its collection submissions and request follow-ups, with Copy applicant link for one still in progress, submissions:write), a timeline (admins also see the Temporal history), the run's cases and decision, the run it replayed and its replays, the webhook deliveries of its events (webhooks:read), and for admins (ops:read) a link to the trace of its start when TRACE_URL_TEMPLATE is set (environment).
Admins (runs:write) operate on a run from its page:
- Cancel run, while the run is active, asks for a reason. The workflow stops without a decision, the run becomes
cancelledwitherrorcancelled: <reason>, its open cases close withrun_cancelledand its forms in progress (its own and request follow-ups) are withdrawn. - Replay from step ▾, once the run has finished, lists the steps its trace recorded. A replay is a new run (trigger
replay) of the same workflow version for the same subject and correlation id, starting at the chosen step with the context the run had when it last started it; the steps after it run again, so forms are asked for again, cases open again and the replay emits its own decision. Cancel an active run before replaying it.
GET /workflow-runs (runs:read: admins, analysts and the integration role, for every run of the tenant) lists runs newest first by startedAt as { items, total, totalIsEstimate } (the count stops at 100,000, where totalIsEstimate turns true). Every filter is optional: subjectId, status and outcome (repeated params), definitionKey, and from/to (inclusive bounds on startedAt). outcome is the outcome of the decision the run emitted, read through workflow_runs.decision_id, so only completed runs match it. search matches a prefix of the run id in its hyphenated form (51a3b2c0, or the full id) or the run's subject the way GET /subjects?search= does; both are index lookups, a contains match is not offered. Each item adds durationMs (finishedAt, or the time of the request for an active run, minus startedAt) and outcome (null until the run emits a decision), and expand=subject adds the subject summary as on GET /cases. Every run carries trigger (api, console or replay), filtered with trigger (repeated params), correlationId (the caller's own id from POST /workflow-runs, at most 200 characters, which search also matches exactly), and replayOf ({ runId, stepId }) on a replay. With subjectId alone the route returns that subject's newest runs, which the API client's workflowRuns.listBySubject returns as an array. GET /workflow-runs/summary (runs:read) takes the list's filters and summarises the runs they match: { total, byStatus, p95DurationMs, autoDecidedPct }, with every status in byStatus, the 95th percentile (percentile_cont) of finishedAt - startedAt over the runs that finished (null when none has) and the share of the runs with a decision decided without a reviewer (null when none has one). Both are computed in SQL on each request.
The worker records a run's trace in workflow_run_steps, one row per visit of a step (a loop brings a step back as visit 2; a request for information repeats the checks as round 2), with the run context the visit started from, which a replay starts with and no route returns. The routes on one run:
| Route | Permission | What it does |
|---|---|---|
GET /workflow-runs/:id/steps | runs:read | The trace in start order as { items, total } (500 at most): status, timings, summary, input and output (values redacted without runs:context), the error of a failed visit and refs to the evaluation, invocation, callback, submission, case or decision it wrote. |
GET /workflow-runs/:id/related | runs:read | cases and decision (cases:read), replayOf, replays, traceUrl (ops:read) and deliveries (webhooks:read), the newest 50 webhook deliveries of the run's events. |
POST /workflow-runs/:id/cancel | runs:write | { reason }; cancels the workflow, closes the run's open cases, withdraws its forms; audits workflow.run.cancelled. 409 run_finished once the run has finished. |
POST /workflow-runs/:id/replay | runs:write | { fromStepId }; 201 with the new run. 409 run_active before the run has finished, step_not_reached for a step its trace does not hold. |
Approvals
Operate › Approvals, the inbox of definition versions waiting for approval (definitions:approve to decide, definitions:write to follow your own requests), is described with the approval settings and four-eyes in Admin console: Define.
Connect
Connect › Apps opens with apps:read, which admins and analysts hold; installing, configuring and testing an app need apps:write and uploading, publishing and approving versions apps:publish, which admins hold. Webhooks needs webhooks:read and API keys users:manage.
- Apps lists the catalogue (
GET /apps): the platform's apps and the organization's own, under Installed and Not installed, each with its version, publisher, category and vendor, its state (Enabled, Disabled or Not installed), the last hour's calls, failures and p95, an open circuit, and tags for an available update, missing secrets or an upload not yet published. A search box matches the name, the vendor and the capabilities, and a filter the publisher. Upload app (apps:publish) takes a module file, shows the manifest the platform read from it (actions, secrets and the hosts it calls) and publishes it, or opens an approval request when the organization requires approvals for apps. On a deployment without an app runner or object storage the page says apps are off. - An app's page heads with its state, publisher, health, an open circuit (with when it opened) and a module the operator blocked, then the last hour's figures. Install (or Installation once installed,
apps:write) picks the version (installed, latest, and the publisher when the app has versions of both), lists the hosts that version calls to be approved (Approve these hosts; an upgrade marks the new ones), and holds Enabled for this organization, the Configuration form generated from the version's config schema and the Secrets of the version (Set value…, Rotate…, Revoke). One save installs, configures or moves the install (Install app →, Save changes →, Upgrade to vX →): for an upgrade the values entered for the new version's secrets are stored first, so the app stays enabled throughout, and a new install that needs secrets is created off, given them, then turned on. Below come Actions (each with its timeout, retries and whether it is idempotent or completes through a webhook, and Try, which calls it once with the stored configuration), the invocations a page at a time (GET /app-invocations?appName=), the newest callbacks (GET /app-callbacks?appName=), Versions (status, publisher, size and memory; the organization's own with Publish, Approve or Reject an open request, and Withdraw), Used by (GET /apps/:name/usage) and Uninstall, which says how many runs wait on the app's callbacks and offers to fail them. The rail has the vendor's documentation and pricing note, the capabilities, the approved hosts, the inbound webhook endpoint the vendor calls and the installation's details, with a link to the app's events in the audit trail (Apps). - Webhooks lists the organization's outbound webhook endpoints with their events, status (Active, Paused or Failing) and last delivery. New endpoint takes the URL, a description and the events from the catalogue, then shows the signing secret once. Opening an endpoint shows its settings with Send test ping (the answer in a banner), Edit, Rotate secret (the new secret once; the old one signs too for 24 hours) and Delete, and its deliveries newest first: time, event, the resource (linking to the run), the state (
queued,502 · retrying,given up after 5…) with the last error, the next attempt, the body on demand and Retry for a failed or given-up one. A filter shows all, failing, pending or delivered ones. The page opens on an endpoint from/connect/webhooks?endpoint=<id>, the link a notification or Home carries. - API keys lists the organization's service users (
GET /service-users,users:manage): purpose, role, keys with their expiry and the last call the API answered for each. New service user → creates one (integration role by default) and offers its first key; New key issues a personal access token shown once, with 30, 90 or 365 days to live; a key can be revoked and a user disabled. WithoutZITADEL_MANAGEMENT_PATon the API, or withoutusers:manage, the page links to the Zitadel console and lists recent machine callers from the audit trail instead.
POST /apps/:name/test (apps:write) calls one action of the installed version once with the install's configuration, secrets and hosts, enabled or not: one attempt, no circuit, nothing recorded but the app.tested audit event. Its answer is { status, output?, externalId?, error?, durationMs, usage? }: success, pending for an asynchronous action (its webhook will find no run), error or timeout. An action the version does not declare is a 400. POST /apps/:name/secrets/:secret (apps:write) seals { value } with SECRET_STORE_KEY (AES-256-GCM, bound to the tenant, the app and the secret's name) and answers { secret, storedAt }; the value is never returned. DELETE on the same path removes it. Without the key both answer 503 secret_store_unavailable. The routes behind the page are listed in Apps: reference.
The workers keep a circuit per tenant and app: after five calls in a row that ran the app and failed or timed out, calls are refused at once (app_circuit_open, 503) for 30 seconds, then one trial call goes out; the first that succeeds closes the circuit. Changes are recorded and audited (app.circuit.opened, app.circuit.closed), GET /apps/health lists the open and half-open circuits as circuits, and Home's attention list names them.
The service-user routes (users:manage) proxy Zitadel with the management user's PAT: GET /service-users (the machine users of the tenant's organisation holding a project role, with their keys and lastCallAt), POST /service-users ({ name, purpose?, role }), PATCH /service-users/:id ({ purpose?, enabled? }), POST /service-users/:id/keys ({ expiresAt }, within 730 days; answers { id, token, expiresAt }, the only time the token is returned) and DELETE /service-users/:id/keys/:keyId. Machine users without a project role (the directory and management users) are never listed or touched. The API checks a key against Zitadel at most once a minute, so a revoked key or a disabled user is refused within a minute. lastCallAt comes from principal_activity, which the auth hook writes at most once a minute per caller and API instance; the same table records when people were last active.
Audit
- Trail (
audit:read) pages throughGET /audit-events, 50 events at a time, filtered by action prefix, actor, resource and time range (all in the URL). Saved views ▾ names the current filters for you (/audit-views, on every device). An event opens in a side sheet (/audit/<eventId>) with the request that wrote it (request id and route; IP and agent for admins), its payload, the other events of the same resource and the case, decision, run and subject it concerns; ↑ and ↓ step to the next event under the trail's filters, across pages. - Exports (
audit:export) previews the row count and size of an export (GET /audit-events/count), downloadsGET /audit-events/exportas CSV or JSON Lines under the default or a chosen file name, checks the file's SHA-256 against the manifest line, and lists past exports with their digests. Scheduled exports lists the schedules with their last run and next run; Schedule this export saves the form's filter (without its time range) on a preset (hourly, daily or weekly at 02:00 UTC) or a cron, and Runs lists each run with a download link for its file. The API redacts and masks payloads for the caller (Audit trail).
Settings
Settings needs tenants:settings (admins), except Queues, which reviewers holding cases:assign reach from the workbench. General, SLA, Tenant and Branding read the current settings, then save the whole object with PUT /tenants/me/settings, which writes a tenant.settings.updated audit event with the dotted paths that changed (see the approval settings and the origin allow-list).
| Page | What it holds |
|---|---|
| General | Your preferences (also under Preferences in the avatar menu, for everyone: time zone, date format, landing page and table density) and the workspace defaults (console): workspace name, time zone, date format, landing page, setup checklist and the idle lock (5 to 480 minutes, 30 by default); and what people see: run context for admins only or for everyone who opens it (showRunContextTo), and the kinds of personal data masked for analysts (maskForAnalysts). |
| Team | The organization's people holding a role (GET /team/users, users:manage): role, status (active, invited, deactivated), last activity and open cases. Invite person sends an invitation through Zitadel; each row changes the role, deactivates or reactivates, or resends or cancels an invitation. Your own row has no actions. |
| Roles | The permission matrix of the admin, analyst and integration roles from GET /roles, read-only, with a CSV export. |
| SLA | The default case SLA (cases.defaultSlaHours, up to 2160 hours), business hours (days, opening hours and time zone the SLA clock runs in), when a case turns due soon (cases.warnAtPct, the share of its SLA left), the at-breach switches (escalate priority, notify the assignee, notify who assigns cases), the SLA hours the published workflows' create_case steps set, and the last 30 days' figures from GET /stats/sla, for the tenant and per workflow (SLAs). |
| Queues | The system queues (fixed name and filter; "Claim next" on or off) and the tenant's queues: create, edit, delete and reorder (/case-queues). The editor takes a name, a default sort, filter conditions, who sees the queue (everyone, admins or only you) and whether "Claim next" uses it, and previews what the filter matches now. Not audited. |
| Reason codes | The reason-code catalogue reviewers pick from when they decide (Reason codes). |
| Tenant | Identity and state (GET /tenants/me: provisioning date, region, status, counts), Approvals required, Four-eyes and the kinds approvals apply to (approvals), the embed origins (embedOrigins, at most 20), the tenant's collection flow URL (collection.flowUrl, https; links point there instead of the deployment's) the lifetime of links for flows that set none (collection.defaultLinkTtlSeconds) after how many days forms nobody saved expire, cancelling a run that waits for one (collection.abandonAfterDays), and the Languages applicants may read forms in (collection.locales, language tags; the first is the one flows are written in), data and retention, and the danger zone: suspend or resume the organization and revoke collection links. |
| Branding | How the collection terminal looks for the tenant's applicants and whom they contact (branding): display name, logo (and one for the dark theme) and its text alternative, brand color, color scheme, corner radii, border width, heading weight, font, support email and page, privacy notice, terms and the return address after an approval, with https addresses only. Empty values take the deployment's defaults; a name or logo here hides the deployment's name and logo, and either support contact hides both of the deployment's. A preview shows the header and a button. |
The preferences in effect are yours over the workspace defaults over the built-in ones; the console fetches them after sign-in (GET /me/preferences, GET /tenants/me) and keeps a copy in the browser so they apply as it loads.
Operator actions
POST /tenants/me/suspend { reason }, POST /tenants/me/resume and POST /tenants/me/revoke-links need tenants:settings and a sign-in under five minutes old: the console sends the ID token of the current session in X-Aletheia-Reauth, and the API checks its signature, its subject and its auth_time (401 reauth_required otherwise). The danger zone asks you to sign in again when your sign-in is older than that. While a tenant is suspended every route but GET /me, GET /me/preferences, GET /roles, GET /tenants/me, GET /health/summary and resuming answers 403 tenant_suspended with the reason, and collection links do not open; runs already under way keep running. Revoking links makes every link issued until then answer 401 ("this link was revoked"); forms stay in progress and new links can be sent. Other API instances apply a change within five minutes. They are audited as tenant.suspended, tenant.resumed and tenant.links.revoked.
Masking and redaction by caller
The API decides what a response shows from the caller's permissions; the console renders what it receives. Stored data and request bodies are never changed.
| Permission | Held by | Without it |
|---|---|---|
subjects:pii | admin | Personal data is masked by key, at any depth: emails (priya.n@…mail.com), phone numbers (+91 98•• ••• 412), identity and account numbers (Z61••••3), dates of birth (1990-••-••) and street address lines (••••). Names of people and organizations, countries and cities stay. |
runs:context | admin | Every value becomes "[redacted]" (keys and array lengths stay) in: run context; case context; app invocation input and output; app callback output; the output of an app's test call; rule evaluation data; the details of rule results on decisions and evaluations; every value under a context, data, details, input or output key of an audit payload. The error text of app invocations, app callbacks and test calls becomes "[redacted]"; a run's error stays readable, because the engine records only the step's app and action there and keeps the vendor's reply on the callback record. Outcomes, scores, reasons, statuses and timings stay. |
Masking applies to data of subjects (POST /subjects, GET /subjects, GET /subjects/:id, the subject of GET /cases/:id), to attributes and the email column of events (ip, deviceId and cardFingerprint are returned as stored), to data of collection submissions read by anyone but the applicant, and to the same records inside timeline entries. Redaction applies to POST/GET /workflow-runs (every row of the list), GET /workflow-runs/:id, GET /workflow-runs/:id/evaluations, GET /app-invocations, GET /app-invocations/:id, GET /app-callbacks, POST /apps/:name/test, GET /decisions, and the run and decisions of GET /cases/:id. The run context holds the signed collection-flow link, which would let a reader act as the applicant. Error text of app invocations and callbacks is redacted like their payloads, as it can quote what the vendor answered about the subject; GET /apps/health returns none.
Audit payloads get both rules by key, at any depth, on GET /audit-events, GET /audit-events/:id, GET /audit-events/export and the audit entries of the subject timeline: masked without subjects:pii, and redacted under context, data, details, input and output without runs:context. Among today's writers that redacts the details of every rule result in workflow.rules.evaluated; ids, keys, versions, outcomes and counts stay, so the trail can still be linked and filtered. Audit trail lists each action's payload and how its fields are classed.
A case's context is a copy of the run context taken when the create_case step opened it, so it carries the same submission data, app output and signed links, and gets the same rule (redacted without runs:context, masked without subjects:pii) on every route that returns a case: GET /cases, the case of GET /cases/:id, POST /cases/:id/assign, /claim, /decide and /close, POST /cases/claim-next, and the case entries of the subject timeline. search still matches context.subjectName on the server. A client that needs the subject's name reads expand=subject on GET /cases or the subject of GET /cases/:id, not the context; the console shows redacted and masked values as hidden for the role.
GET /workflow-runs/:id/history stays admin-only. Free text is returned as stored: decision and rule reasons (a list lookup's reason quotes the value it found), case notes, approval comments, close reasons and file names, including where they appear inside audit payloads.
Smoke
scripts/smoke-cases.sh (step 16, sourced from smoke-verification.sh) sets cases.defaultSlaHours = 1, runs kyb-onboarding to manual review, checks the directory (or warns and skips when GET /users is empty), asserts slaState = ok and dueAt about one hour after createdAt, claims the case, checks queues and counts, adds and edits a note, attaches a document, decides, waits for the close with run_completed, verifies the audit trail, closes an undecided case explicitly and restores the settings. Step 17 approves a named merchant through hello-world, rejects its kyb-onboarding case by hand, then checks subject search, the timeline (order, kinds, paging, no case.decided duplicate, the integration role's reduced view) and that the integration role sees every run context value as [redacted] while the admin sees the link.