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_provenance post-hoc or pre-execution anomaly-review columns — e.g. crm.customer_merge_candidate, inventory.stock_adjustment_request, billing.ar_adjustment, pricing/payments's agent-anomaly review_status columns, 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"). approvals is 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. approvals is tenant-scoped throughout — every one of its 8 tables carries a tenant_isolation RLS policy keyed to current_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 with platform.contract" half, which stands unchanged.
  • Notification dispatch mechanics (actual SMTP/SMS send, delivery-provider webhooks) — approval_delivery records the notification attempt and its outcome, not the transport. This is a disclosed, deliberate, temporary duplication of a slice of the not-yet-built notifications module's own planned "delivery attempts" scope (docs/modules/MODULE_INDEX.md already 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. approvals never 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:

  • platformtenant.id is the FK target for every one of the 8 tables' tenant_id column.
  • identityactor.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_locsite.id (nullable scoping FK on approval_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:

  1. Requester (role 1) — wire-capable now. An agent may be the party that asks for approval: approval_request.initiator_actor_id accepts an agent actor, and initiator_is_agent records it explicitly. The schema fully supports this today; enabling it is purely a service-layer + agent_duty_grant authority 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_approval guarantee 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).
  2. 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_type includes 'agent_recommendation' as a first-class event type, distinct from 'agent_decided' — an agent can log a recommendation (or draft an approval_delivery notification body) that a human then acts on. No blocks_agent_approver gate applies to this role at all, because it never resolves an approval_step.
  3. Approver / auto-approve (role 3) — designed, but deliberately NOT wired. The schema mechanically permits an agent-type actor to resolve an approval_step whenever the parent workflow's blocks_agent_approver = false (the DEFAULT) — trg_approval_step_blocks_agent_approver only rejects the agent decision when the flag is true. 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) an agent_duty_grant row actually granting the relevant approvals:approval_step:decide-shaped permission to some agent type, and (c) re-proving trg_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_delivery rows are written per assigned approver (channel = email/in_app/sms, status tracks pending → sent → delivered → opened → failed) — this is the human-facing notification path, explicitly not the same audience as platform.outbox (which carries domain-facing state-change events for other systems, e.g. "tell purchasing this PO's approval resolved"). A future ApprovalsService is expected to write to both channels on every state change: approval_delivery for the human, and a platform.outbox event for domain-facing consumers — disclosed as design intent only; platform.outbox has 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 over approval_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_request move 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 with platform.contract — is re-confirmed unchanged (§2).
  • DR-B — workflow_type's CHECK-enum was a domain-knowledge leak, removed. The live admin.approval_workflow.workflow_type CHECK (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 an approvals migration just to name it. Now free text on both approval_workflow and approval_routing_rule (matching resolution). approval_policy.workflow_type (net-new) follows the identical free-text convention, NULL meaning "tenant-wide default."
  • DR-C — source_type decoupled from a CHECK-enumerated pairing; source_module stays CHECK'd. source_module is 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_type was originally drafted as a matching CHECK-enumerated pair, but the live admin.approval_request never 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 under source_module='purchasing' — see §11). source_type is free text.
  • DR-D — initiator_actor_id: renamed from requested_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, since x <> NULL evaluates to NULL, not FALSE, in Postgres, and a CHECK only rejects on FALSE — the identical bug class this codebase already learned from chk_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 as purchasing.purchase_order's own two CHECKs already established.
  • DR-E — approval_step replaces JSON step_history, and resolution_mode resolves a real ambiguity. The old JSON blob was not queryable for "is this parallel step's quorum satisfied yet." resolution_mode (all_must_approve vs. any_one_resolves) distinguishes an AND-parallel group from an OR-candidate-fanout group sharing the same step_index — the original draft left this genuinely ambiguous ("multiple rows share the same index" could mean either). step_mode/condition are snapshotted from approval_workflow.steps at creation time, reusing ai.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.invitation still has. token_hash (SHA-256 of a ≥256-bit CSPRNG value) mirrors admin.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 — no app.current_tenant_id context 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 for identity.service_account/api_key's own auth handshake. This exact gap exists, unaddressed, in identity.invitation today; 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 authenticated alone — 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-level GRANT UPDATE that already covers the same privilege. Confirmed two ways: information_schema.column_privileges still showed the grant, and a live UPDATE against the column unexpectedly succeeded. Fixed by REVOKEing the table-level UPDATE entirely and re-GRANTing UPDATE column-by-column on every column except blocks_agent_approver. Re-tested live: the flip attempt now correctly fails with permission 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.id defaults to platform.uuid_generate_v7() (Remediation Phase 2's own rule for genuinely append-only tables) and its REVOKE+trigger reuses platform.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) is NOT NULL — no NULL-bypass surface exists on any of them. The 2 CHECKs on legitimately-nullable columns (chk_approval_request_resolver_not_initiator on resolved_by_actor_id; chk_approval_step_decision on decision) use deliberate IS NULL OR patterns matching real "not yet resolved/decided" semantics, not a bypass. chk_approval_request_resolved_requires_actor_at (the DR-1 fix) uses only NOT IN-against-a-NOT NULL-column and IS NOT NULL predicates — 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 own agent_duty_grant row, not by anything in approvals.
  • Role 2 (approver-assistant): approvals:approval_step:recommenddraft_only/observational, logs an approval_event row with event_type='agent_recommendation', never touches approval_step.decision itself.
  • Role 3 (approver): approvals:approval_step:decidenot granted to any agent type today. The permission code is named here for forward reference only; no tenant should create an agent_duty_grant row 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 the source_module CHECK. No reciprocal column on any of the 8 target schemas' tables, mirroring tax.tax_calculation.source_ref's and payments.payment_intent.source_ref's identical polymorphic pattern.
  • approvals → identity: every *_actor_id column (§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_idplatform.tenant.id. platform.outbox is a documented, not-yet-real producer seam — see §6.
  • admin → approvals (severed): Admin no longer owns any approval-workflow table; admin's own doc and CROSS_MODULE_CONTRACTS.md are 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):
    1. 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 direct workflow_id FK, not this engine's opaque source_ref contract — a real re-architecture, not a drop-in.
    2. identity's global/mixed-scope workflow-template intent (a Vrida-shipped template, nullable tenant_id) is not representable under approval_workflow.tenant_id's blanket NOT NULL — decide when identity's convergence (item 1) is built.
    3. identity.sod_violation — when identity's compliance-review UI is built.
    4. crm.customer_tax_certificate — when CRM's service layer is built.
    5. crm.customer_merge_candidate/customer_merge — when CRM's service layer is built. Its review_status quintet IS the business gate itself, not an autonomy seam — the convergence decision is whether to drop it, keep it as a denormalized cache of approval_request.status, or leave it as system-of-record.
    6. inventory.stock_adjustment_request — when Inventory's service layer is built (same review-status-is-the-gate note).
    7. inventory.item_merge_candidate/item_merge — when Inventory's service layer is built.
    8. inventory.stock_count reconciliation sign-off — when Inventory's service layer is built.
    9. ai.import_record accept/reject — when AI's import-pipeline service layer is built.
    10. pos.sale_refund.approved_by_actor_id — when POS's service layer is built.
    11. purchasing.purchase_order's own approval_status/approved_by_actor_id gate — DECIDED: leave as-is for now, do not converge yet.
    12. purchasing.vendor_invoice approve/dispute/void gate — when purchasing's service layer is built.
    13. purchasing.vendor_invoice_match discrepancy-resolution — when purchasing's service layer is built.
    14. purchasing.vendor_return RMA-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_workflow 10 12 MOVED + extended: +step_mode, +blocks_agent_approver
    approval_routing_rule 11 11 MOVED, unchanged shape
    approval_request 18 20 MOVED + extended: −step_history (dropped, superseded by approval_step), +initiator_is_agent, +escalated_at, +expires_at; requested_by_actor_id renamed initiator_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 queryable approval_step table, 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).

Last modified: Jul 9, 2026, 4:44 PM PT
On this page
Esc