Appearance
Admin console: Define
Define is the admin console's authoring area: Rules, Rule sets, Workflows, Collection flows and Lists, each with a list page and an editor, plus a history page per definition and the approvals inbox under Operate. The editors are generated from JSON Schema the API serves, so a new step type, rule type or field type shows up in the console without UI work. This page documents those schema and validation endpoints, how the editors use them, and the version and approval routes every definition kind shares; the rule reference covers what each rule type evaluates.
Reading needs definitions:read (lists: lists:read), which analysts hold, so they see Define read-only. Editing needs definitions:write, publishing definitions:publish and approving definitions:approve, which only admins hold; backtests need backtests:run, which both roles hold. The integration role sees none of it.
Packages:
@actanomos/aletheia-core—StepTypeDescription,FieldTypeDescription,FormUiHints,DefinitionIssue,ValidationReport(builder.ts), and the step, field and flow schemas the descriptions are derived from.@aletheia-dev/workflow-engine—validateWorkflowBody/validateWorkflowInput(pure,validate.ts) andstepOutputs, the context keys each step writes.@aletheia-dev/collection-flow—validateFlowBody/validateFlowInput(pure,validate-flow.ts).@aletheia-dev/definitions— the normalized diff behind the diff route.apps/api— the catalogues (src/builder/step-types.ts,src/builder/field-types.ts), the schema and validation routes (src/routes/builder.ts;api.builder.*in the API client) and the version and approval routes (src/routes/governance.ts).apps/admin-console—src/areas/define(rules, rule sets, lists, history, policy packs),src/areas/workflowsandsrc/areas/flows.
Editing and publishing
Every definition kind works the same way:
- Save updates the stored version in place while it is a draft (
PATCH <prefix>/:key/versions/:version) and otherwise creates the next draft version (PUT <prefix>/:key); an older version opened with Restore as draft always becomes a new one. A version is fixed once approval is requested or it is published. While the stored version is a draft, edits also autosave in place two seconds after the last change, unless the editor's own checks or the server's validation report an error. Each version has a change note (one line, shown on the versions list) and records who last saved it (updatedBy). The rule, workflow and collection-flow editors also keep unsaved edits in the browser and offer to restore them on the next visit. - The publish control reads Publish → or, when the tenant requires approvals (or a publish answered
409 approval_required, or the user lacksdefinitions:publish), Request approval →, which takes a note for the approver. Unsaved edits are saved first. A publish refused for unpublished references lists every issue. - History opens the definition's history page.
- Each editor has a form and a JSON view of the same body; both round-trip.
Rules
Define › Rules lists the latest version of every rule (GET /rule-definitions) with its type, severity, weight, version, status, where it is used (rule sets and workflows, from GET /definitions/usage) and its hits over the last 30 days (Activity), filtered by status, type, severity and tag. New rule → and Import policy pack need definitions:write. A rule's page (/define/rules/<key>) holds:
- Definition: the key (new rules only: lowercase letters, digits,
_or-, fixed afterwards), name, type, severity (info,warn,block), weight, tags, enabled, the type's config and the description. The config form is generated from the type'sconfigSchema(GET /rule-types) anduiHints, whosepickersname a picker per field, the ones step types use (Step types):list_lookup'slistKeyis the list picker, and anapprule'sapp,action,inputandresultPathare the install picker, the action picker, the input editor of the chosen action and a picker of the paths it answers (The input editor for app calls), as are those of itsfallback(The fallback of an app call). Anexpressionfield is the CEL editor with a cheat sheet (data,subject,subjectId,now,inList,hasPath,getPath,daysSinceand the pitfalls). Changing the type reseeds the config from the schema's defaults. The Change note describes the version. - Test against a subject: a dry run of the saved version, and of the published one when it differs, in one call against a stored subject or inline JSON (
POST /rule-definitions/:key/dry-runwithversions;definitions:writeorbacktests:run, so analysts can run it), each result with a sentence explaining it. Apps are not called in a dry run (apprules come backskipped), and result details are redacted as a run's are for callers withoutruns:context. - Backtest (
backtests:run): replays the saved version over a window of 1 to 180 days (30 by default) and up to 5000 subjects, optionally under a published rule set (POST /backtests, polled every 2 seconds), then shows outcome counts, changed outcomes, overlap with other rules and sample hits. Hits per day charts the last 15 days in the tenant's time zone: the hits every evaluation recorded, or, once a backtest of the saved version completed, its sample's recorded hits against the version's, day by day (GET /stats/rules/:key/hits). Previous backtests lists the rule's earlier runs. - Validation in the side panel: the editor's own checks, then
POST /rule-definitions/validate(see rule and rule set issue codes), whose warnings, such as a list not changed in over 30 days, never block a save; and Used by.
See testing a rule before it decides anything.
Rule sets
Define › Rule sets lists every set with its use case, policy and rule count. A set's page edits its key, name, use case and description, the rules in evaluation order (each with enabled, a weight override and shadow; add, remove, move up and down) and the aggregation policy: Max severity, Sum of weights (clamp and contiguous bands, with Normalize bands) or First match (with a default outcome). The Simulation panel works from the draft without evaluating anything: each rule's effective weight, the highest possible score and, under Sum of weights, the band a given score falls in. The side panel validates the set with POST /rule-sets/validate: a rule that does not exist is an error, a rule without a published version a warning. See rule sets and aggregation.
Lists
Lists are live data, not versioned definitions: rules read the current entries. Define › Lists (lists:read) shows every list with its kind (string, email, country, ip_cidr, number) and entry count; New list → and Edit details set the key, name, kind, case-insensitive matching and description (PUT /lists/:key; lists:write). A list's page searches its entries on the server, 100 a page, adds entries (one value per line, optionally value,reason), imports a CSV (value,reason,expires_at; POST /lists/:key/import) and removes the selected entries. An expiry can only be set through the CSV import. See lists.
Workflows
Define › Workflows lists every workflow with its trigger, version, step count, runs per day and the share decided automatically over the last 30 days (Activity). New workflow → and Import policy pack (a pack file, imported as drafts) need definitions:write. The editor shows the definition as a Graph, as a list of Steps (reorder, delete) or as JSON, with Validate, Save, the publish control and History in the header.
- Palette: the step types from
GET /workflow-step-types; clicking one adds it below the selected step. Problems, below it, lists the validation issues; clicking one selects its step. - Properties: with no step selected, the workflow's key, name, description, trigger and entry step; with a step selected, its form (step id, name, next step and the type's own fields) or its JSON (Advanced), Delete step, Make entry step and the keys the step writes.
Step types
GET /workflow-step-types returns one entry per member of core's WorkflowStep union:
configSchema: the step's own fields as JSON Schema (z.toJSONSchemain input mode, so fields with defaults are optional).id,name,nextandtypeare left out: the editor manages them itself.dataPaths: the context paths the step may read, as hints for the path picker.outputs: the context keys the step writes, with<outputKey>standing for the step's configured output key. Later steps' path pickers offer them.terminal: the step ends the run and takes nonext.uiHints: field order, labels, help and pickers.
| Type | Reads | Writes | Pickers |
|---|---|---|---|
evaluate_rules | subjectId, subject.*, submission.*, rules.*, <outputKey>.* | <outputKey> (default rules) | ruleKeys: rule, ruleSetKey: ruleSet |
call_app | same | <outputKey>, <outputKey>ExternalId (async calls) | app: app, action: appAction, inputMapping: path (values), input: appInput |
wait_for_collection | nothing | <outputKey> (default submission), …Id, …Url | flowKey: flow |
create_case | nothing | <outputKey> (default manualDecision), …CaseId | none |
emit_decision | rules.*, <outputKey>.* | nothing; terminal | none |
branch | subjectId, subject.*, submission.*, rules.*, <outputKey>.* | nothing | conditions[].when: condition, conditions[].next: step, default: step |
Picker keys are dotted paths into configSchema; [] steps into array items. call_app names its four pickers again under fallback. (fallback.app, fallback.action, fallback.inputMapping and fallback.input). The picker values are the ones FormUiHints documents: list, rule, ruleSet, flow, app, appAction, appInput, appResult, step, path, condition, cel. condition and appInput edit a whole object, the others one value. Rule types describe their config forms the same way: GET /rule-types answers each type's uiHints with fieldOrder, labels, help and pickers (field → picker, the same names); list_lookup has { listKey: 'list' } and app{ app: 'app', action: 'appAction', input: 'appInput', resultPath: 'appResult' }, with the same four under fallback..
evaluate_rules needs exactly one of ruleKeys and ruleSetKey. JSON Schema cannot express that refinement, so the form allows both; the validate endpoint reports it as a schema issue.
How the editors use the schemas
- Schema-driven forms. Rule configs (
/rule-types), step properties (/workflow-step-types) and app configurations (an install's config schema) render with@rjsf/corefrom the served JSON Schema;uiHintsbecome the rjsfuiSchema(order, labels, help, widgets). - Pickers. Fields with a picker hint render a widget backed by the API instead of a text input: lists, rules, rule sets, flows, the tenant's installs (listed as
screening · http-api 0.5.0, the install's name, its app and the version it runs; enabled ones first, the others marked "secrets missing" or "disabled"; a step or rule that names a former name of an install keeps it, listed asscreening-old · former name of screening), the actions of the version the chosen install runs, the chosen action's input and the paths its output gives (below), steps of the current workflow, and context paths. The path picker offers what the selected step can read (POST /workflow-definitions/context-pathson the draft, refreshed after edits), an app call's output down to the answers to the questions it asks (Context paths), each with its type and the step that writes it, marked when only some paths to the step write it; the step type'sdataPathsfollow as hints. - Optional groups. An object a step or a rule may leave out whose own fields are required (a
fallback) stays out until Add fallback, which shows its fields, and Remove fallback drops it whole; a new step or rule starts without it. - Condition builder.
conditionfields edit a{ path, op, value }with a path picker, an operator (equals, does not equal, greater than, at least, less than, at most, is one of, exists) and a value input typed by the operator. - CEL editor.
celfields use a CodeMirror editor. - Validation. The editors post the draft to the validate endpoint 400 ms after an edit and mark the steps and fields the issues name; errors block publishing, warnings are shown inline.
The input editor for app calls
The literal input of a call_app step and of an app rule (the appInput picker) is edited for the action it calls. The editor reads the install and the action chosen beside it and renders the action's input schema (actions[].inputSchema of the version the install runs, from GET /apps) with the same schema form, which Edit as JSON swaps for the JSON editor and back. With no install or action chosen, while the installs load or when they cannot be read, or for an action whose input schema is open, it is the JSON editor, with the reason above it. A note names the fields inputMapping sets from the run, which replace what the form holds; those fields are not required in the form. For an action that takes run data in some fields only (its mappable, such as http-api's variables), the note names a mapped field outside them and says the call fails.
For an install of the platform's http-api (App catalogue) the editor also knows the install's configuration, for both its actions: request, which answers what the vendor sent back, and start, which waits for the install's webhook:
- the install's endpoint, shown as the base of the path the step gives, and its method, headers and query parameters, shown as hints beside the step's own;
- a body editor that suits the content type: JSON, text, the key-value lines of a form, the parts of a
multipart/form-databody or the one document of anapplication/octet-streambody. A part is text, with its own content type when it needs one, or a Document named by one of the step's variables (<<passportId>>, offered from the step's variables) whose value the run gives as a document's id, with the file name the vendor sees when it is not the document's own. A binary body names its document the same way and goes under the document's own content type unless the step's headers name one; the host sets a multipart body's content type itself, with its boundary. A document goes only while the install's documents switch is on, and only one of the run's subject; - for
start, The vendor's session id: where the vendor's answer names the session the install's webhook will complete, The answer's JSON body at a dot path (data.id), The answer's header or This text, written with variables (kyc-<<applicantId>>); and Wait for the webhook, an amount in seconds, minutes, hours or days, the action's longest (seven days) when empty. The head of the editor says whether the install takes webhooks, and astartstep has no response options: the webhook's body is its output; - Variables, a row per variable the request uses as
<<name>>: Fixed writes the value to the input'svariables, From the run writesvariables.<name>toinputMappingwith a context path from the path picker, with a hint naming the built-ins (<<$idempotencyKey>>, the call's key, the same on each attempt, and<<$callbackUrl>>, the install's webhook URL, among them), where steps may use each of the install's secret variables and which stay in its own query, headers and auth; - for
request, the response options: the statuses answered besides 2xx (acceptStatuses), the paths to keep (pick) and how the body is read (responseAs); - Request checks, as the step is edited, for what the app refuses when the call runs: a variable nothing defines or maps, a secret variable of the install where its Steps may use it in does not list the place (the path, a query parameter's or a header's value, the body or a text part), in a name (a query parameter's or a header's key, a form field's or a part's name, a file name) or outside a string in a JSON body, a step variable (fixed or from the run) named like one of the install's secrets, a built-in out of its place, a header or query parameter the install's auth or the host keeps, a body on
GETorHEAD; a document while the install's documents are off, a document named otherwise than by one variable or by a secret, a multipart body with no part to send, a part without a name, a part's content type that is no media type or uses a variable, and aContent-Typeheader of the step's beside a multipart body; forstart, an install that takes no webhook (the call fails before anything is sent), a secret in the session id, and a session id written as text that no variable the run fills or<<$idempotencyKey>>changes, since an install holds one session per external id and a second call would fail; - Paste from Hoppscotch…, which turns a pasted Hoppscotch request into the step's input: the endpoint made relative to the install's endpoint, or to a leading variable such as
<<baseUrl>>, the method, the parameters, the headers and the body (a multipart body's text entries, with their own content types), the request's variables as literal variables (whichinputMappingoverrides), and its auth only asinheritornone. It lists what it could not take: pre-request and test scripts, OAuth 2.0 whatever the grant, GraphQL, a request to another host, and any other auth, which belongs to the install; and the files Hoppscotch's export leaves out, a multipart body's file entries and a binary body, which the author names as documents afterwards.
For an install of one of the platform's model apps (AI models) the editor asks for what generate and decide take, under a line naming the install and the model it asks:
- for
generate(Asking for an answer), Instructions (optional), given after the install's own, the Prompt and The answer: Answer as text, an object of a JSON Schema, a list of such objects, one of the options (one per line) or any JSON. An object or a list starts from a schema with every property required and no other allowed, and the schema is checked as it is written: what the host would refuse is listed under it, each line with the JSON Pointer of the part it is about; - for
decide(Asking for decisions), What the questions are about: The step's variables, which the model reads as one JSON object, A text written with variables (stateTemplate), Data from the run, a context path the input mapping puts instateas it comes, or A JSON value (state); and the Questions, 32 at most, added with Ask yes or no, Ask one of the options and Ask a score on levels. Each has its Id, the segment of the paths that read its answer, its Answer type, What to decide and what the type takes: Yes from a probability of (0.5 when left empty) with Yes when (optional) and No when (optional), the options with what each means, or the Levels, from the lowest (0); - Variables, Fixed or From the run as for
http-api, used as<<name>>in the instructions and the prompt, or in the questions and a text state; - Model (optional), which suggests the models the install allows the action, first the one asked when the field is empty (for
decidethe install's decision model, else its model; forgenerateits model), and forgenerateMost tokens (optional), the install's when empty; - checks, as the step is edited, for what the call would refuse, such as no question, an id that is not a letter then letters, digits,
_or-, a choice with fewer than two options or one twice, an empty state or prompt, a<<name>>no variable holds, or a model the install does not allow; - what the answer gives: the paths later steps read under the step's output key, such as
ai.answers.fraud.probabilityor an object answer'ssummary.output.verdict(a rule's result path reads them without it), and for a decision how a branch routes on it, going on when the first question's probability is at least 0.8 and opening a case by default, so that an answer without a probability goes to a person (Routing on an answer), or which value fails a rule.
The editors of http-api and the model apps come with the platform's versions only: a version of the organization's own gets the form of its action's input schema, whatever the app's name.
The rule form (Define › Rules) uses the same pickers: an app rule picks its install and action and edits its input with this editor, the installs read from GET /apps when the form first needs them. Its Result path offers the paths the chosen action answers, those its output declares and those its input shapes (answers.fraud.value for a decide that asks fraud), and takes any other.
The fallback of an app call
A call_app step and an app rule may name a Fallback: another install, its action and that action's input, called when the step's or the rule's own install cannot answer, because its circuit is open (the call never left) or because the vendor's outage outlasted the call's retries. It is never called after a timeout or another failure that may have reached the vendor when the action is not idempotent. The fallback's install picker lists every install but the step's or the rule's own (one named by a former name is the same install; a fallback that names it stays visible, marked "the install it falls back from", until the author picks another), its action picker the actions of the version the chosen install runs, and its Input mapping and Literal input are edited as the step's own, apart from them: the input editor reads the fallback's install and action, and its note names a mapped field the fallback's action does not take run data in (mappable), with which the call fails. When the fallback's action takes the same input as the step's or the rule's own, as one model app's does another's (AI models), Copy the input above copies that input and its mapping into the fallback's, the model left out unless the fallback's install allows it. A rule's fallback also takes a Result path, offered from what its action answers as the rule's is, and a Fail when value for its output, the rule's own when left empty, and its action must answer synchronously. The fallback is reviewed and published with the definition, so it calls no install nobody approved; the validate endpoint reports an install that does not exist, an action its version lacks, or an input mapping its action refuses. Its output lands under the step's outputKey. When the fallback fails too, the step fails with the first install's error, the fallback's in its details. The canvas node reads vendor-a · screen · fallback vendor-b, and the run page links the fallback's install and says when a call went through the fallback.
The visual editor
The graph is a projection of the body, not a second model: toGraph(body) in the console's graphModel.ts draws it, and every canvas edit (connect, delete, drag, add, auto layout) is a pure function from body to body, so the saved definition is always the plain WorkflowBody.
- Nodes and edges. One node per step, with its type, an entry mark, a new or edited mark against the published version, its problem count and a type-specific summary (rule keys or rule set, app action, flow key, case type, decision, branch conditions). Edges come from
next, from each branchconditions[].next(labelled with the condition) and from the branchdefault. Connecting two nodes setsnextor a branch target; deleting a node leaves the edges that pointed at it dangling, which validation reports asdangling_next. - Positions. Node positions are stored in the definition under
ui: { positions: { <stepId>: { x, y } } }.uiis optional and editor-only: the interpreter never reads it,PUTstores it as part of the definition,GETreturns it and publishing keeps it. Seeded workflows carry noui. - Auto layout. When a step has no stored position (every step of a seeded workflow), the whole graph is laid out with dagre, top to bottom; Auto layout does the same on demand. A save stores whatever the canvas shows.
- Validation overlay. The console validates the draft without
ui, since positions never affect validity; issues render as badges on the nodes theirstepIdnames and in the Problems list, and publishing is blocked while errors remain. The API still checks auiit receives: a position for a step id that no longer exists is alayout_unknown_stepwarning (pathdefinition.ui.positions.<id>), never an error. - Versions. Layout is content: a save that only moves nodes saves the draft like any other change (a new draft version when the stored one is published) and has to be published to become the published layout. A tenant's layout-only draft of a seeded workflow is still a difference, so
pnpm db:seedre-publishes the seed content over it exactly as it would over any tenant edit of a seeded key. - Simulation (dry-running a whole workflow against sample data) is deferred; rules keep their per-rule dry run.
Collection flows
Define › Collection flows lists every flow with its shape (steps and fields), version, the workflows whose wait_for_collection step uses it and, over the last 30 days, its completion rate and median time to submit (Activity), with Embed snippet and New flow →. The editor has a Form view, a JSON view (entryStepId, linkTtlSeconds, steps, presentation, outcome and translations) and a Drop-off view:
- Steps: the steps in order with + Add; move up, move down and remove under each step's Rename; Branches from this step (conditions to other steps; the first match wins, then the default next step or the end of the flow); the Problems list.
- Flow settings: key, name, description, the version's change note, Link expires after (hours, stored as
linkTtlSeconds: one minute to 720 hours; empty takes the tenant's default from Settings › Tenant, seven days unless set), Applicant screens, Translations and the entry step. - Applicant screens (Edit screens): the welcome screen's heading, text and minutes (
presentation.intro; empty, the flow's name, description and an estimate from its fields), the thank-you screen (presentation.outro), what applicants see after the submit (outcome.visibility: a thank-you only, where the application stands, or that and then the decision; unset, the deployment'sCOLLECTION_TERMINAL_SHOW_DECISIONpicks the last or the middle one) and the under-review, approved and not-approved screens (outcome.review,approved,rejected). Empty boxes keep the terminal's own words; text may hold[label](https://…)links. - Translations (Edit translations): the flow in other languages (
translations, by language tag), one language at a time: its name and description, each step's title and description, each field's label, help, placeholder, unit, option labels and messages, and the screens' copy. Every box shows the flow's own words as its placeholder, and an empty box keeps them. The languages offered are the tenant's (Settings › Tenant › Languages; the first is the one the flow is written in); Add a language starts one the tenant does not list yet, which applicants see once it is added there. Renaming a step keeps its translations; a translation of a step, field or option the flow no longer has is a warning under Problems. - Fields of the selected step, added from
GET /collection-field-types, and the field form: key, label, required, type, Visible when, help text, placeholder, options, length or value limits, pattern, and for files the accepted types and size limit (fixed), Takes several files with At most (multiple,maxFiles), how applicants add them (capture: the camera, their files, or either) and the Document they are (documentKind), which tells applicants what to take. - Live preview: the selected step on a mobile or desktop frame, rendered with
StepFormfrom@aletheia-dev/collection-flow-ui(the collection terminal draws the same fields with its own components). Answers typed there are sample data shared by every step, sovisibleWhenconditions can be tried out, and Continue checks them (files excepted). Branches are not followed and nothing is sent anywhere. - Preview as applicant (
definitions:read) saves unsaved edits first, then opens the stored version in the collection terminal in a new tab (POST /collection-flows/:key/versions/:version/previewreturns{ url, expiresAt }; the link works for 15 minutes and its token sits in the URL fragment). The terminal walks the real steps, branches and checks with a preview banner: no subject, run or submission exists, nothing is saved or sent, file uploads are off and file fields are not required. Each link is audited ascollection_flow.previewed. - Drop-off (
definitions:read, a saved flow): where applicants got to over the last 7, 30 or 90 days, from the steps the collection terminal showed them (GET /stats/flows/:key/funnel, Activity): how many opened the flow and sent it, and per step how many reached it and how many stopped there without sending, with the uploads that failed per field and reason. The worker keeps the eventsRETENTION_SUBMISSION_EVENT_DAYS(90 by default, never fewer than the 90-day window).
The field form does not edit unit and messages yet: set them through the API or a policy pack, and note that saving the flow from the console drops them.
Field types
GET /collection-field-types returns one entry per FieldType, with optionsSchema (what the field editor shows beyond key, label, required, placeholder, help and visibleWhen) and valueSchema (the shape the submission stores):
| Type | Options | Value |
|---|---|---|
text | validation: min/max length, pattern; autocomplete, inputMode | string |
email | autocomplete | string (format: email) |
number | validation: min/max; unit shown after the input, such as % or GBP; autocomplete, inputMode | number |
select | options (at least one { value, label }) | string |
date | autocomplete: bday or off | string (format: date) |
boolean | none; a required box must be checked | boolean |
file | read-only limits: maxBytes (20 MB), contentTypes (JPEG, PNG, WebP, PDF); multiple, maxFiles, capture, documentKind | { fileId, name? }, or an array of them with multiple |
Every type also takes messages, copy that replaces the collection terminal's generated errors without changing what is accepted: required when a required field is left unanswered, and invalid when the answer fails its format, length, range or pattern check (up to 200 characters each; boolean and file take only required). An empty string is no answer, whatever the type: an optional field takes it as unanswered and a required one asks for an answer.
autocomplete names what the applicant's browser may fill in (an HTML autocomplete token such as given-name, postal-code or tel, or off; no password or payment-card tokens), and inputMode the on-screen keyboard (text, numeric, decimal, tel, email, url, search). Unset, the terminal picks: the email keyboard and autofill for an email, the decimal keyboard for a number, and a birth date's autofill for a date whose key or label says so. The field form sets both under Autofill and Keyboard.
The embed snippet
Embed snippet gives a merchant's developer the two ways to embed a flow, with the deployment's collection terminal address filled in: a plain iframe, or the loader script:
html
<div id="aletheia-flow"></div>
<script src="https://collect.example/embed/aletheia-collect.js"></script>
<script>
// `link` is the applicant link returned by POST /collection-submissions.
AletheiaCollect.mount({
container: '#aletheia-flow',
link: link,
onComplete: function (detail) {
// detail.submissionId; detail.outcome once the decision is known
},
});
</script>For admins the dialog also lists the tenant's allowed embed origins (Settings › Tenant). Embedding the collection flow documents the loader's options, the events and the origin checks.
Schema routes
| Route | Permission | Response |
|---|---|---|
GET /rule-types | definitions:read | { items: RuleTypeDescription[] } |
GET /workflow-step-types | definitions:read | { items: StepTypeDescription[] } |
GET /collection-field-types | definitions:read | { items: FieldTypeDescription[] } |
POST /workflow-definitions/validate | definitions:read | ValidationReport; the body is a draft |
POST /collection-flows/validate | definitions:read | ValidationReport; the body is a draft |
POST /rule-definitions/validate | definitions:read | ValidationReport; the body is a draft |
POST /rule-sets/validate | definitions:read | ValidationReport; the body is a draft |
POST /workflow-definitions/context-paths | definitions:read | { items: ContextPath[] } for { definition, stepId? } |
GET /definitions/usage?kind=&key= | definitions:read | { items: DefinitionUsage[] } |
Validation
The validate endpoints take the same body as the matching PUT (an UpsertWorkflowDefinitionInput, UpsertCollectionFlowInput, UpsertRuleDefinitionInput or UpsertRuleSetInput), save nothing, and always answer 200 with a ValidationReport:
json
{
"ok": false,
"issues": [
{
"severity": "error",
"path": "definition.steps.0.next",
"stepId": "rules",
"code": "dangling_next",
"message": "step \"rules\" points to unknown step \"nowhere\""
}
]
}ok is false when any issue is an error; warnings never block. A body that fails the schema is not a 400: each Zod issue becomes a schema issue with its path, next to every graph and reference issue, so the editor can mark all of them at once. Paths are relative to the request body (definition.steps.<i>.…); stepId and fieldKey name the step and field when there is one. A PUT still enforces its own checks: validation is advice, the save path is the gate.
Workflow issue codes
| Code | Severity | Meaning |
|---|---|---|
schema | error | The body or a step does not match its schema (including the ruleKeys/ruleSetKey choice). |
entry_missing | error | entryStepId names no step. |
duplicate_step_id | error | Two steps share an id. |
dangling_next | error | A next, branch conditions[].next or default names no step. |
terminal_has_next | error | An emit_decision step has a next. |
unknown_rule | error | A ruleKeys entry names no rule (any version counts, as on PUT). |
unknown_rule_set | error | ruleSetKey names no rule set. |
unknown_flow | error | flowKey names no collection flow. |
unknown_install | error | app names none of the tenant's installs. |
install_renamed | warning | app names a former name of an install, which still reaches it: call it by its name. |
unknown_action | error | The version the install runs has no such action. |
unmappable_input | error | An inputMapping key sets a field the action takes no run data in (mappable): calls fail. |
install_no_webhook | warning | The step calls an action that waits for a webhook through an install that receives none: it times out. |
install_disabled | warning | The install is disabled: the step's calls fail until it is enabled. |
install_pending | warning | The install is new and waits for its first approval: the step's calls fail until it is approved. |
unreachable_step | warning | No path from the entry step reaches the step. |
unknown_output_reference | warning | An inputMapping value or branch condition path starts with a key no earlier step writes on any path. |
cycle | warning | The step closes a loop; legal, but a branch must eventually leave it. |
layout_unknown_step | warning | A ui.positions key names no step (a stale editor position); ignored by the engine. |
For unknown_output_reference, a path's first segment is accepted when it is subject, subjectId, submission or rules, or a key some step before it (on any path from the entry) writes: its outputKey or the derived <outputKey>Id, <outputKey>Url, <outputKey>CaseId, <outputKey>ExternalId.
Publishing still requires every referenced rule and rule set to be published; validation accepts drafts, like PUT.
Rule and rule set issue codes
| Code | Severity | Meaning |
|---|---|---|
schema | error | The body does not match its schema. |
config | error | The rule type rejects the config (path config.<field>). |
unknown_list | error/warning | A list lookup names a list that does not exist (an error, as on save); an expression's inList does (a warning). |
list_stale | warning | A list the rule reads has not changed in over 30 days: neither its details nor a new entry. |
unknown_install | error | An app rule names none of the tenant's installs. |
install_renamed | warning | An app rule names a former name of an install, which still reaches it: call it by its name. |
unknown_action | error | The version the install runs has no such action. |
async_action | error | An app rule names an action that answers through a webhook: a rule cannot wait for one. |
unmappable_input | error | An app rule's inputMapping key sets a field the action takes no run data in (mappable). |
install_disabled | warning | The install the app rule names is disabled. |
install_pending | warning | The install the app rule names is new and waits for its first approval. |
unknown_result_path | warning | The fallback's result path (the rule's unless it gives one) reads a field its action declares it never answers: the rule would pass whenever the fallback answers. |
rule_action_timeout | warning | The action, or the fallback's, may run longer than 50 s (a model app's generate may run two minutes): the rules of a step share one minute, and every call is held to what is left of it. |
rule_disabled | warning | The rule is disabled, so it is skipped wherever it runs. |
unknown_rule | error | A rule set entry names a rule with no version. |
unpublished_rule | warning | A rule set entry names a rule with no published version; publishing the set needs one. |
no_live_rules | warning | Every rule of the set is disabled or shadow, so the set always gives its policy's default. |
Context paths
POST /workflow-definitions/context-paths takes a workflow definition (saved or not) and the reading stepId, and lists what that step can read: subjectId and subject, which every run starts with, then each path the steps that can run before it write, with its type, the step that writes it (writtenBy) and always (every path from the entry step to the reader runs the writer). Fields are typed where the API knows them: a rule aggregate (<outputKey>.outcome, riskScore, reasons, …), a manual decision, the fields of the latest version of a collection flow and an app action's output, from the version the step's install runs. An action's output gives the fields its schema declares, nested, six levels and 300 paths at most (appOutputPaths), and those the step's literal input shapes (the action's outputFromInput): the answers to the questions a model app's decide asks, and the fields of an object answer whose schema its generate gives. A decide step with the output key ai that asks fraud offers ai.answers.fraud.value and ai.answers.fraud.probability, a number. Without stepId, every step's writes.
Usage
GET /definitions/usage lists, for every rule, rule set and collection flow another definition names, the rule sets and workflows that name it (usedBy: kind, key, version and status). The published version is linked when it names the definition, else the latest one; a workflow uses a rule directly or through a rule set it names. kind and key narrow the answer; with both, the item comes back even when nothing names it. The console's Used by columns and panels read it.
Activity
GET /stats/definitions?kind=&days= (definitions:read; kind is rule, workflow or collection_flow, days 1 to 90, default 30) counts what each key did over the window; keys without activity are absent.
rule: the results every evaluation recorded for the key, whatever version or set ran it:evaluated(passed, failed or errored),hits(failed, shadow results included: the rule fired) anderrors.workflow: the runs started:runs,volumePerDay,failed,decided(runs with a decision) andautoDecidedPct(of those, decided without a reviewer).collection_flow: the submissions started:started,submitted,completionPctandmedianDurationMsfrom start to submission.
GET /stats/flows/:key/funnel?days= (definitions:read) reads the events the collection terminal reports (POST /collection-submissions/:id/events: a step shown, an upload that failed; ids and codes only) for one flow, any version: opened (submissions shown a step), submitted (of those, sent), and per step reached and stoppedHere (whose last step it was and that are in progress or expired, so not sent), in the order of the latest version, then the steps only older versions had; and uploadFailures per field and reason (a refusal or rejection code, or the API's error code), most first. The worker keeps the events RETENTION_SUBMISSION_EVENT_DAYS (90 by default, never fewer than the 90-day window; Retention).
GET /stats/rules/:key/hits?days=&compareVersion= (definitions:read) gives one rule's recorded results per day (every day of the window, zero when idle; days of the tenant's console.timeZone, else UTC, named in timeZone) and, with compareVersion, that version's latest completed backtest as backtest: its sample per day of the same zone, recordedHits (the result recorded at the time) against versionHits (the version's on the same evaluations); null when the version has none. A rule backtest records the per-day series in its summary.daily. Days are UTC: the tenant has no time zone yet.
Collection-flow issue codes
| Code | Severity | Meaning |
|---|---|---|
schema | error | The body, a step or a field does not match its schema; linkTtlSeconds outside 60 s to 30 days. |
entry_missing | error | entryStepId names no step. |
duplicate_step_id | error | Two steps share an id. |
duplicate_field_key | error | A field key is used twice, in the same step or across steps (submission data is flat). |
unknown_branch_target | error | A next or branches[].next names no step. |
select_without_options | error | A select field has no options. |
unreachable_step | warning | No path from the entry step reaches the step. |
visible_when_unknown_path | warning | A visibleWhen.path names no field answered earlier on some path or elsewhere in the same step. |
file_field_in_first_step_required | warning | The entry step requires an upload; applicants tend to abandon flows that open with one. |
Policy packs
A policy pack is one JSON file of definitions (Example policies), which declares every install its call_app steps and app rules call, with the app each runs (installs). Import policy pack on the Rules page opens a dialog with three tabs: Starter packs lists the packs the deployment ships (GET /definitions/packs, the repository's examples/policies), with the installs each one calls, and imports one with a click (POST /definitions/import?pack=<id>); Import file checks a pack file or pasted JSON in the browser (an install a step or rule calls that the pack does not declare is refused at that step or rule) and imports it (POST /definitions/import); with Publish after import, which needs definitions:publish, either publishes too. An item the import refuses is listed with the reason, an install the organization runs another app under among them. The result lists each definition's new version, each list's rows and the installs the pack calls: Present, with what the install's version cannot answer of what the pack calls (an action it lacks, or one an app rule calls that answers asynchronously, which refuses a publishing import), or missing, with Create install → (the app's page opens Add install under that name) when the organization's catalogue has the app, and Missing when it does not, with a link to upload it. Export writes the published version of every published rule as one pack (GET /definitions/export), with the installs it calls and the app each runs; a definition that calls an install the organization lacks stops the export, which says which. The same button on the Workflows page imports a pack file at once, as drafts, says which installs it calls are missing and offers to create the first one the catalogue has the app of. Export JSON on a history page exports that definition's published version.
Versions and approvals
Every definition kind (rules, rule sets, workflows, collection flows) has the same version history, diff and approval routes, under its prefix (/rule-definitions, /rule-sets, /workflow-definitions, /collection-flows; DEFINITION_PATHS in @actanomos/aletheia-core). The routes live in apps/api/src/routes/governance.ts, the /approvals routes in approvals.ts; the diff is @aletheia-dev/definitions.
| Route | Permission | Result |
|---|---|---|
GET <prefix>/:key/versions | definitions:read | { items: DefinitionVersionSummary[] }, newest first |
GET <prefix>/:key/versions/:version | definitions:read | that version in full (any status) |
PATCH <prefix>/:key/versions/:version | definitions:write | the draft saved in place; 409 once it is not a draft |
GET <prefix>/:key/diff?from=&to= | definitions:read | DefinitionDiff |
POST <prefix>/:key/versions/:version/request-approval | definitions:write | DefinitionApproval (201) |
POST <prefix>/:key/versions/:version/approve | definitions:approve | { approval, definition } |
POST <prefix>/:key/versions/:version/reject | definitions:approve | { approval, definition } |
POST <prefix>/:key/versions/:version/withdraw | definitions:write | { approval, definition } |
GET /approvals?status=&kind=&mine=&stale= | definitions:read | { items: DefinitionApproval[], total } (the inbox) |
GET /approvals/:id | definitions:read | ApprovalDetail |
GET /approvals/:id/comments | definitions:read | { items: ApprovalComment[] }, oldest first |
POST /approvals/:id/comments | definitions:write or definitions:approve | ApprovalComment (201) |
Request, approve, reject and withdraw take an optional { "comment": "..." } body.
History and diffs
GET .../versions lists every version with its status (draft, pending_approval, published, archived), name, change note, author (createdBy: the actor that PUT the version; updatedBy: the last to save the draft in place) and timestamps, plus the latest approval request for that version, if any. PATCH .../versions/:version takes the body PUT takes, with an optional note (an absent note keeps the stored one, a blank one clears it), and answers 409 once the version is no longer a draft.
The history page (/define/<kind>/<key>/history) lists the versions, each headed by its change note (else its name) with who last saved it, with Compare, Restore as draft, Request approval (drafts), Withdraw (your own pending request, or any as an admin) and, for approvers, Approve and Reject (hidden on your own request while four-eyes is on). Above the list, the diff between two versions (by default the published one and the latest) shows as a table of changes with their summaries, a tree or the raw delta (and for workflows the Steps view), with the approval of the newer version. Restore as draft reads the old version with GET .../versions/:version and opens the editor with it as unsaved edits; saving them PUTs the body back, which creates a new draft like any other edit.
GET .../diff compares two versions; to defaults to the latest version and from to the published one (or to - 1 when to is the published version or nothing is published). An unknown version is a 404. The result carries changes (one readable line per change) and the raw jsondiffpatch delta for tree renderers. A workflow's diff adds steps: every step of the newer version in its order, with the steps it removed where they stood, each with its op (added, removed, changed, moved, unchanged), its position on each side, one line per side (evaluate kyb-onboarding · {"outputKey":"rules"} → route) and its change summaries. The Steps view lists them with a +/− gutter.
Two versions are compared on a normalized document: name, description and the kind's own fields (rule: type, severity, weight, enabled, tags, config; rule set: useCase, definition; workflow: trigger, definition; collection flow: definition). Ids, keys, versions, statuses, authors and timestamps are ignored, and so is a workflow's editor layout (definition.ui): a version that only moves nodes diffs as "no changes".
Array items are matched by identity, not position: steps by id, fields by key, rule-set rules by ruleKey. A reordered step is reported as moved, never as removed and re-added. Items without an identity (score bands, branch conditions) are matched by position. Each change has an op (added, removed, changed, moved), a dotted path that uses identities instead of indexes (definition.steps.rules.next, definition.rules.country_blocklisted), a summary, and the before/after values where they apply. Summaries read like:
step "review" added,step "review" moved (position 4 -> 2)step "rules" next: decide -> route,step "route" condition 1 next: review -> decidefield "identityDocument" added to step "review",step "ubo" field "uboOwnershipPct" validation.min: 0 -> 25rule "high_ownership_foreign_ubo" severity warn -> blockrule set: rule "country_blocklisted" added,band 2 max 90 -> 85name: KYB onboarding -> KYB onboarding v2<path> changed/added/removedwhen a whole object or array changes and no better phrasing applies (e.g.definition.policy.bands removedwhen the policy mode changes).
Approval settings
Approvals are per tenant, in PUT /tenants/me/settings under approvals: { required, fourEyes, requiredFor }; the console edits them under Settings › Tenant:
required(defaultfalse): when on,POST <prefix>/:key/publishis refused with 409approval_required(the message names the request-approval route) and the console shows Request approval → instead of Publish →. When off, publishing needs no approval, and a version can still be sent for one.fourEyes(defaulttrue): the requester of an approval cannot approve it (403four_eyes). Rejecting your own request is allowed but still needsdefinitions:approve; the requester withdraws it instead.requiredFor(unset: every kind): the kinds approvals apply to, amongworkflow,rule,rule_set,collection_flowandapp. Withapp, publishing an uploaded app version opens a request that another administrator decides withapps:publish(Apps: reference), and so does a save of an install that adds hosts (held saves); Operate › Approvals shows those requests beside the definitions'. An install change keeps the settings in force when it was requested: turning four-eyes off later does not let its requester approve it.
Seeds and the smoke keep required: false; the smoke turns it on only for its own steps.
The state machine
- Request (
draft -> pending_approval): only a draft can be requested (409 otherwise); one open request per version. Audited asdefinition.approval.requested. - Approve (
pending_approval -> published): needs an open request (409 otherwise) and runs the same checks as publishing (a workflow or rule set may only reference published rules and sets); it closes the request asapprovedand publishes that version, archiving the previously published one. Audited asdefinition.approval.approvedand<kind>.published(e.g.rule_definition.published, the same action a direct publish records). - Reject (
pending_approval -> draft): needs an open request; closes it asrejected. The draft can be edited (which creates a new version) or requested again. Audited asdefinition.approval.rejected. - Withdraw (
pending_approval -> draft): needs an open request, and the caller must be its requester or an admin (tenants:settings), otherwise 403not_requester; closes it aswithdrawn. Audited asdefinition.approval.withdrawn. - Discuss: requesters and approvers add messages to a request, open or decided, with
POST /approvals/:id/comments({ "body": "..." }, up to 4,000 characters). Messages are never edited or removed. Audited asdefinition.approval.commented.
definitions:approve belongs to the admin role; analysts can read histories, the integration role sees none of it.
The approvals inbox
GET /approvals lists open requests across all kinds, oldest first, or with status=decided the approved, rejected and withdrawn ones, newest decision first. kind narrows to one kind, mine=true to the caller's own requests and stale=true to requests made over a day ago; limit and offset page, and total counts every match. Operate › Approvals shows the views open, requested by me, stale and decided, filtered by kind, each read from the API; the rail counts the open requests, the caller's own and the stale ones. Each open request is a card with the requester and comment, the diff of the requested version against the published one, what it affects, the latest backtest and the eligible approvers, with Approve & publish and Reject (rejecting needs a comment, which tells the requester what to change) and Withdraw on your own requests. With four-eyes on, the console replaces the decision buttons on your own request with "You requested this". The tab and its count need definitions:approve or definitions:write.
GET /approvals/:id returns one request (ApprovalDetail) with what deciding it needs: the requested version in full, the published version's number, the diff from it (from the previous version when the requested one is published or nothing is), the latest completed backtest of a rule or workflow version, what the version names (dependencies: a rule set's rules; a workflow's rules, rule sets and collection flows), the published definitions that name its key (usage: the rule sets and workflows a new rule version reaches, the workflows of a rule set or flow), the directory's users who may approve it, each with eligible and why not (the requester under four-eyes), and the discussion. The console's request page (/approvals/:id) shows all of it, with the decision box, Withdraw… for the requester or an admin, Run a backtest for rules and workflows (backtests:run; a workflow version replays its rule steps, see Rules) and a comment box under the discussion.