What it does
/api/v1/approvals/[id] (POST approve | reject) refuses to record a
decision unless every governance invariant is satisfied. Two layers:
- Single-stage gate (W6.3) — refuses any decision where
approverId === requesterIdwhentenantSettings.aiAnalyzerSettings.governance.fourEyesEnabledis on. - Multi-stage chain (W14) — every approval is backed by an ordered
list of approval-request stage rows. Each stage has a
requiredRoleand anordinal. Decisions walk the chain in order, failing CLOSED on every guard.
Configuration
The W6.3 self-approval gate is opt-in:admin stage
per existing approval). To opt into a multi-stage chain, pass stages
on creation.
Creating a multi-stage approval
stages is optional. Omitting it produces a single default admin
stage — identical to the W6.3 single-stage path. requiredRole must be
one of viewer, editor, admin.
Per-stage decision walk
POST /api/v1/approvals/{id} finds the first pending stage,
records the decision against it, and updates the parent only when:
- The decision is
reject→ parent flips torejectedimmediately. - The decision is
approveAND the stage is the last ordinal → parent flips toapprovedand the storedpayloadis applied. - Otherwise → parent stays
pending; the next stage’s reviewer can decide.
Security defaults — fail CLOSED
Every decision-time guard inverts to “reject” on uncertainty:
The “approval flipped to approved but the entity didn’t change” silent
failure shape is not reachable under W14: the stage update,
parent flip, and payload apply commit atomically or fail atomically.
Reading an approval with its stages
GET /api/v1/approvals/{id} now returns the parent plus an
ordered stages array so the UI can render the chain progress in one
round-trip:
Audit trail
Every stage decision writes one audit-log row with:action:"approve" | "reject"entityType:"approval_request"changes:{ stageOrdinal, stageName, stageStatus, parentStatus, decisionScope }decisionScope:"stage"when the parent stays pending,"request"when this decision flipped the parent to its final state.
decisionScope to filter audits when computing “how many requests
were resolved this week” — counting on action === "approve" alone now
overcounts in proportion to the stage depth.
Migration
prisma/manual-sql/09_parity_w11_to_w19.sql creates the
approval_request_stages table and backfills exactly one default
stage row per existing pending approval (ordinal=0,
requiredRole='admin', status mirrored from the parent). Existing
admin-only flows keep their behavior; nothing migrates “into”
multi-stage by default.
Known limit (V1)
requiredRole is a single value per stage — exact role match is
enforced. There is no role hierarchy (“admin satisfies editor-stage”).
A multi-role-per-stage policy is tracked separately as a future
extension of the in-memory recordDecision policy in
lib/governance/approval-workflow.ts.
Decision-flow publish gate
A third surface applies four-eyes specifically to publishing decision flows. When the tenant settingrequirePublishApproval is true
(tenant settings),
POST /api/v1/decision-flows/publish requires a fresh approved
ApprovalRequest with entityType=decisionFlow,
action=publish, and entityId equal to the flow id.
- One approval = one publish. The successful publish stamps the approval’s id
onto the new
publishedVersions[]entry, so the same approval cannot authorize a second publish (reason: "consumed"). - Two distinct identities guaranteed. The approval flows through the same stage-walk above, which already rejects self-approval and duplicate approvers, so an approved publish request implies a requester and an approver who are not the same person.
- Fails CLOSED. If the
tenantSettingslookup errors, publish is blocked unless a valid approval exists — matching the self-approval gate’s posture. - A blocked publish returns
422(title: "Publish approval required",reason: "no_approval" | "consumed") and writes apublish_blockedaudit entry (reason: publish_approval_missing | publish_approval_consumed).
requirePublishApproval: false leaves one-click publish unchanged.
See Decision Flows — publish. Migration:
prisma/manual-sql/26_tenant_require_publish_approval.sql.
See also: Compliance · Cron jobs · Approvals API