Verification

Multi-step approval workflows & signatures

Verification turns a plain CRUD write into a governed change. Instead of a record updating instantly, the change can be parked as a pending verification that one or more approvers must accept before it takes effect — with an optional signature and a full before/after diff.

Approvals are modelled as a flow graph: a sequence (or branching set) of steps, each with its own approvers/verifiers. Nucleus advances the instance through the graph, recording every decision. This is how you implement four-eyes review, compliance sign-off, or editorial publishing.

Two route families are exposed: one for acting on verifications (approve/reject/status) and one for managing the flow definitions themselves.

Behaviour#

Global defaults for how verification behaves. Individual flows can refine these, but these set the baseline posture.

config.nucleus.json — verification
1{2  "verification": {3    "enabled": true,4    "autoResetOnRejection": true,5    "requireSignatureByDefault": false,6    "diffTrackingEnabled": true,7    "endpoints": { "enabled": true, "basePath": "/verifications" },8    "flowEndpoints": { "enabled": true, "basePath": "/verification-flows" }9  }10}
enabledbooleanOptional

Master switch. When true (and a db is present) the verification service initialises and its record + flow routes register. Flows never auto-intercept entity writes — the entity record is never mutated; you start a flow explicitly (start / start-for-entity) and apply the outcome yourself when it completes.

Defaultfalse
autoResetOnRejectionbooleanOptional

Defaults to false — when the key is omitted a rejected flow terminates (the instance is set to status 'rejected' with completed_at, and does not restart). Set true (an explicit opt-in, as in the example above) to auto-restart the flow from step 1 on rejection, keeping a resubmission loop simple — the requester fixes the issue and the flow runs again from the top.

Defaultfalse
requireSignatureByDefaultboolean (reserved)Optional

Reserved / not currently wired — a global default is NOT enforced. Signatures are required per-verifier via each verifier's require_signature setting in the flow graph, so set it there rather than expecting this flag to gate decisions.

Defaultfalse
diffTrackingEnabledboolean (reserved)Optional

Reserved / not-yet-implemented as a toggle — diffs are captured unconditionally whenever a `diff` is passed to the decide endpoint, independent of this flag. Leaving it on/off does not change diff behaviour today.

Defaultn/a — unwired

Endpoints#

Verification exposes two route families with configurable prefixes. Only the flow-management family is independently toggleable (via flowEndpoints.enabled); the record family registers whenever verification.enabled is true.

endpoints{ enabled?: boolean; basePath?: string }Optional

Routes for acting on verifications — list pending items, read status, approve and reject. This is what reviewer UIs talk to. Only basePath is honored — the route family reads config.endpoints?.basePath and mounts unconditionally when verification.enabled is true. The enabled field is NOT currently wired for this family (setting it false is a no-op); use the master verification.enabled switch to turn record routes off.

Default{ enabled: true, basePath: "/verifications" }
flowEndpoints{ enabled?: boolean; basePath?: string }Optional

Routes for managing flow definitions — create and edit the step graphs that drive approvals. The entire group is godmin-only (flow definitions ARE the approval policy). Asymmetry to note: the SERVER mounts these routes unless flowEndpoints.enabled === false (default-on), but the CLIENT generator only emits the FLOW_* actions when flowEndpoints.enabled is explicitly truthy — so omitting the block entirely mounts the routes yet generates no FLOW_* actions. Set flowEndpoints.enabled: true explicitly (as in the example) to get both.

Default{ enabled: true, basePath: "/verification-flows" }

Under the hood — the flow engine#

A flow is a node graph persisted across several tables; an in-progress approval is an instance that materialises concrete requirements as it advances. This is what the VerificationFlowPage builder writes and the VerificationService runs.

flow graphsteps · verifiers · notifications · edgesOptional

A flow stores three node kinds — step, verifier and notification — plus edges, verifier configs and notification rules/recipients/channels (seven tables in total). saveFlow replaces the whole graph in a single request — deleting the prior steps/edges/verifier configs/notification rows and re-inserting from the payload (not wrapped in a DB transaction); publishFlow flips is_draft off so only published flows can start.

starting an instanceverification_instancesOptional

startFlow creates one active instance per entity record (rejecting a second concurrent one), sets current_step_order = 1, then materialises requirements for step 1. Step order comes from the step nodes sorted by step_order.

requirement materialisationverification_requirementsOptional

For each verifier wired into the current step a pending requirement row is created. A role verifier with all_must_approve is expanded into one requirement per user holding that role (so everyone must sign off); otherwise it's a single requirement keyed by user or role.

decidingapprove / reject + reason + signature + diffOptional

decide matches the caller to a pending requirement of the current step — directly by user id, or by membership of the required role — then records the decision with an optional free-text reason (persisted as reason || null on the decision row — e.g. a rejection justification), an optional signature_id (when require_signature is set) and the captured before/after diff. When require_signature is set, signature_id must reference a file uploaded by the deciding verifier themselves (files.uploadedBy === user_id): an omitted signature_id is rejected first with 'Signature is required for this verification step', and only a bare or foreign file uuid that fails the ownership lookup is rejected with 'Signature file not found or not owned by the verifier'. all_must_approve aggregates every requirement before the step advances; a rejection ends or (with autoResetOnRejection) restarts the flow.

