approvals — Module Spec
1. Purpose
approvals is Vrida's cross-cutting, tenant-side business-process approval-workflow ENGINE — module #21, the next module built after Admin (#20). It owns the reusable machinery for "this action needs N human sign-offs, routed by threshold/type/site, before it's allowed to proceed": workflow/routing DEFINITIONS, per-request STEP instances, one-click approve/reject TOKENS, outbound approver notifications, and an append-only audit trail. It exists because this exact engine already existed — as admin.approval_workflow/approval_routing_rule/approval_request — but admin's own "tenant technical/operational configuration" framing never actually described a multi-consumer engine other domain modules are meant to write into. This build MOVES those 3 tables out of Admin into their own module, extends them, and adds 5 net-new tables (approval_policy, approval_step, approval_delivery, approval_token, approval_event) — 8 tables / 97 columns total. It REVERSES the "stays Admin-internal" half of PROJECT_DECISIONS #34 Section 5's DECIDED Option B (the "does not converge with platform.contract's operator-review gate" half of that same decision is untouched — see §2).
This is also the first module whose own design-verification pass found and fixed a genuine Postgres ACL misconception mid-build (see §9 DR-G) — a real, generally-applicable lesson, not just a schema fix.
2. Ownership
Owns — 8 tables, 97 columns:
| Table | Cols | Role |
|---|---|---|
approval_workflow |
12 | Configurable approval-chain DEFINITION (steps JSONB, step_mode, the C8 anchor blocks_agent_approver) |
approval_routing_rule |
11 | Routes a trigger (workflow_type + threshold_cents + optional site) to a workflow |
approval_policy |
10 | Self-approval & SoD configuration (allow_self_approval intent, min_distinct_approvers, escalation timeout) |
approval_request |
20 | Runtime INSTANCE — the routed request a source-module action is waiting on |
approval_step |
14 | Per-request step instance (replaces the old JSON step_history) — sequential/parallel/conditional, AND vs. OR resolution |
approval_delivery |
12 | Outbound approver notification attempts (email/in_app/sms) |
approval_token |
10 | One-click approve/reject tokens — hash-only, atomic single-use |
approval_event |
8 | Append-only audit log of everything that happens to a request |
| Total | 97 |
Does NOT own:
- The ~41 existing autonomy-review-seam tables scattered across the codebase (each domain module's own
review_status/reviewed_by_actor_id/decision_provenancepost-hoc or pre-execution anomaly-review columns — e.g.crm.customer_merge_candidate,inventory.stock_adjustment_request,billing.ar_adjustment,pricing/payments's agent-anomalyreview_statuscolumns,purchasing.purchase_order.approval_status). Those tables' own review-status column IS the approval gate for that specific mechanism (already disclosed this way in OPEN_ITEMS rows 5/6: "this table's existing review_status quintet is NOT an autonomy seam — it IS the business approval gate itself").approvalsis a genuinely separate, reusable, MULTI-STEP workflow engine for when a tenant wants configurable routing/thresholds/multi-approver chains layered on top of — or eventually in place of — that single-column pattern. 14 of those mechanisms across 6 modules are logged as explicit convergence candidates, not silently assumed to migrate (§11).purchasing.purchase_order's own gate is explicitly DECIDED not to converge yet (a deliberate call, not an oversight). platform.contract's Vrida-operator review gate (review_status/review_reason/reviewed_by_actor_id/reviewed_at) — a structurally different plane. That gate is Vrida-staff reviewing a tenant's contract/subscription: cross-tenant,service_role-scoped, no tenant RLS applies.approvalsis tenant-scoped throughout — every one of its 8 tables carries atenant_isolationRLS policy keyed tocurrent_setting('app.current_tenant_id'), assuming the approver is a member of the same tenant. This is the exact distinction PROJECT_DECISIONS #34 Section 5 drew when it first chose Option B; this move reverses only the "stays Admin-internal" half of that decision, not the "does not converge withplatform.contract" half, which stands unchanged.- Notification dispatch mechanics (actual SMTP/SMS send, delivery-provider webhooks) —
approval_deliveryrecords the notification attempt and its outcome, not the transport. This is a disclosed, deliberate, temporary duplication of a slice of the not-yet-builtnotificationsmodule's own planned "delivery attempts" scope (docs/modules/MODULE_INDEX.mdalready lists an 11-table/166-col Notifications module as planned) — logged to OPEN_ITEMS (row 15) with a reconciliation trigger, not silently carried forever. - The underlying business action itself.
approvalsnever executes the PO send, the discount grant, the refund — it only gates whether the source module's own write path is allowed to proceed. Execution stays entirely in the source module's own service.
3. Layer & Dependencies
Cross-cutting infrastructure layer, the same shape as identity/platform/ai: consumed by every other tenant-operational module for its own approval-gating needs, but itself depends on no domain module.
Depends on:
platform—tenant.idis the FK target for every one of the 8 tables'tenant_idcolumn.identity—actor.id(read-only FK target everywhere a human/agent is referenced:approval_request.initiator_actor_id/.resolved_by_actor_id,approval_step.assigned_approver_actor_id/.acted_by_actor_id,approval_delivery.recipient_actor_id,approval_token.actor_id,approval_event.actor_id);agent_duty_grant(read-only, consulted at the service layer to gate the 3 agent roles in §5 — a permission-keyed, not table-keyed, relationship, so it carries no FK).multi_loc—site.id(nullable scoping FK onapproval_routing_rule.site_id, unchanged from its admin-era shape).platform.outbox— a documented future producer relationship, not an FK or schema dependency today. See §7 for the push/pull population story.
Depends on NO domain module — the reverse-dependency rule is stated verbatim in the schema source (_schema.ts): "any module may depend on approvals; approvals depends on NO domain module." Domains connect via the polymorphic (source_module, source_type, source_ref) triple, never a real FK — a structural guarantee that approvals can never be blocked from evolving by a domain module's own schema changes, and that locking approvals never requires touching any domain module's schema.
Depended on by: no domain module has a real FK into approvals today (schema-only, no consumer has been updated to call into it yet). Every domain module that has ever produced a review_status-shaped gate is a prospective consumer — see the 14-item convergence list in §11.
4. Capabilities — the 3 agent roles
approvals is the first module whose Part D capability discovery is organized around who is on which side of the approval, not a per-table autonomy tier. Three distinct roles exist, at three different levels of readiness:
- Requester (role 1) — wire-capable now. An agent may be the party that asks for approval:
approval_request.initiator_actor_idaccepts an agent actor, andinitiator_is_agentrecords it explicitly. The schema fully supports this today; enabling it is purely a service-layer +agent_duty_grantauthority question (does this agent already hold, via its role, the permission for the underlying source action it's requesting approval for), not a schema gap.chk_approval_request_resolver_not_initiator+trg_approval_step_no_self_approvalguarantee an agent-initiated request can never be resolved by that same agent — but a human-controlled proxy resolving on the agent's behalf is not schema-detectable (a permanent, disclosed limitation — OPEN_ITEMS row 16). - Approver-assistant (role 2) — wire-capable now. An agent may draft, summarize, or recommend a decision for the human approver without ever deciding itself. The schema already anticipates this:
chk_approval_event_event_typeincludes'agent_recommendation'as a first-class event type, distinct from'agent_decided'— an agent can log a recommendation (or draft anapproval_deliverynotification body) that a human then acts on. Noblocks_agent_approvergate applies to this role at all, because it never resolves anapproval_step. - Approver / auto-approve (role 3) — designed, but deliberately NOT wired. The schema mechanically permits an agent-type actor to resolve an
approval_stepwhenever the parent workflow'sblocks_agent_approver = false(the DEFAULT) —trg_approval_step_blocks_agent_approveronly rejects the agent decision when the flag istrue. This is the C8 financial-autonomy-boundary gate: designed and live-reproduced (rejects correctly when the flag is true), but role 3 itself is not enabled for any real workflow today. Enabling it requires (a) a dedicated tenant/product decision to actually build a workflow with agent-approver intent, (b) anagent_duty_grantrow actually granting the relevantapprovals:approval_step:decide-shaped permission to some agent type, and (c) re-provingtrg_approval_step_blocks_agent_approver's rejection against real production data at the moment it's enabled (OPEN_ITEMS row 17) — none of which has happened. No workflow should ever be authored today assuming role 3 is available.
5. Service Contract — ApprovalsService
Not built this pass. No ApprovalsService exists yet — schema-only build, matching every other module's own first-pass precedent (crm/inventory/pricing/pos/orders/purchasing/tax/billing/payments/admin all shipped schema-only before their service layer). Downstream code would query approvals.* directly via Drizzle until one exists. See §7 for the binding contract shape a future ApprovalsService must honor, and §8 for the specific guards it must never attempt to work around.
6. Data-Flow / Population Model
approvals is populated the same way ai's agent-runtime cluster is — not by a user filling out a form in this module directly, but by another module's service layer calling in when its own write path needs a gate.
The requestApproval() contract (documented future shape, not yet implemented):
requestApproval({
tenantId, siteId?,
workflowType, // free text — resolved to an approval_workflow via approval_routing_rule
sourceModule, sourceType, sourceRef, // the polymorphic backref — see §11
amountCents?, // matched against routing_rule.threshold_cents
initiatorActorId, // NOT NULL at call time — no null-initiator path exists
initiatorIsAgent,
})
→ 1 approval_request row (status='pending')
+ N approval_step rows, snapshotted from the resolved approval_workflow.steps at creation time
(so a later edit to the workflow definition never retroactively changes an in-flight request)
+ 1 approval_event row (event_type='created')
Resolution is dual-path, deliberately redundant — a push side and a pull side that don't depend on each other:
- Push / notify.
approval_deliveryrows are written per assigned approver (channel= email/in_app/sms,statustracks pending → sent → delivered → opened → failed) — this is the human-facing notification path, explicitly not the same audience asplatform.outbox(which carries domain-facing state-change events for other systems, e.g. "tell purchasing this PO's approval resolved"). A futureApprovalsServiceis expected to write to both channels on every state change:approval_deliveryfor the human, and aplatform.outboxevent for domain-facing consumers — disclosed as design intent only;platform.outboxhas no dispatcher/consumer anywhere in the codebase yet (a pre-existing gap, not introduced here — OPEN_ITEMS row 19), so this pairing is not live today. - Pull / read-model. Because delivery can fail (
approval_delivery.status='failed', or a channel that's simply never wired), the durable source of truth for "what needs my attention" is always a direct query overapproval_step/approval_request(WHERE assigned_approver_actor_id = :me AND decision IS NULL) — a "My Approvals" queue that never depends on a notification having succeeded.approval_token's one-click links are an accelerant on top of this queue, not a replacement for it.
Resolution itself (a human or, eventually, a role-3 agent, deciding a step) writes to approval_step (decision, decided_at, acted_by_actor_id) and, when the request's steps are fully satisfied, to approval_request (status, resolved_by_actor_id, resolved_at) — plus an approval_event row per transition ('approved'/'rejected'/'escalated'/'expired'/'token_used').
7. Build Requirements (binding)
Six requirements a future ApprovalsService must never attempt to bypass — each backed by a DB-level guard that was live-reproduced with real before/after proof against the local Supabase Postgres instance (127.0.0.1:54322) during this build, not merely asserted:
DR-1 — never resolve a request without a real resolver (chk_approval_request_resolved_requires_actor_at)
ApprovalsService MUST NOT set status IN ('approved','rejected') without a non-null resolved_by_actor_id AND resolved_at in the same write. Live-reproduced: the constraint was temporarily dropped alongside the initiator_actor_id NOT NULL to prove the historical "approved by nobody" bug (zero approval_step rows, NULL resolver, status='approved') actually succeeds without both fixes — then re-proved rejected with both restored.
DR-2 — never resolve a request as its own initiator, on any step (chk_approval_request_resolver_not_initiator + trg_approval_step_no_self_approval)
MUST NOT let the same actor be both initiator_actor_id and a step's acted_by_actor_id — enforced at the header level AND on every individual approval_step row regardless of step_index (closes self-approval on non-final steps of a multi-step chain, not just the header). Live-reproduced across 3 sub-cases: header self-approval, non-final-step self-approval, and the null-approver path (initiator tries to self-resolve when zero steps were ever created).
DR-3 — parallel-quorum steps must be satisfied by genuinely distinct actors (trg_approval_step_distinct_approvers)
When approval_policy.min_distinct_approvers > 1, MUST NOT let the same actor decide two sibling approval_step rows sharing a step_index and count toward quorum twice. Live-reproduced: same actor rejected on their second sibling-step decision; a genuinely different actor succeeds.
DR-4 — an agent may never resolve a step on a blocks_agent_approver=true workflow (trg_approval_step_blocks_agent_approver + column write-lock)
The C8 boundary. MUST NOT allow an agent-type actor's decision through on such a workflow (a human's decision on the same step succeeds — the trigger targets agent actors specifically), and MUST NOT allow any tenant-scoped write path to flip blocks_agent_approver on an existing workflow (table-level UPDATE is REVOKEd from authenticated, then re-GRANTed column-by-column on every OTHER column — see §9 DR-G for why a naive column-level REVOKE alone does not work in Postgres). Live-reproduced: agent decision rejected, human decision succeeds, flag-flip rejected with permission denied for table approval_workflow, an ordinary column (description) on the same table remains writable by the same role.
DR-5 — token redemption must be one atomic, single-use, expiry-aware statement
ApprovalsService MUST redeem an approval_token with exactly one UPDATE ... WHERE id = :id AND used_at IS NULL AND superseded_at IS NULL AND expires_at > now() — never a read-then-write, and never check used_at alone (the original draft's predicate left a TOCTOU race open on the expiry dimension). Issuing a new token for the same (approval_step_id, actor_id, action) triple, or the step resolving through ANY channel, MUST supersede prior outstanding tokens (trg_approval_token_supersede_on_issue, trg_approval_step_supersede_tokens_on_resolve) — closing a sibling-token replay gap where an opposite-action token for the same step otherwise stayed independently redeemable after its sibling already decided. Live-reproduced: first redemption succeeds; replay of the same token rejected (0 rows updated); a 2nd token for the same triple supersedes the 1st (which then can't be redeemed); an expired token is rejected by the combined predicate.
DR-6 — approval_event is genuinely append-only
No code path may ever UPDATE or DELETE an approval_event row — enforced by REVOKE + platform.reject_append_only_mutation() (the same shared trigger already consumed by 9 tables across 6 schemas, not reimplemented here). Live-reproduced: both UPDATE and DELETE rejected, even connected as the Postgres superuser — the trigger blocks every role, not just authenticated.
8. Design Rationale (DR-A…H)
- DR-A — the move, and which half of Option B reverses. PROJECT_DECISIONS #34 Section 5 DECIDED "Option B": the tenant-side approval engine stays Admin-internal AND does not converge with
platform.contract's operator-review gate. This build reverses only the first half —approval_workflow/approval_routing_rule/approval_requestmove out of Admin into their own module because Admin's "technical/operational configuration" framing never actually described a multi-consumer engine other domain modules write into; a dedicated module makes the reverse-dependency rule (§3) explicit and enforceable in a way a sub-concern of Admin's own schema could not. The second half — no convergence withplatform.contract— is re-confirmed unchanged (§2). - DR-B —
workflow_type's CHECK-enum was a domain-knowledge leak, removed. The liveadmin.approval_workflow.workflow_typeCHECK (IN ('po_approval','discount_approval','refund_approval','other')) baked 3 specific business-process names into the engine's own schema — meaning a purchasing/crm/inventory engineer inventing a new approval-gated action would need anapprovalsmigration just to name it. Now free text on bothapproval_workflowandapproval_routing_rule(matching resolution).approval_policy.workflow_type(net-new) follows the identical free-text convention,NULLmeaning "tenant-wide default." - DR-C —
source_typedecoupled from a CHECK-enumerated pairing;source_modulestays CHECK'd.source_moduleis structural polymorphism (which of 8 known schemas a UUID points into — a typo here is a referential-integrity risk, so it stays a CHECK:purchasing/pos/orders/identity/crm/inventory/ai/other).source_typewas originally drafted as a matching CHECK-enumerated pair, but the liveadmin.approval_requestnever had one — the pairing would have been a genuinely NEW coupling that recurs every time an already-supported module adds a new record kind (purchasing alone has 3 separate OPEN_ITEMS convergence candidates undersource_module='purchasing'— see §11).source_typeis free text. - DR-D —
initiator_actor_id: renamed fromrequested_by_actor_id, and made NOT NULL — the root cause fix for the approved-by-nobody bug. A nullable initiator let both self-approval guards fail open, sincex <> NULLevaluates toNULL, notFALSE, in Postgres, and a CHECK only rejects onFALSE— the identical bug class this codebase already learned fromchk_purchase_order_sent_requires_approval's own historical NULL-bypass. The companion presence CHECK,chk_approval_request_resolved_requires_actor_at, is the piece the original draft was still missing even after the initiator fix — distinctness (DR-2) and presence (DR-1) are separate, both-necessary constraints, exactly aspurchasing.purchase_order's own two CHECKs already established. - DR-E —
approval_stepreplaces JSONstep_history, andresolution_moderesolves a real ambiguity. The old JSON blob was not queryable for "is this parallel step's quorum satisfied yet."resolution_mode(all_must_approvevs.any_one_resolves) distinguishes an AND-parallel group from an OR-candidate-fanout group sharing the samestep_index— the original draft left this genuinely ambiguous ("multiple rows share the same index" could mean either).step_mode/conditionare snapshotted fromapproval_workflow.stepsat creation time, reusingai.agent_execution.authority_level_applied's own established snapshot-not-live-reference precedent, so a later edit to a workflow definition never retroactively changes an in-flight request's already-committed shape. - DR-F — token security is hash-only and atomically single-use, closing a gap this codebase's own
identity.invitationstill has.token_hash(SHA-256 of a ≥256-bit CSPRNG value) mirrorsadmin.api_key's hash-only convention, specified explicitly after the design-verification pass found the original draft left entropy/algorithm unspecified. Token redemption is a pre-session action — noapp.current_tenant_idcontext exists at click time — so it runs via a service-role connection deriving tenant scope solely from the matched token row, the same RLS-bypass precedent this codebase already documents foridentity.service_account/api_key's own auth handshake. This exact gap exists, unaddressed, inidentity.invitationtoday; it is closed here rather than reproduced. - DR-G — a real Postgres ACL lesson, found live during this build (not by the earlier design-verification passes). The first-attempt fix for DR-4's column write-lock —
REVOKE UPDATE (blocks_agent_approver) ON approval_workflow FROM authenticatedalone — did not actually restrict anything: Postgres column-level and table-level ACLs are additive, so a column-level REVOKE cannot subtract from a broader table-levelGRANT UPDATEthat already covers the same privilege. Confirmed two ways:information_schema.column_privilegesstill showed the grant, and a liveUPDATEagainst the column unexpectedly succeeded. Fixed by REVOKEing the table-levelUPDATEentirely and re-GRANTingUPDATEcolumn-by-column on every column exceptblocks_agent_approver. Re-tested live: the flip attempt now correctly fails withpermission denied for table approval_workflow; an ordinary column (description) on the same table remains writable. Worth citing as a standalone precedent for any future column-level write-lock in this codebase — the table-level-first approach is the only one that actually works. - DR-H — append-only via reuse, not reinvention.
approval_event.iddefaults toplatform.uuid_generate_v7()(Remediation Phase 2's own rule for genuinely append-only tables) and its REVOKE+trigger reusesplatform.reject_append_only_mutation()verbatim — the same function already guarding 9 tables across 6 schemas. No new append-only mechanism was invented for this module. - NULL-in-CHECK sweep (all 13 CHECK constraints examined). Every CHECK-guarded enum/threshold column across the schema (
channel,status,event_type,min_distinct_approvers,source_module,action,resolution_mode,step_mode) isNOT NULL— no NULL-bypass surface exists on any of them. The 2 CHECKs on legitimately-nullable columns (chk_approval_request_resolver_not_initiatoronresolved_by_actor_id;chk_approval_step_decisionondecision) use deliberateIS NULL ORpatterns matching real "not yet resolved/decided" semantics, not a bypass.chk_approval_request_resolved_requires_actor_at(the DR-1 fix) uses onlyNOT IN-against-a-NOT NULL-column andIS NOT NULLpredicates — never bare equality against a nullable column — confirmed NULL-safe by construction, not by luck.
9. Agent Authority Mapping
No new authority mechanism — pure consumer of identity.agent_duty_grant, exactly as every module since crm. The 3 roles in §4 map onto permission codes at 3 different authority postures:
- Role 1 (requester): no
approvals-specific permission code at all — an agent requests approval as a side effect of attempting the underlying source-module action; its authority to do so is governed entirely by that source module's ownagent_duty_grantrow, not by anything inapprovals. - Role 2 (approver-assistant):
approvals:approval_step:recommend—draft_only/observational, logs anapproval_eventrow withevent_type='agent_recommendation', never touchesapproval_step.decisionitself. - Role 3 (approver):
approvals:approval_step:decide— not granted to any agent type today. The permission code is named here for forward reference only; no tenant should create anagent_duty_grantrow against it until the role-3 enablement decision (OPEN_ITEMS row 17) is made and re-proven live.
10. Cross-Module Seams
- approvals ← (any module), polymorphic:
approval_request.source_module(CHECK:purchasing/pos/orders/identity/crm/inventory/ai/other) +source_type(free text) +source_ref(plain uuid) — no FK (a single column can't target 8 different tables), validated only by thesource_moduleCHECK. No reciprocal column on any of the 8 target schemas' tables, mirroringtax.tax_calculation.source_ref's andpayments.payment_intent.source_ref's identical polymorphic pattern. - approvals → identity: every
*_actor_idcolumn (§3) →identity.actor.id, real enforced FKs. - approvals → multi_loc:
approval_routing_rule.site_id(nullable) →multi_loc.site.id. - approvals → platform: every table's
tenant_id→platform.tenant.id.platform.outboxis a documented, not-yet-real producer seam — see §6. - admin → approvals (severed): Admin no longer owns any approval-workflow table;
admin's own doc andCROSS_MODULE_CONTRACTS.mdare updated elsewhere in this docs pass to reflect the move (not restated here). - The 14 deferred convergence candidates, across 6 modules (none automatic — each needs its own future decision):
identity.access_request— when identity's own service/HTTP layer for access requests is built, or when a 2nd module needs the same pattern. Disclosed shape mismatch: identity's own docs (DR-22) envisioned a directworkflow_idFK, not this engine's opaquesource_refcontract — a real re-architecture, not a drop-in.identity's global/mixed-scope workflow-template intent (a Vrida-shipped template, nullabletenant_id) is not representable underapproval_workflow.tenant_id's blanket NOT NULL — decide when identity's convergence (item 1) is built.identity.sod_violation— when identity's compliance-review UI is built.crm.customer_tax_certificate— when CRM's service layer is built.crm.customer_merge_candidate/customer_merge— when CRM's service layer is built. Itsreview_statusquintet IS the business gate itself, not an autonomy seam — the convergence decision is whether to drop it, keep it as a denormalized cache ofapproval_request.status, or leave it as system-of-record.inventory.stock_adjustment_request— when Inventory's service layer is built (same review-status-is-the-gate note).inventory.item_merge_candidate/item_merge— when Inventory's service layer is built.inventory.stock_countreconciliation sign-off — when Inventory's service layer is built.ai.import_recordaccept/reject — when AI's import-pipeline service layer is built.pos.sale_refund.approved_by_actor_id— when POS's service layer is built.purchasing.purchase_order's ownapproval_status/approved_by_actor_idgate — DECIDED: leave as-is for now, do not converge yet.purchasing.vendor_invoiceapprove/dispute/void gate — when purchasing's service layer is built.purchasing.vendor_invoice_matchdiscrepancy-resolution — when purchasing's service layer is built.purchasing.vendor_returnRMA-authorization — when purchasing's service layer is built.
11. Deferred / Future Items
No ApprovalsService yet (§5). approval_delivery vs. the future notifications module's own planned "delivery attempts" scope — decide whether approval_delivery becomes a thin producer into notifications, or notifications absorbs it outright (OPEN_ITEMS row 15). platform.outbox has no dispatcher/consumer anywhere in the codebase — a pre-existing gap this module inherits rather than introduces (OPEN_ITEMS rows 18/19); no state-mutating consumer should wire against the outbox push channel alone until one exists. Role-3 (agent-as-approver) enablement is a dedicated future decision requiring re-proof of trg_approval_step_blocks_agent_approver against real production data at the moment it's enabled, not assumed from this build's own test pass (OPEN_ITEMS row 17). Agent-initiated request resolved by a human-controlled proxy is a permanent, schema-unsolvable limitation, disclosed not hidden (OPEN_ITEMS row 16). Three minor, non-blocking Section 4 gaps, logged with their own triggers: approval_step.condition needs a documented example JSONB shape (e.g. {"amount_cents_gt": 500000}) — low priority, fix opportunistically; no index yet on approval_request.expires_at/approval_token.expires_at — add when an escalation/expiry-sweep job is built; no CHECK enforcing step_mode/resolution_mode semantic consistency (resolution_mode is only meaningful when step_mode='parallel') — add when real step-creation logic is built and the production shape is known. ai.agent_execution has no reviewer-attribution column for a needs_approval action's resolution — once role 1 or role 3 is wired for any agent_execution-originated action, approval_step/approval_event becomes the natural place this gets closed (a role-1/pre-execution-gate convergence specifically, not a license to migrate the other 41 autonomy-seam tables' post-hoc pattern generally).
12. Admin → Approvals Move — Accounting
Full retained record lives in this build's PROJECT_DECISIONS entry (cited here, not duplicated word-for-word).
Admin side. 13 tables / 161 cols → 10 tables / 122 cols (−3 tables, −39 cols). Removed:
approval_workflow(−10),approval_routing_rule(−11),approval_request(−18). The remaining 10 admin tables are UNCHANGED:tenant_branding,compliance_document,tenant_setting,setting_definition,hardware_device,integration_config,integration_provider_catalog,webhook_config,api_key,custom_field_definition. Admin's own "Groups:" concern-list drops "Tenant-Side Approval Engine" entirely (down to 3 groups: Presentational / Technical-Operational Config / Tenant Extensibility).Approvals side — table-by-table fate.
Table Admin cols (before) Approvals cols (after) Fate approval_workflow10 12 MOVED + extended: +step_mode,+blocks_agent_approverapproval_routing_rule11 11 MOVED, unchanged shape approval_request18 20 MOVED + extended: −step_history(dropped, superseded byapproval_step),+initiator_is_agent,+escalated_at,+expires_at;requested_by_actor_idrenamedinitiator_actor_id(and made NOT NULL)approval_policy— 10 NET-NEW approval_step— 14 NET-NEW approval_delivery— 12 NET-NEW approval_token— 10 NET-NEW approval_event— 8 NET-NEW Reconciles exactly: 10+11+18 = 39 cols leave Admin; 12+11+20 = 43 cols arrive for the 3 moved tables (net +4, from the additions above minus the 1 dropped column) plus 10+14+12+10+8 = 54 cols for the 5 net-new tables — 43+54 = 97, matching §2's total.
Zero tables consolidated, zero columns silently dropped without a named successor. The only genuine drop is
approval_request.step_history— superseded by the queryableapproval_steptable, not a capability loss.Migration:
packages/db/migrations/20260709070000_approvals_module.sql. Drizzle schema:packages/db/src/schema/approvals/{_schema,workflow,request,delivery,index}.ts. Tests:apps/api/src/approvals/__tests__/approvals-schema.spec.ts(22 tests, all passing, covering all 6 critical guards + the NULL-in-CHECK sweep + RLS + FK resolution).