AUTH4D-159 Flows engine, per-application overrides and versioning
A flow is an ordered list of steps that runs at one synchronous trigger (pre_registration,
post_login, pre_guest_upgrade or device_approval, see AUTH4D-158). Flows are versioned: each
flow has at most one editable draft and any number of immutable published versions, one of which is
active. A binding chooses which flow runs for a trigger, for the whole project or for one
application.
The schemas are in packages/contracts (FlowDefinitionSchema, FlowStepSchema,
FlowSummarySchema, FlowVersionSchema, FlowBindingSchema, FlowExecutionSchema). The engine
is apps/api/src/modules/flows/engine.ts and the management routes are
apps/api/src/modules/flows/management.ts. Storage is migration 0019_flows.sql.
Resolution
For each trigger the API runs:
- the flow bound to the application (the OAuth client in the request), if there is one;
- otherwise the flow bound to the project;
- otherwise the built-in default.
The built-in default is read-only code with no steps. It always continues with no claims or roles changes and writes no trace, so a project with no bindings behaves exactly as it did before Flows. Creating or publishing a flow changes nothing until it is bound.
Definitions
{
"timeBudgetMs": 500,
"steps": [
{
"id": "block-discord",
"type": "deny",
"reasonCode": "discord_disabled",
"when": { "methods": ["discord"] }
},
{ "id": "tier", "type": "patch_claims", "claims": { "game:tier": "gold" } },
{ "id": "roles", "type": "patch_roles", "add": ["player"], "onError": "fail_open" }
]
}
| Field | Rules |
|---|---|
timeBudgetMs |
50 to 2000, default 2000. The budget for the whole flow; it can be lowered, never raised. |
steps |
0 to 16 steps, run in order. Step ids are unique, lowercase, up to 32 characters. |
onError |
fail_closed (default) or fail_open. See below. |
when |
Optional. methods and/or countries; the step runs only when every list matches. |
Step types:
type |
Effect |
|---|---|
deny |
Stops the flow and denies the action with reasonCode (lowercase, 2 to 64 characters). |
patch_claims |
Sets 1 to 16 claims. A null value removes a claim set by an earlier step. |
patch_roles |
Adds then removes role names (add, remove, up to 16 each). |
Claim names must be namespaced as namespace:name, for example game:tier. The registered JWT and
OIDC claims (iss, sub, aud, exp, iat, jti, sid, scope and others) and any name that
starts with auth4 (for example auth4_roles or auth4:tier) are reserved and rejected. Claim
values are strings (up to 256 characters), numbers, booleans or arrays of up to 32 strings. Role
names follow the AUTH4D-154 rule: a lowercase letter, then lowercase letters, digits, _, . or
-, up to 64 characters.
Execution
Steps run in order inside the time budget. A denial stops the flow; later steps are not run. When a step throws or the budget runs out before it finishes:
fail_closeddenies the action with reason codeflow_step_failedorflow_budget_exceeded;fail_openskips the step and the flow continues. Once the budget is spent, every remaining step counts as timed out, so onlyfail_opensteps can be passed over.
A denial reaches the user as the same access_denied error the AUTH4D-158 trigger points already
return; the reason code is never shown to end users. A flow that continues may carry claims and
roles patches in the hook decision (FlowHookDecisionSchema, claims and roles). Token issuance
does not apply them yet; that is a later Flows ticket. If the bound flow cannot be loaded, the
trigger fails closed, as hooks always have.
Every run of a bound flow writes one trace: flow, version, trigger, application, scope (project
or application), outcome, reason code, duration and each step’s outcome and duration. Traces never
contain user ids, claim values, email or IP addresses or other request data. They are purged 30
days after the run (LIFECYCLE_FLOW_TRACE_RETENTION_DAYS).
Management API
All routes are under /management/tenants/{tenantId} and need a management token. Reads need
tenant:read and any project role. Writes need tenant:write and the owner or admin role; viewers
get 403 insufficient_role, and the refusal is audited. The console calls these routes through its
BFF at /api/management/..., which refuses cross-site writes (403 csrf_rejected). JSON responses
are no-store. Another project’s flows, bindings and applications answer as if they did not exist.
| Method and path | Body | Result |
|---|---|---|
GET /flows |
{ flows, builtInDefault } |
|
POST /flows |
{ name, trigger, definition? } |
201 { flow, versions }, draft v1 |
GET /flows/{flowId} |
{ flow, versions }, newest first |
|
PUT /flows/{flowId}/draft |
{ definition, name? } |
{ flow, versions } |
POST /flows/{flowId}/publish |
{} |
{ flow, versions } |
POST /flows/{flowId}/rollback |
{ version } |
{ flow, versions } |
GET /flows/{flowId}/executions |
{ executions }, latest 50 |
|
GET /flow-bindings |
{ bindings } |
|
PUT /flow-bindings/{trigger} |
{ flowId } |
{ binding } for the project |
DELETE /flow-bindings/{trigger} |
{} |
204 |
PUT /flow-bindings/{trigger}/clients/{clientId} |
{ flowId } |
{ binding } for one application |
DELETE /flow-bindings/{trigger}/clients/{clientId} |
{} |
204 |
PUT .../draftreplaces the draft, or creates the next version number as a draft when the flow has none. Published versions never change.publishturns the draft into a published version and makes it active (409 no_draftwithout a draft).rollbackmakes an earlier published version active again (404for an unknown version,409 version_not_publishedfor the draft). Rolling back to the active version changes nothing.- Binding needs a published flow of the same trigger (
409 flow_not_published,409 trigger_mismatch). Binding replaces any existing binding at that scope. Unbinding nothing is a no-op. - Limits: 50 flows per project, 1000 versions per flow, 32 KiB per definition
(
400 definition_too_large). Invalid definitions answer400 validation_failed. - A concurrent change answers
409 flow_changed; reload and try again.
Example summary:
{
"flowId": "flo_3c1f…",
"trigger": "post_login",
"name": "Login claims",
"activeVersion": 2,
"draftVersion": null,
"createdAt": "2026-10-06T10:00:00.000Z",
"updatedAt": "2026-10-06T10:05:00.000Z"
}
Audit events
Each change writes one project audit event, in the same D1 batch as the change:
management.flow.created, management.flow.draft_updated, management.flow.published,
management.flow.rolled_back, management.flow.bound and management.flow.unbound. Metadata
holds the actor’s role, the trigger, version numbers and the binding scope; never definitions or
claim values. Refused writes are recorded with outcome denied.
Source: docs/api-spec/auth4d-159.md