notification triggerson_flow_started / on_step_reached / on_approved / on_rejected / on_flow_completedOptional

The engine fires five trigger types across the lifecycle: on_flow_started as the instance starts, on_step_reached as it reaches each step, on_approved / on_rejected on each decision, and on_flow_completed when the flow finishes. For each it fires the notification nodes wired to the relevant step (following edges one or two hops, through verifier nodes) — which is how an approval flow emails or notifies the right reviewers without you writing glue code.

From the frontend#

With the endpoints on, the client generates these type-safe actions. VERIFICATION_START / VERIFICATION_START_FOR_ENTITY kick off a flow (explicit start is the only way one begins — see enabled above); VERIFICATION_PENDING / STATUS / DECIDE power a reviewer's approval inbox; the FLOW_* actions power an admin flow-builder. The signatures below are the server HTTP endpoints, and the generated client actions hit those exact same plural paths — generateAllEndpoints derives them from the configured basePath, so VERIFICATION_DECIDE posts to `/verifications/:entity_name/:entity_id/decide`, identical to the mounted route. (A separate static VERIFICATION_ENDPOINTS constant exports singular `/verification/...` paths, but that legacy artifact is not what the config-driven generator produces and its paths match no mounted route — don't rely on it.) See Ready-Made Actions for the catalog.

recipe — a reviewer approval inbox
1const actions = useApiActions();2 3// 1 · load everything awaiting my decision4actions.VERIFICATION_PENDING.start({5  onAfterHandle: (r) => setQueue(r.data),6});7 8// 2 · approve or reject the current step of one record9const decide = (entity: string, id: string, approved: boolean) =>10  actions.VERIFICATION_DECIDE.start({11    payload: {12      entity_name: entity,13      entity_id: id,14      decision: approved ? "approved" : "rejected",15    },16    onAfterHandle: refreshQueue,17  });
VERIFICATION_STARTPOST · /verifications/start

Start a named flow against one entity record — the explicit entry point to the whole feature (writes are never auto-intercepted). Body: flow_id, entity_name, entity_id. Rejects a second concurrent instance for the same record.

VERIFICATION_START_FOR_ENTITYPOST · /verifications/start-for-entity

Start whichever published flow is configured for that entity type without naming a flow_id — resolves the flow from the entity_name, then starts it exactly like VERIFICATION_START. Body: entity_name, entity_id.

VERIFICATION_PENDINGGET · /verifications/pending

Every record awaiting the current user's decision.

VERIFICATION_STATUSGET · /verifications/status/:entity_name/:entity_id

Where a specific record sits in its approval graph. Readable only by the flow's submitter or a godmin (it exposes the approval diff) — but the submitter check is skipped when the instance has a null started_by, so a service-initiated flow with no submitter is readable by any authenticated caller. Flows started via the HTTP API always record a started_by.

VERIFICATION_HISTORYGET · /verifications/history/:entity_name/:entity_id

The record's full approval decision history — same VerificationStatusResponse shape as STATUS (the verifications list plus current_step / total_steps / is_completed / is_rejected counters). Scoped exactly like STATUS: readable only by the flow's submitter or a godmin, since it exposes each decision's before/after diff — and, exactly like STATUS, the submitter check is skipped when the instance has a null started_by (service-initiated flows), leaving it readable by any authenticated caller.

VERIFICATION_ENTITY_STATUSESGET · /verifications/statuses/:entity_name

Operator/reconciliation view listing every record's verification status for an entity type — godmin-only (403 'Forbidden: operator privileges required' for ordinary callers), since each row exposes its approval diff. Query params: status (active | completed | rejected | cancelled), page, limit.

VERIFICATION_DECIDEPOST · /verifications/:entity_name/:entity_id/decide

Approve or reject the current step, with an optional signature when required. Body: decision ('approved' | 'rejected'), an optional free-text reason recorded with the decision (e.g. a rejection justification, persisted as reason || null), an optional signature_id, and an optional before/after diff. When require_signature is set, signature_id must reference a file the deciding verifier uploaded themselves — another user's file (or a bare uuid) is rejected.

FLOW_LISTGET · /verification-flows

List the defined flow graphs — optional ?entity_name filter narrows to flows for one entity type. Returns the VerificationFlow rows. Part of the same godmin-only flow-management group.

FLOW_GETGET · /verification-flows/:flow_id

Read one flow definition, returning { flow, graph } — the flow row plus its full step/verifier/notification graph (what the flow-builder loads to edit). Godmin-only, like the rest of the flow-management group.

FLOW_SAVE / FLOW_PUBLISH / FLOW_DELETE· /verification-flows[/:flow_id][/publish]

Create or edit a flow graph, publish it live, or remove it — the building blocks of an admin flow editor. Together with FLOW_LIST / FLOW_GET these five FLOW_* actions form the whole flow-management group, which is godmin-only.

Related sections