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:
@aletheia-dev/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: a list field becomes a list picker, anexpressionfield 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) |
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. The picker values are the ones FormUiHints documents: list, rule, ruleSet, flow, app, appAction, step, path, condition, cel.
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 installed apps (enabled ones first, the others marked "secrets missing" or "disabled") and the actions of the installed version, 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), 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. - 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 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 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_app | error | app names no app the tenant installed. |
unknown_action | error | The app version the tenant installed has no such action. |
app_disabled | warning | The app is installed but disabled: the step's calls fail until it is enabled. |
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_app | error | An app rule names an app the tenant has not installed. |
unknown_action | error | The app version the tenant installed has no such action. |
async_action | error | An app rule names an action that answers through a webhook: a rule cannot wait for one. |
app_disabled | warning | The app rule's app is installed but disabled. |
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 declared output, from the version the tenant installed. 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.
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). 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) and imports one with a click (POST /definitions/import?pack=<id>); Import file checks a pack file or pasted JSON in the browser and imports it (POST /definitions/import); with Publish after import, which needs definitions:publish, either publishes too. Export writes the published version of every published rule as one pack (GET /definitions/export). The same button on the Workflows page imports a pack file at once, as drafts. 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 @aletheia-dev/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.screen.next, definition.rules.pep_hit), a summary, and the before/after values where they apply. Summaries read like:
step "idv" added,step "idv" moved (position 4 -> 2)step "screen" next: has_document -> rules,step "route" condition 1 next: review -> decidefield "identityDocument" added to step "review",step "ubo" field "uboOwnershipPct" validation.min: 0 -> 25rule "sanctions_hit" severity warn -> blockrule set: rule "pep_hit" 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); Operate › Approvals shows those requests beside the definitions'.
Seeds and the smoke keep required: false; the smoke turns it on only for its own step.
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.