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:

  1. the flow bound to the application (the OAuth client in the request), if there is one;
  2. otherwise the flow bound to the project;
  3. 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_closed denies the action with reason code flow_step_failed or flow_budget_exceeded;
  • fail_open skips the step and the flow continues. Once the budget is spent, every remaining step counts as timed out, so only fail_open steps 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 .../draft replaces the draft, or creates the next version number as a draft when the flow has none. Published versions never change.
  • publish turns the draft into a published version and makes it active (409 no_draft without a draft). rollback makes an earlier published version active again (404 for an unknown version, 409 version_not_published for 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 answer 400 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