Approval Workflow — Combined Requirements & Build Specification

Product: Meridian fintech platform Stack: React (web) · .NET 8 Web API · SQL Server / PostgreSQL · EF Core Existing tables reused: Customer (the account), CustomerUsers (people on an account), Roles (existing RBAC) Reference prototype: approval-workflow-demo.html — open it. Every screen in this document is a real screenshot of it. Version: 1.0


How to use this document

It is written to be handed to two audiences at once and to be fed directly to an AI coding assistant in VS Code.

You are… Read
Executive / product §1–§4, §7 (screens), §16 (decisions)
Backend engineer §5 + §18 (tables), §6 (engine), §8 + §19 (API and the tables each one touches), §9 (.NET structure), §20 (seed data)
Frontend engineer §7 (screens, components, tables per screen), §8 (API), §10 (React structure)
QA §7 acceptance criteria, §13 test matrix, §20 seed data
AI assistant in VS Code §17 — generation prompts, in order. §18–§20 for tables and seed data.

Conventions used throughout


1. Executive summary

Today anyone on a Customer account holding the right permission can move money, issue a card or change a colleague's role on their own. Permission is the only control.

This project adds a second, independent layer: authorisation. The account owner configures rules; actions matching those rules wait for named colleagues to approve before anything happens. Everyone involved sees how many approvals are collected and exactly who is being waited on.

Control Question it answers Enforced
Permission (Roles — existing) May this person start this? At request time
Approval policy (new) Under what conditions does it take effect? After request, before execution

Why now

  1. Business accounts above roughly ten people expect maker–checker controls as table stakes; their auditors ask for it by name.
  2. One compromised employee login plus one permission is currently an uncapped loss.
  3. Segregation of duties appears in SOC 2, ISO 27001, PCI DSS and every banking supervisory framework. Building it deliberately now is cheaper than building it under an audit deadline.

Shape of the work. Roughly five to six months to full scope. The first working slice — one action, one approver, end to end, in production — lands in about eight weeks.


2. Standards this must satisfy

Name these to auditors; they will ask by name.

Standard What it requires How we satisfy it
Maker–checker (four-eyes) The originator of an instruction is never its sole authoriser ExcludeInitiator is a literal true; enforced in the aggregate and by a DB constraint
Segregation of duties (COSO, SOX §404) Conflicting capabilities never accumulate in one person Stages can require specific roles or groups, not just a count
Dual control / N-of-M n approvals from a pool of m Quorum + eligibility set, so one absence never blocks the business
Risk-based authorisation (FFIEC) Control strength scales with exposure Amount bands plus non-amount risk rules
AML/CFT (FATF R.10, R.16) Some instructions held regardless of amount Platform-mandated policies, layered above customer config, non-overridable
SCA (PSD2, where applicable) Authorising is itself a sensitive action Step-up auth on every decision; method recorded
PCI DSS 7 / 8 / 10 Least privilege, unique attributable identity, complete audit trails Every approval attributable to one CustomerUser; append-only log
SOC 2 / ISO 27001 Config changes are privileged changes Policy versions immutable, attributable, revertible

3. Scope

3.1 Actions that can require approval

Group Actions
Money out Fiat withdrawal, crypto withdrawal, internal transfer, payment to beneficiary, bulk payment, scheduled payment creation, FX conversion, card top-up
Cards Issue, spend-limit increase, unfreeze, terminate, credential reissue
Destinations Add/edit beneficiary, whitelist crypto address, remove from whitelist
Governance Invite user, change role, grant/revoke permission, remove user, change owner, create or change an approval policy
Account Change payout bank account, change registered email/phone, raise limits, close account

3.2 Actions that must NEVER be gated

These reduce risk. Delaying them causes harm.

The freeze/unfreeze asymmetry is the single most commonly mis-specified rule in this feature class. Freezing a card is how someone stops fraud at 2am. Gating it is the one change that makes customers less safe. Unfreezing restores risk, so that is where approval belongs. The same applies to limits: lowering is safe, raising needs approval.

3.3 Individual customers — a different product, same engine

A single-person Customer has no colleague to approve. The same engine delivers a security hold: an action above a threshold waits for a cooling-off period plus confirmation from a second registered device. Different copy, different value proposition (defeating account takeover). Phase 7.


4. Roles in the process

Mapped onto the existing Roles table — we add no new role records, only eligibility rules that reference them.

Role in the process Can do Cannot do
Initiator (Maker) Raise, attach a justification, answer questions, cancel their own, edit (which clears approvals) Approve their own request
Approver (Checker) Approve, decline with a reason, ask a question Approve twice at one stage; act on a locked stage
Owner Everything above, plus author and publish rules, emergency override Weaken platform-mandated controls
Auditor Read every request, decision and policy; export evidence Raise or approve anything
Releaser (optional, deferred) Submit a fully approved instruction to the rail Approve — release is a separate duty

5. Data model

5.1 Existing tables — read only, not modified

-- EXISTING. The account. Do not alter.
Customer(
    CustomerId      UNIQUEIDENTIFIER PK,
    CustomerType    ...,     -- Individual | Business
    BaseCurrency    ...,
    TimeZone        ...,
    ...
)

-- EXISTING. A person on an account. Do not alter.
CustomerUsers(
    CustomerUserId  UNIQUEIDENTIFIER PK,
    CustomerId      UNIQUEIDENTIFIER FK → Customer,
    RoleId          UNIQUEIDENTIFIER FK → Roles,
    Email, FullName, Status, CreatedAtUtc,
    ...
)

-- EXISTING. Do not alter.
Roles(
    RoleId          UNIQUEIDENTIFIER PK,
    CustomerId      UNIQUEIDENTIFIER NULL,   -- NULL = system role
    Name            NVARCHAR(...),           -- 'Owner','Director','Finance Manager',...
    ...
)

Every new table keys on CustomerId and references people by CustomerUserId. Do not introduce a parallel user concept. Do not add columns to the three tables above; if the feature appears to need one, it belongs in an Approval* table instead.

5.2 New tables

-- ═══════════════════════════════════════════════════════════
--  POLICY CONFIGURATION
-- ═══════════════════════════════════════════════════════════

CREATE TABLE ApprovalPolicy (
    ApprovalPolicyId        UNIQUEIDENTIFIER PRIMARY KEY,
    CustomerId              UNIQUEIDENTIFIER NULL,   -- NULL = platform-mandated
    Name                    NVARCHAR(200)    NOT NULL,
    Description             NVARCHAR(1000)   NULL,
    Origin                  TINYINT          NOT NULL,  -- 1=Customer 2=PlatformMandated 3=Template
    IsEnabled               BIT              NOT NULL DEFAULT 1,
    EnforcementMode         TINYINT          NOT NULL DEFAULT 1,  -- 1=Enforce 2=Shadow(watch)
    ActiveVersionId         UNIQUEIDENTIFIER NULL,
    CreatedAtUtc            DATETIME2        NOT NULL,
    CreatedByCustomerUserId UNIQUEIDENTIFIER NOT NULL,
    CONSTRAINT FK_Policy_Customer FOREIGN KEY (CustomerId) REFERENCES Customer(CustomerId),
    INDEX IX_Policy_Customer (CustomerId, IsEnabled)
);

-- Versions are IMMUTABLE. Editing creates a new version.
-- A pending request always references the version in force when it was raised.
CREATE TABLE ApprovalPolicyVersion (
    ApprovalPolicyVersionId UNIQUEIDENTIFIER PRIMARY KEY,
    ApprovalPolicyId        UNIQUEIDENTIFIER NOT NULL REFERENCES ApprovalPolicy(ApprovalPolicyId),
    VersionNumber           INT              NOT NULL,
    Status                  TINYINT          NOT NULL,  -- 1=Draft 2=PendingApproval 3=Active 4=Superseded
    DefinitionJson          NVARCHAR(MAX)    NOT NULL,  -- the rule tree, see §7.2
    DefinitionHash          VARBINARY(32)    NOT NULL,
    EffectiveFromUtc        DATETIME2        NULL,
    ChangeNote              NVARCHAR(1000)   NULL,
    PublishedAtUtc          DATETIME2        NULL,
    PublishedByCustomerUserId UNIQUEIDENTIFIER NULL,
    CreatedAtUtc            DATETIME2        NOT NULL,
    CreatedByCustomerUserId UNIQUEIDENTIFIER NOT NULL,
    CONSTRAINT UQ_PolicyVersion UNIQUE (ApprovalPolicyId, VersionNumber),
    INDEX IX_PolicyVersion_Active (ApprovalPolicyId, Status)
);

-- Named approver panels, so rules survive staff turnover.
CREATE TABLE ApproverGroup (
    ApproverGroupId UNIQUEIDENTIFIER PRIMARY KEY,
    CustomerId      UNIQUEIDENTIFIER NOT NULL REFERENCES Customer(CustomerId),
    Name            NVARCHAR(200)    NOT NULL,
    Description     NVARCHAR(500)    NULL,
    IsDeleted       BIT              NOT NULL DEFAULT 0,
    CONSTRAINT UQ_ApproverGroup UNIQUE (CustomerId, Name)
);

CREATE TABLE ApproverGroupMember (
    ApproverGroupId  UNIQUEIDENTIFIER NOT NULL REFERENCES ApproverGroup(ApproverGroupId),
    CustomerUserId   UNIQUEIDENTIFIER NOT NULL REFERENCES CustomerUsers(CustomerUserId),
    AddedAtUtc       DATETIME2        NOT NULL,
    AddedByCustomerUserId UNIQUEIDENTIFIER NOT NULL,
    PRIMARY KEY (ApproverGroupId, CustomerUserId)
);

-- Cover while someone is away.
CREATE TABLE ApprovalDelegation (
    ApprovalDelegationId UNIQUEIDENTIFIER PRIMARY KEY,
    CustomerId           UNIQUEIDENTIFIER NOT NULL REFERENCES Customer(CustomerId),
    FromCustomerUserId   UNIQUEIDENTIFIER NOT NULL REFERENCES CustomerUsers(CustomerUserId),
    ToCustomerUserId     UNIQUEIDENTIFIER NOT NULL REFERENCES CustomerUsers(CustomerUserId),
    ValidFromUtc         DATETIME2        NOT NULL,
    ValidToUtc           DATETIME2        NOT NULL,
    MaxAmountBase        DECIMAL(28,8)    NULL,     -- optional cap on delegated authority
    Reason               NVARCHAR(500)    NULL,
    IsRevoked            BIT              NOT NULL DEFAULT 0,
    CreatedAtUtc         DATETIME2        NOT NULL,
    CONSTRAINT CK_Delegation_NotSelf CHECK (FromCustomerUserId <> ToCustomerUserId),
    INDEX IX_Delegation_Lookup (CustomerId, FromCustomerUserId, ValidFromUtc, ValidToUtc)
);
-- ═══════════════════════════════════════════════════════════
--  REQUESTS
-- ═══════════════════════════════════════════════════════════

CREATE TABLE ApprovalRequest (
    ApprovalRequestId     UNIQUEIDENTIFIER PRIMARY KEY,
    ReferenceNumber       NVARCHAR(32)     NOT NULL UNIQUE,  -- 'WD-2026-004871'
    CustomerId            UNIQUEIDENTIFIER NOT NULL REFERENCES Customer(CustomerId),

    ActionKey             NVARCHAR(100)    NOT NULL,  -- 'CryptoWithdrawal','CardIssue','RoleChange'
    ActionCategory        NVARCHAR(50)     NOT NULL,  -- 'MoneyOut','Cards','Governance'

    Status                TINYINT          NOT NULL,  -- see §12 state machine
    ExecutionMode         TINYINT          NOT NULL,  -- 1=AutoOnApproval 2=RequiresRelease

    -- the serialised intent
    CommandTypeKey        NVARCHAR(200)    NOT NULL,  -- registry key, NOT assembly-qualified name
    CommandSchemaVersion  INT              NOT NULL,
    CommandPayloadJson    NVARCHAR(MAX)    NOT NULL,
    CommandPayloadHash    VARBINARY(32)    NOT NULL,  -- detects tampering

    -- denormalised for lists, thresholds and reporting
    Title                 NVARCHAR(300)    NOT NULL,  -- '12.5 ETH to Nordwind Systems'
    Summary               NVARCHAR(300)    NULL,
    DetailJson            NVARCHAR(MAX)    NOT NULL,  -- label/value pairs for the detail screen
    Amount                DECIMAL(28,8)    NULL,
    CurrencyCode          NVARCHAR(20)     NULL,
    AmountInBaseCurrency  DECIMAL(28,8)    NULL,
    BaseCurrencyRate      DECIMAL(28,12)   NULL,
    CounterpartyName      NVARCHAR(300)    NULL,
    CounterpartyMaskedRef NVARCHAR(400)    NULL,
    ResourceId            UNIQUEIDENTIFIER NULL,      -- card id, CustomerUserId, wallet id

    InitiatedByCustomerUserId UNIQUEIDENTIFIER NOT NULL REFERENCES CustomerUsers(CustomerUserId),
    InitiatedAtUtc        DATETIME2        NOT NULL,
    Justification         NVARCHAR(2000)   NULL,
    InitiatorIpAddress    NVARCHAR(64)     NULL,
    InitiatorDeviceId     NVARCHAR(200)    NULL,

    -- requirement snapshot, FROZEN at submission
    RequirementJson       NVARCHAR(MAX)    NOT NULL,
    ReasonsJson           NVARCHAR(MAX)    NOT NULL,  -- plain-language explanations
    AppliedPolicyVersions NVARCHAR(MAX)    NOT NULL,

    TotalApprovalsRequired INT             NOT NULL,
    TotalApprovalsGiven    INT             NOT NULL DEFAULT 0,
    CurrentStageOrder      INT             NOT NULL DEFAULT 1,

    ExpiresAtUtc          DATETIME2        NULL,
    ClockPausedAtUtc      DATETIME2        NULL,      -- set while a question is open
    RemindEveryHours      INT              NOT NULL DEFAULT 8,
    LastRemindedAtUtc     DATETIME2        NULL,
    NextEscalationAtUtc   DATETIME2        NULL,

    ApprovedAtUtc         DATETIME2        NULL,
    ExecutionStartedAtUtc DATETIME2        NULL,
    ExecutedAtUtc         DATETIME2        NULL,
    ClosedAtUtc           DATETIME2        NULL,
    ClosureReason         NVARCHAR(1000)   NULL,

    ExecutionIdempotencyKey NVARCHAR(100)  NOT NULL UNIQUE,
    ExecutionResultRef    NVARCHAR(200)    NULL,
    ExecutionAttempts     INT              NOT NULL DEFAULT 0,
    ExecutionError        NVARCHAR(2000)   NULL,

    FundsHoldId           UNIQUEIDENTIFIER NULL,
    IsBreakGlass          BIT              NOT NULL DEFAULT 0,
    IsShadowMode          BIT              NOT NULL DEFAULT 0,

    RowVersion            ROWVERSION,

    INDEX IX_Req_Customer_Status (CustomerId, Status, InitiatedAtUtc DESC),
    INDEX IX_Req_Expiry          (Status, ExpiresAtUtc),
    INDEX IX_Req_Initiator       (InitiatedByCustomerUserId, Status),
    INDEX IX_Req_Reference       (ReferenceNumber)
);

CREATE TABLE ApprovalRequestStage (
    ApprovalRequestStageId UNIQUEIDENTIFIER PRIMARY KEY,
    ApprovalRequestId      UNIQUEIDENTIFIER NOT NULL REFERENCES ApprovalRequest(ApprovalRequestId),
    StageOrder             INT              NOT NULL,
    Name                   NVARCHAR(200)    NOT NULL,   -- 'Finance','Director','Board'
    RequiredCount          INT              NOT NULL,
    ApprovedCount          INT              NOT NULL DEFAULT 0,
    Status                 TINYINT          NOT NULL,   -- 1=Locked 2=Open 3=Complete 4=Skipped
    IsParallelWithPrevious BIT              NOT NULL DEFAULT 0,
    EligibilityJson        NVARCHAR(MAX)    NOT NULL,   -- {type:'role'|'group'|'users', value:...}
    OpenedAtUtc            DATETIME2        NULL,
    CompletedAtUtc         DATETIME2        NULL,
    CONSTRAINT UQ_ReqStage UNIQUE (ApprovalRequestId, StageOrder)
);

-- Materialised eligible approvers, so "who are we waiting on" is one cheap query.
-- Recomputed whenever CustomerUsers, Roles or group membership changes.
CREATE TABLE ApprovalRequestStageApprover (
    ApprovalRequestStageId UNIQUEIDENTIFIER NOT NULL REFERENCES ApprovalRequestStage(ApprovalRequestStageId),
    CustomerUserId         UNIQUEIDENTIFIER NOT NULL REFERENCES CustomerUsers(CustomerUserId),
    EligibilitySource      NVARCHAR(100)    NOT NULL,  -- 'Role:Director','Group:Board','Delegated'
    OnBehalfOfCustomerUserId UNIQUEIDENTIFIER NULL,
    IsStillEligible        BIT              NOT NULL DEFAULT 1,
    NotifiedAtUtc          DATETIME2        NULL,
    LastRemindedAtUtc      DATETIME2        NULL,
    PRIMARY KEY (ApprovalRequestStageId, CustomerUserId)
);

-- APPEND ONLY. Never updated, never deleted.
CREATE TABLE ApprovalDecision (
    ApprovalDecisionId     UNIQUEIDENTIFIER PRIMARY KEY,
    ApprovalRequestId      UNIQUEIDENTIFIER NOT NULL REFERENCES ApprovalRequest(ApprovalRequestId),
    ApprovalRequestStageId UNIQUEIDENTIFIER NULL REFERENCES ApprovalRequestStage(ApprovalRequestStageId),
    StageOrder             INT              NULL,

    DecisionType           TINYINT          NOT NULL,  -- 1=Approve 2=Decline 3=AskInfo 4=AnswerInfo
                                                       -- 5=Cancel 6=Release 7=Override 8=Invalidated
    DecidedByCustomerUserId  UNIQUEIDENTIFIER NOT NULL REFERENCES CustomerUsers(CustomerUserId),
    OnBehalfOfCustomerUserId UNIQUEIDENTIFIER NULL,
    RoleIdSnapshot         UNIQUEIDENTIFIER NOT NULL,  -- role AT TIME OF DECISION
    RoleNameSnapshot       NVARCHAR(200)    NOT NULL,
    Comment                NVARCHAR(2000)   NULL,      -- MANDATORY for Decline and Override

    DecidedAtUtc           DATETIME2        NOT NULL,
    IpAddress              NVARCHAR(64)     NULL,
    DeviceId               NVARCHAR(200)    NULL,
    GeoCity                NVARCHAR(120)    NULL,
    AuthMethod             NVARCHAR(50)     NOT NULL,  -- 'Biometric','WebAuthn','TOTP'
    StepUpVerificationId   NVARCHAR(200)    NULL,

    CountsTowardQuorum     BIT              NOT NULL,
    IsInvalidated          BIT              NOT NULL DEFAULT 0,
    InvalidatedReason      NVARCHAR(500)    NULL,

    PreviousDecisionHash   VARBINARY(32)    NULL,
    DecisionHash           VARBINARY(32)    NOT NULL,

    -- One effective approval per person per stage. Physically impossible to break.
    CONSTRAINT UQ_Decision_OnePerStage
        UNIQUE (ApprovalRequestStageId, DecidedByCustomerUserId, DecisionType),
    INDEX IX_Decision_Request (ApprovalRequestId, DecidedAtUtc)
);

CREATE TABLE ApprovalRequestThread (
    ApprovalRequestThreadId UNIQUEIDENTIFIER PRIMARY KEY,
    ApprovalRequestId       UNIQUEIDENTIFIER NOT NULL REFERENCES ApprovalRequest(ApprovalRequestId),
    CustomerUserId          UNIQUEIDENTIFIER NOT NULL REFERENCES CustomerUsers(CustomerUserId),
    EntryKind               TINYINT          NOT NULL,  -- 1=Question 2=Answer 3=Comment
    Body                    NVARCHAR(4000)   NOT NULL,
    AttachmentRefsJson      NVARCHAR(MAX)    NULL,
    CreatedAtUtc            DATETIME2        NOT NULL
);

-- APPEND ONLY, hash-chained.
CREATE TABLE ApprovalAuditLog (
    ApprovalAuditLogId  UNIQUEIDENTIFIER PRIMARY KEY,
    CustomerId          UNIQUEIDENTIFIER NOT NULL REFERENCES Customer(CustomerId),
    ApprovalRequestId   UNIQUEIDENTIFIER NULL,
    ApprovalPolicyId    UNIQUEIDENTIFIER NULL,
    EventType           NVARCHAR(100)    NOT NULL,
    ActorCustomerUserId UNIQUEIDENTIFIER NULL,          -- NULL for system events
    ActorRoleNameSnapshot NVARCHAR(200)  NULL,
    BeforeStateJson     NVARCHAR(MAX)    NULL,
    AfterStateJson      NVARCHAR(MAX)    NULL,
    MetadataJson        NVARCHAR(MAX)    NULL,
    OccurredAtUtc       DATETIME2        NOT NULL,
    IpAddress           NVARCHAR(64)     NULL,
    CorrelationId       NVARCHAR(100)    NULL,
    PreviousHash        VARBINARY(32)    NULL,
    EntryHash           VARBINARY(32)    NOT NULL,
    INDEX IX_Audit_Customer_Time (CustomerId, OccurredAtUtc DESC),
    INDEX IX_Audit_Request (ApprovalRequestId, OccurredAtUtc)
);

CREATE TABLE FundsHold (
    FundsHoldId       UNIQUEIDENTIFIER PRIMARY KEY,
    CustomerId        UNIQUEIDENTIFIER NOT NULL REFERENCES Customer(CustomerId),
    WalletId          UNIQUEIDENTIFIER NOT NULL,
    CurrencyCode      NVARCHAR(20)     NOT NULL,
    Amount            DECIMAL(28,8)    NOT NULL,
    Reason            NVARCHAR(100)    NOT NULL,   -- 'PendingApproval'
    ApprovalRequestId UNIQUEIDENTIFIER NULL,
    Status            TINYINT          NOT NULL,   -- 1=Active 2=Released 3=Captured
    CreatedAtUtc      DATETIME2        NOT NULL,
    ReleasedAtUtc     DATETIME2        NULL,
    INDEX IX_Hold_Wallet (WalletId, Status)
);

CREATE TABLE ApprovalOutboxMessage (
    ApprovalOutboxMessageId UNIQUEIDENTIFIER PRIMARY KEY,
    OccurredAtUtc  DATETIME2     NOT NULL,
    Type           NVARCHAR(300) NOT NULL,
    PayloadJson    NVARCHAR(MAX) NOT NULL,
    ProcessedAtUtc DATETIME2     NULL,
    Attempts       INT           NOT NULL DEFAULT 0,
    Error          NVARCHAR(MAX) NULL,
    INDEX IX_Outbox_Unprocessed (OccurredAtUtc)
);

5.3 Enforce append-only at the database, not only in code

CREATE TRIGGER TR_ApprovalDecision_NoDelete ON ApprovalDecision
INSTEAD OF DELETE AS BEGIN
    RAISERROR('ApprovalDecision rows cannot be deleted.', 16, 1);
END;
GO
CREATE TRIGGER TR_ApprovalDecision_NoUpdate ON ApprovalDecision
INSTEAD OF UPDATE AS BEGIN
    -- only IsInvalidated / InvalidatedReason may change, via the aggregate
    IF UPDATE(DecidedByCustomerUserId) OR UPDATE(DecidedAtUtc) OR UPDATE(DecisionType)
        RAISERROR('Approval decisions are immutable.', 16, 1);
END;
GO
-- Grant the application principal INSERT and SELECT only on ApprovalDecision
-- and ApprovalAuditLog. No UPDATE, no DELETE.

5.4 Entity Framework configuration

public sealed class ApprovalRequestConfiguration : IEntityTypeConfiguration<ApprovalRequest>
{
    public void Configure(EntityTypeBuilder<ApprovalRequest> b)
    {
        b.ToTable("ApprovalRequest");
        b.HasKey(x => x.Id);
        b.Property(x => x.Id).HasColumnName("ApprovalRequestId");
        b.Property(x => x.RowVersion).IsRowVersion();

        b.HasMany(x => x.Stages).WithOne()
            .HasForeignKey("ApprovalRequestId").OnDelete(DeleteBehavior.Cascade);
        b.HasMany(x => x.Decisions).WithOne()
            .HasForeignKey("ApprovalRequestId").OnDelete(DeleteBehavior.Restrict);

        // No navigation into CustomerUsers — that is a different bounded context.
        // Names are resolved by a read-side projection, not by an EF include.
        b.Property(x => x.InitiatedByCustomerUserId).IsRequired();

        // Multi-tenancy: every query is scoped to the current Customer, always.
        b.HasQueryFilter(x => x.CustomerId == TenantContext.Current.CustomerId);
    }
}

The global query filter on CustomerId is mandatory on every Approval* entity. A cross-tenant read on this data is a reportable incident. Do not rely on remembering a .Where().


6. The rule engine

6.1 One function, three call sites

Evaluate(actionKey, facts) → { Required, Stages[], Reasons[], ExpiryHours }
Call site Purpose Screen
Policy simulator "What would this rule do?" Test screen, before publishing
Preview endpoint "What will happen if I submit?" Live panel on every action form
The gate "Does this actually wait?" The request itself

All three must call the same implementation. If they diverge, the setup screen promises two approvals, the form promises two, and the gate demands three. That is the most damaging bug this feature can have, because it destroys trust in the control itself.

Design consequence: the evaluator is a pure function over a fact object, in its own class, with no I/O. Fact-gathering happens outside it. It is unit-testable with no database.

6.2 Rule definition format

Stored in ApprovalPolicyVersion.DefinitionJson. Data only, never executable code.

{
  "schemaVersion": 1,
  "rules": {
    "CryptoWithdrawal": {
      "bands": [
        { "min": 0,      "max": 1000,   "stages": [] },
        { "min": 1000,   "max": 25000,  "stages": [
            { "name":"Finance",  "requiredCount":1,
              "eligibility":{"type":"role","value":"Finance Manager"},
              "parallelWithPrevious":false, "excludeInitiator":true } ] },
        { "min": 25000,  "max": 250000, "stages": [
            { "name":"Finance",  "requiredCount":1,
              "eligibility":{"type":"role","value":"Finance Manager"},
              "parallelWithPrevious":false, "excludeInitiator":true },
            { "name":"Director", "requiredCount":1,
              "eligibility":{"type":"role","value":"Director"},
              "parallelWithPrevious":false, "excludeInitiator":true } ] },
        { "min": 250000, "max": null,   "stages": [ "...Finance, Director, Owner..." ] }
      ],
      "extra": [
        { "id":"fresh-destination", "enabled":true,
          "label":"Destination added in the last 24 hours",
          "conditions":[ {"fact":"destinationAgeHours","op":"lt","value":24} ],
          "stages":[ { "name":"Owner","requiredCount":1,
                       "eligibility":{"type":"role","value":"Owner"},
                       "parallelWithPrevious":false,"excludeInitiator":true } ] }
      ],
      "expiryHours": 48,
      "remindHours": 8,
      "escalateAfterHours": 24
    },

    "CardIssue":  { "stages":[ {"name":"Finance","requiredCount":1,
                     "eligibility":{"type":"role","value":"Finance Manager"},
                     "parallelWithPrevious":false,"excludeInitiator":true} ],
                    "expiryHours":168, "remindHours":24 },
    "RoleChange": { "stages":[ {"name":"Owner","requiredCount":1,
                     "eligibility":{"type":"role","value":"Owner"},
                     "parallelWithPrevious":false,"excludeInitiator":true} ],
                    "expiryHours":168, "remindHours":24 }
  },
  "combination": "UnionStrictest"
}

Supported operators — a closed set: eq neq gt gte lt lte between in notIn isNull isNotNull and or not

There is no expression or script operator, and there must never be one. Adding one converts a data format into a remote-code-execution surface authored by customers. If a customer needs something the operators cannot express, that is a product conversation about adding a fact, not an escape hatch.

6.3 The fact catalogue — server-owned

The rule builder fetches this. Never hardcode the list in React, or the two drift and customers build rules the engine silently ignores.

Fact key Label shown to the customer Type Source
amountInBaseCurrency Amount in USD number Command payload × FX rate
destinationAgeHours Hours since destination added number Beneficiary/whitelist table
destinationIsNew First ever payment to this destination bool Payment history
destinationCountryRisk Destination country risk enum Risk service
initiatorRoleName Role of the person raising it enum Roles.Name via CustomerUsers
initiatorTenureDays How long they have been on the account number CustomerUsers.CreatedAtUtc
initiatorRolling24hOutflow What they have already moved today number Velocity projection
outsideBusinessHours Raised outside working hours bool Customer.TimeZone
isRecognisedDevice From a known device bool Device fingerprint
asset Asset enum Command payload
walletId Specific wallet guid Command payload

Fail closed. If a fact cannot be resolved — the risk service is down — the condition evaluates to true, so the rule that would have demanded extra approval fires. A service outage must never silently disable a control. Log it loudly and tell the user why an extra approval appeared.

6.4 Combining rules: union, strictest wins

Several rules can match one action. All matching requirements are merged, not first-match:

Why not first-match: it is easier to build and easier to explain, but it fails dangerously. A customer writes "over $100k needs 3 approvals", later adds "payments to Vendor A need 1 approval" for convenience, and has unknowingly created a $500k single-approval path. Union prevents a narrow convenience rule from silently weakening a broad safety rule.

The cost is that customers can over-constrain themselves into deadlock — which is why the simulator (§7, screen S6) is a required part of this feature, not a nice-to-have.

6.5 Reference implementation (from the prototype)

public sealed class RuleEvaluator : IRuleEvaluator
{
    public EvaluationResult Evaluate(string actionKey, ApprovalFacts facts, PolicyDefinition policy)
    {
        var result = new EvaluationResult { ActionKey = actionKey };

        if (RiskReducingActions.Contains(actionKey))
            return result;                                  // never gated. See §3.2.

        if (!policy.Rules.TryGetValue(actionKey, out var rules))
            return result;

        result.ExpiryHours = rules.ExpiryHours;
        result.RemindHours = rules.RemindHours;
        var stages = new List<ResolvedStage>();

        // 1) amount bands
        if (rules.Bands is { Count: > 0 })
        {
            var band = rules.Bands.FirstOrDefault(b =>
                facts.AmountInBaseCurrency > b.Min &&
                (b.Max is null || facts.AmountInBaseCurrency <= b.Max));

            if (band is { Stages.Count: > 0 })
            {
                foreach (var s in band.Stages) AddStage(stages, s);
                result.Reasons.Add($"{BandLabel(band)} → {Describe(band.Stages)}");
            }
        }

        // 2) flat requirement for non-monetary actions
        if (rules.Stages is { Count: > 0 })
        {
            foreach (var s in rules.Stages) AddStage(stages, s);
            result.Reasons.Add($"Every {actionKey} → {Describe(rules.Stages)}");
        }

        // 3) extra checks, additive
        foreach (var x in rules.Extra?.Where(e => e.Enabled) ?? [])
        {
            if (x.Conditions.All(c => _conditions.Test(c, facts)))
            {
                foreach (var s in x.Stages) AddStage(stages, s);
                result.Reasons.Add($"{x.Label} → {Describe(x.Stages)}");
            }
        }

        result.Stages = stages.OrderBy(StageRank).ToList();
        for (var i = 0; i < result.Stages.Count; i++) result.Stages[i].Order = i + 1;
        result.Required = result.Stages.Count > 0;
        return result;
    }

    /// Union + strictest-wins. Same audience takes the HIGHER count, never the sum.
    private static void AddStage(List<ResolvedStage> list, StageSpec s)
    {
        var existing = list.FirstOrDefault(x => x.Eligibility.Signature() == s.Eligibility.Signature());
        if (existing is not null)
            existing.RequiredCount = Math.Max(existing.RequiredCount, s.RequiredCount);
        else
            list.Add(ResolvedStage.From(s));
    }
}

6.6 Resolving approvers against the existing tables

public async Task<IReadOnlyList<EligibleApprover>> ResolveAsync(
    Guid customerId, Guid initiatorCustomerUserId, Eligibility eligibility, CancellationToken ct)
{
    var candidates = new Dictionary<Guid, EligibleApprover>();

    switch (eligibility.Type)
    {
        case EligibilityType.Role:
            // JOIN CustomerUsers → Roles on the EXISTING schema
            var byRole = await _db.CustomerUsers
                .Where(u => u.CustomerId == customerId
                         && u.Status == UserStatus.Active
                         && _db.Roles.Any(r => r.RoleId == u.RoleId && r.Name == eligibility.Value))
                .Select(u => u.CustomerUserId)
                .ToListAsync(ct);
            foreach (var id in byRole)
                candidates.TryAdd(id, new EligibleApprover(id, $"Role:{eligibility.Value}"));
            break;

        case EligibilityType.Group:
            var byGroup = await _db.ApproverGroupMembers
                .Where(m => m.ApproverGroup.CustomerId == customerId
                         && m.ApproverGroup.Name == eligibility.Value
                         && !m.ApproverGroup.IsDeleted)
                .Select(m => m.CustomerUserId)
                .ToListAsync(ct);
            foreach (var id in byGroup)
                candidates.TryAdd(id, new EligibleApprover(id, $"Group:{eligibility.Value}"));
            break;

        case EligibilityType.Users:
            foreach (var id in eligibility.UserIds)
                candidates.TryAdd(id, new EligibleApprover(id, "Named"));
            break;
    }

    // Cover: a delegate inherits the authority of whoever is away
    var now = _clock.UtcNow;
    var delegations = await _db.ApprovalDelegations
        .Where(d => d.CustomerId == customerId && !d.IsRevoked
                 && d.ValidFromUtc <= now && d.ValidToUtc >= now)
        .ToListAsync(ct);

    foreach (var d in delegations)
        if (candidates.ContainsKey(d.FromCustomerUserId))
            candidates.TryAdd(d.ToCustomerUserId,
                new EligibleApprover(d.ToCustomerUserId, "Delegated", OnBehalfOf: d.FromCustomerUserId));

    // BR-01: four eyes. Absolute. Not configurable.
    candidates.Remove(initiatorCustomerUserId);

    return candidates.Values.ToList();
}

7. Screens

Every screenshot below is the working prototype. For each screen: what it is for, the acceptance criteria, the APIs it calls, and the React components to build.


S1 · Approvals settings — before setup

Approvals off

Purpose. Convert the owner using their own data. Approvals ship off for every existing Customer; this is the door.

Requirements

ID Requirement
AC-1.1 Approvals are disabled by default for every existing Customer.
AC-1.2 Three figures computed from that Customer's own last 90 days: outbound value moved on one approval, count over $25,000, number of CustomerUsers who can move money.
AC-1.3 Only a CustomerUser whose Role is Owner sees the setup entry point. Others see a read-only explainer.
AC-1.4 An Approvals nav item appears only once the feature is enabled.

APIs

GET /api/v1/approval-policies                      → [] when unconfigured
GET /api/v1/approvals/reports/exposure?days=90     → { valueSingleApproval, countOverThreshold, usersWhoCanMoveMoney }

React ApprovalsSettingsPageEmptyState, ExposureStatCard ×3

Tables

ApprovalPolicy R · Customer R (existing) · CustomerUsers R (existing) · Roles R (existing)

Exposure figures are computed from your existing transaction/ledger tables, not from any Approval* table — nothing has been gated yet.


S2 · Choose a starting point

Choose a starting point

Purpose. Most customers should never open the custom rule builder. Templates carry the industry knowledge.

ID Requirement
AC-2.1 Three templates named for team shape, not mechanism.
AC-2.2 One recommended, with the reasoning shown, computed from the Customer's own history.
AC-2.3 Adopting a template creates an ApprovalPolicyVersion with Status = Draft. Never live immediately.
AC-2.4 "Start from scratch" available but visually secondary.

APIs

GET  /api/v1/approval-policies/templates
GET  /api/v1/approval-policies/recommendation
POST /api/v1/approval-policies/{id}/versions   { templateKey }   → draft

React TemplateGalleryPageTemplateCard, RecommendationBanner

Tables

ApprovalPolicyTemplate R · ApprovalPolicy W · ApprovalPolicyVersion W (Draft)

Adopting a template inserts one ApprovalPolicy row and one ApprovalPolicyVersion with Status = 1 (Draft). Nothing becomes live.


S3 · Rules — the threshold table

Rules by amount band

Purpose. The screen four customers in five will never leave. Note the tabs — this is not a payments feature, it is a gate above every state-changing action.

ID Requirement
AC-3.1 Bands are contiguous with no gaps. Editing band n's ceiling sets band n+1's floor automatically.
AC-3.2 The last band always has max: null and renders "and above".
AC-3.3 Deleting a band re-links its neighbours so contiguity holds.
AC-3.4 A band with no stages renders "Nobody — goes straight out", never a blank cell. The owner must see the gap they are leaving.
AC-3.5 Action tabs cover every gateable action. Risk-reducing actions appear with an explanation and no controls.
AC-3.6 Changes autosave to the draft. No save button.
AC-3.7 Feasibility runs on every change, not on save.

APIs

GET /api/v1/approval-policies/action-types
PUT /api/v1/approval-policies/{id}/versions/{v}          # autosave, debounced 600ms
POST /api/v1/approval-policies/{id}/versions/{v}/validate → { hard[], soft[] }

React RuleEditorPageActionTypeTabs, BandTableBandRow, ExtraCheckList, TimingPanel, FeasibilityBanner

Tables

ApprovalPolicyVersion R/W (draft only) · ApprovalActionType R · ApproverGroup R · CustomerUsers R · Roles R

Autosave writes DefinitionJson on the draft version only. A version with Status = 3 (Active) is never updated — editing creates a new version.


S4 · Custom rule builder

Custom rule builder

Purpose. The escape hatch for controls that a threshold table cannot express. Two things make it survivable: the plain-English panel and live feasibility.

ID Requirement
AC-4.1 Condition fields, operators and value editors all come from GET /facts. Never hardcoded in React.
AC-4.2 Operators are typed by the fact's metadata — a date field cannot be given a currency comparison.
AC-4.3 The plain-English panel is always visible and updates on every keystroke.
AC-4.4 Stages added here are additive on top of the band's stages.
AC-4.5 Two rules requiring the same audience take the higher count, never the sum.
AC-4.6 No free-text formula field exists anywhere in the UI or API.
AC-4.7 Web only. Do not build this on mobile.

APIs

GET  /api/v1/approval-policies/facts
PUT  /api/v1/approval-policies/{id}/versions/{v}
POST /api/v1/approval-policies/{id}/versions/{v}/validate

React CustomRuleBuilderPageConditionListConditionRow, StageListStageRow, PlainEnglishPanel, FeasibilityBanner

Tables

ApprovalFactDefinition R · ApprovalPolicyVersion R/W (draft) · ApproverGroup R · Roles R

The condition dropdowns are populated from ApprovalFactDefinition. Adding a fact is a seed-data change plus an evaluator change — never a frontend change.


S5 · Who can approve — groups and cover

Approver groups and cover

Purpose. Rules point at panels, not people, so they survive staff turnover. This screen also solves the vacation problem — without cover, one absence stalls payroll, and that is the number one reason customers turn this feature off.

ID Requirement
AC-5.1 A group shows member count and "used by N rules".
AC-5.2 A group used by a live rule cannot be deleted without confirmation naming the rules.
AC-5.3 A group with no spare approver for a rule that uses it shows a warning.
AC-5.4 Marking a CustomerUser away removes them from every approver pool immediately and re-runs feasibility.
AC-5.5 Cover has an end date and an optional amount cap.
AC-5.6 A cover approval records both identities: "X on behalf of Y".
AC-5.7 Cover cannot be chained (A→B→C refused) and cannot enable self-approval. Enforced by CK_Delegation_NotSelf and a domain check.

APIs

GET/POST/PUT/DELETE /api/v1/approver-groups
POST/DELETE         /api/v1/approver-groups/{id}/members    { customerUserId }
GET/POST/DELETE     /api/v1/approval-delegations

React ApproverGroupsPageGroupCardMemberToggleList, CoverTable, AwayToggleRow

Tables

ApproverGroup R/W · ApproverGroupMember R/W · ApprovalDelegation R/W · CustomerUsers R · Roles R · ApprovalRequestStageApprover W (recompute)

Changing membership or cover must recompute ApprovalRequestStageApprover for every live request, then re-check feasibility.


S6 · Test before turning it on

Test before turning on

Purpose. The screen that decides adoption. A customer whose payroll gets blocked turns the feature off and never comes back.

ID Requirement
AC-6.1 The hypothetical runs the real evaluator, not an approximation.
AC-6.2 Historical replay covers that Customer's own last 90 days.
AC-6.3 Reports: total, how many would have waited, average approvals, and how many would have become unsatisfiable.
AC-6.4 Any unsatisfiable result names the stage, the reason, and offers a route to the fix.
AC-6.5 Publishing is allowed with soft warnings, blocked on a hard deadlock.

APIs

POST /api/v1/approval-policies/{id}/versions/{v}/simulate
     { mode:'hypothetical', actionKey, payload }
     { mode:'replay', days:90 }                        # long-running → job + poll

React PolicyTesterPageHypotheticalForm, SimulationResult, HistoricalReplayPanel, DeadlockWarning

Tables

ApprovalPolicyVersion R (draft) · CustomerUsers R · Roles R · ApproverGroup R · ApprovalDelegation R · your existing transaction tables R (replay)

The replay reads 90 days of real history from your existing payment tables and runs them through the draft. It writes nothing.


S7 · Review and turn on

Review and turn on

Purpose. Publishing changes how everyone in the company gets paid, so it gets a review step written as consequences, not configuration.

ID Requirement
AC-7.1 The change is shown in plain language, not as a JSON diff.
AC-7.2 An explicit "never delayed" list is shown, covering freeze, lock and lower-limit.
AC-7.3 Watch-first (shadow mode) is the default in production.
AC-7.4 A pre-written, editable announcement goes to every CustomerUser who can move money.
AC-7.5 Publishing writes an ApprovalAuditLog entry naming who, when and what changed.
AC-7.6 Publishing is blocked while validate returns any hard problem.

APIs

POST /api/v1/approval-policies/{id}/versions/{v}/publish   { mode:'watch'|'enforce', announcement }

React PublishReviewPageConsequenceDiff, NeverDelayedList, AnnouncementEditor, ModePicker

Tables

ApprovalPolicyVersion W (Draft → Active) · ApprovalPolicy W (ActiveVersionId, EnforcementMode) · ApprovalAuditLog W · ApprovalOutboxMessage W (announcement)

Publishing supersedes the previous version (Status = 4) rather than deleting it. One transaction, three writes plus the outbox row.


S8 · Turned on

Turned on

Confirmation plus an explicit hand-off to the next step. AC-8.1 Never leave the owner on a dead-end success screen.

Tables

ApprovalPolicy R · ApprovalPolicyVersion R


S9 · Live rules summary

Live rules summary

Read-only view of what is in force, per action type, with routes to groups, version history and the audit log.

AC-9.1 In watch mode, a persistent banner shows the count recorded so far and a one-click switch to enforcing.

Tables

ApprovalPolicy R · ApprovalPolicyVersion R (Active) · ApprovalActionType R · ApprovalRequest R (shadow counts)


S10 · Wallet — the balance now means three things

Wallet with held balance

Purpose. This is where the feature reaches into screens you already have, and where the most support tickets come from if you get it wrong.

ID Requirement
AC-10.1 Every place a balance appears shows ledger / held / available separately.
AC-10.2 Waiting requests appear in activity lists with a distinct badge.
AC-10.3 Waiting rows are excluded from statements, exports, reconciliation and spend totals.
AC-10.4 A waiting item is never rendered as though it had settled.

Implementation warning. Balance used to mean one thing and now means three. Rename the raw property to LedgerBalance and let the compiler find every reader, then triage each: display → available, reconciliation → ledger, risk → available. This is the most error-prone part of the retrofit.

APIs

GET /api/v1/wallets/{id}    → { ledgerBalance, heldForApproval, available, holds[] }
GET /api/v1/wallets/{id}/activity   # union: real transactions ∪ live approval requests

React WalletPageBalanceBreakdown, ActivityTablePendingRow

Tables

FundsHold R · ApprovalRequest R (live only) · your existing wallet/ledger/transaction tables R

The activity list is a union: real transactions ∪ ApprovalRequest rows with Status IN (1 Pending, 2 InfoRequested, 3 Approved, 4 Executing). Available balance = ledger − SUM(FundsHold.Amount WHERE Status = 1).


S11 · Send money — the highest-value screen in the feature

Send form with live preview

Purpose. The requirement and the actual names are shown before the person commits. Note the button: "Send for approval", not "Send". Cheapest change in the project, biggest effect on whether people understand what they just did.

ID Requirement
AC-11.1 The requirement appears before submission, debounced 400ms on every input change.
AC-11.2 Named approvers, not a count. "Anand Rao, Sunita Desai or Rahul Verma".
AC-11.3 Crossing a band while typing visibly changes the panel.
AC-11.4 "Why does this need approval?" present, collapsed, plain language.
AC-11.5 Button reads "Send for approval" when gated, "Send" when not.
AC-11.6 Justification field appears only when approval is required.
AC-11.7 Available balance is net of existing holds.
AC-11.8 Validation runs before the gate. Never collect approvals on a payment that cannot succeed.
AC-11.9 An unsatisfiable requirement is refused at submission with an explanation, not created and left stuck.

APIs

POST /api/v1/approvals/preview   { actionKey, payload }
  → { willRequireApproval, totalRequired,
      stages:[{ name, requiredCount, eligibleApprovers:[{customerUserId,fullName}] }],
      reasons:[], expiresInHours, unsatisfiable:[{stageName,reason}] }

POST /api/v1/wallets/{id}/withdrawals
  → 200 { outcome:'Completed', resultId }
  → 202 { outcome:'PendingApproval', approval:{...} }

React SendMoneyPageApprovalPreviewPanel(build once, embed on every action form)

Tables

Preview: ApprovalPolicy R · ApprovalPolicyVersion R · ApprovalFactDefinition R · CustomerUsers R · Roles R · ApproverGroup R · ApprovalDelegation R · FundsHold R

On submit: ApprovalRequest W · ApprovalRequestStage W · ApprovalRequestStageApprover W · FundsHold W · ApprovalAuditLog W · ApprovalOutboxMessage W

Preview writes nothing. Submit is one transaction across six tables — the funds hold and the outbox row must commit with the request or you get orphaned holds and silent approvers.


S12 · Sent for approval

Sent for approval

ID Requirement
AC-12.1 Never uses "sent", "paid" or "complete" for the underlying action.
AC-12.2 Shows the reference number, copyable.
AC-12.3 Names the first-stage approvers.
AC-12.4 States explicitly that funds are held — pre-empts the "my balance is wrong" ticket.

React SentForApprovalPagePendingConfirmation, NextStepHint

Tables

ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R · CustomerUsers R


S13 · Approvals inbox

Approvals inbox

ID Requirement
AC-13.1 Default tab "Waiting for you"; live count badge on the nav item.
AC-13.2 Sorted by soonest expiry, not newest. An expiring payment is the actionable risk.
AC-13.3 Time remaining colour-shifts under six hours.
AC-13.4 Risk signals visible in the row ("new destination").
AC-13.5 Bulk approve: one action type, below an owner-set cap, each recorded individually, blocked for the top tier.

APIs

GET  /api/v1/approvals?filter=awaiting-me|raised-by-me|all-pending|closed&sort=expiring&cursor=
GET  /api/v1/approvals/counts
POST /api/v1/approvals/bulk-approve  { ids[], stepUpToken }

React ApprovalInboxPageInboxTabs, RequestTable, BulkApproveBar, ExpiryCountdown

Tables

ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R · ApprovalDecision R · CustomerUsers R · ApprovalDelegation R

The inbox query is ApprovalRequestStageApprover joined to open stages — that is why the table is materialised. Do not resolve roles and groups per row at read time.


S14 · Request detail — the approver's view

Request detail — can approve

Purpose. Enough to make a real decision, with the risk signal that fired the rule shown next to the field it concerns. "Ask a question" matters: without it, an approver with a doubt has only Decline, which kills the request and forces a re-raise.

ID Requirement
AC-14.1 Full payload, plus the initiator's note and attachments.
AC-14.2 Risk flags inline, not in a separate panel.
AC-14.3 Three outcomes: Approve, Decline, Ask a question.
AC-14.4 Declining requires a reason.
AC-14.5 Read access limited to initiator, eligible approvers, owner and auditors. Others get 404, not 403 — do not confirm existence.

Tables

ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R · ApprovalDecision R · ApprovalRequestThread R · CustomerUsers R · Roles R


S15 · Confirming with step-up authentication

Approve confirmation

ID Requirement
AC-15.1 Every approval and decline requires step-up auth.
AC-15.2 The confirmation restates the amount and destination.
AC-15.3 The prompt names what is being approved — never a generic "Authenticate". A bare prompt trains reflexive approval, which is the behaviour this feature exists to prevent.
AC-15.4 The token is short-lived (2 min), single-use, and bound to this specific request id.
AC-15.5 The screen states what will be recorded.
AC-15.6 No optimistic UI. An approval is a legal record and the server may legitimately refuse it.

APIs

POST /api/v1/approvals/{id}/step-up/challenge  → { challengeId, nonce, expiresAt }
POST /api/v1/approvals/{id}/step-up/verify     → { stepUpToken }
POST /api/v1/approvals/{id}/approve            { comment?, stepUpToken }
                                               Header: Idempotency-Key

React ApproveModal, StepUpPrompt

Tables

ApprovalDecision W (append only) · ApprovalRequestStage W (ApprovedCount, Status) · ApprovalRequest W (counters, Status) · ApprovalAuditLog W · ApprovalOutboxMessage W · FundsHold W (on final approval → Captured)

One transaction under UPDLOCK on ApprovalRequest. UQ_Decision_OnePerStage makes a duplicate approval physically impossible even if the lock and rowversion both fail.


S16 · Where it stands — the core screen

Where it stands 2 of 3

Everything else in this feature exists to make this screen truthful. 2 of 3, waiting on Nadia Rahman, expires Thursday 06:52. That is the answer to "where is it".

ID Requirement
AC-16.1 "X of Y approvals" with a progress bar.
AC-16.2 Per-stage breakdown: complete / in progress / locked.
AC-16.3 Names the specific people being waited on, with a Remind button.
AC-16.4 Each approval shows time, auth method and location.
AC-16.5 Expiry as countdown and absolute time in Customer.TimeZone.
AC-16.6 Plain-language reasons the request was gated.
AC-16.7 Warns that editing clears approvals — before Edit is clicked.
AC-16.8 Updates live without refresh.

APIs

GET  /api/v1/approvals/{id}
GET  /api/v1/approvals/{id}/timeline
POST /api/v1/approvals/{id}/remind   { customerUserIds[] }     # rate-limited
POST /api/v1/approvals/{id}/cancel                              # initiator only
SignalR: ApprovalRecorded, StageCompleted, RequestApproved, RequestDeclined, RequestExpired

React RequestDetailPageApprovalTracker ★, RequestPayload, WhyRequired, DecisionPanel

Tables

ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R/W (LastRemindedAtUtc) · ApprovalDecision R · ApprovalRequestThread R · CustomerUsers R

Remind writes only LastRemindedAtUtc and enqueues an outbox row. It never touches the decision tables.


S17 · Cannot approve — and why

Cannot approve and why

Purpose. Four-eyes made visible. The initiator sees the request but no Approve control — not a disabled one — and a sentence explaining why, plus who can.

ID Requirement
AC-17.1 The viewer block is computed server-side. The client never decides who may approve.
AC-17.2 Blocked reasons are distinguished: Initiator, AlreadyApproved, StageLocked, NotEligible, Closed.
AC-17.3 Each renders a specific sentence, never a generic "not permitted".
AC-17.4 The initiator sees Edit and Cancel instead, with the warning that editing clears approvals.
"viewer": {
  "canApprove": false, "canDecline": false, "canCancel": true, "canOverride": false,
  "blockedReason": "Initiator",
  "blockedExplanation": "You raised this, so you cannot approve it. That is the whole point of the control."
}

Tables

ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R · ApprovalDecision R

The viewer block is derived from these four tables server-side. The client is never sent the eligibility rules.


S18 · Completed — the audit record

Completed with full record

Purpose. The closed loop and the evidence in one place. This is the screen you show a compliance officer.

ID Requirement
AC-18.1 One unbroken timeline from raised to settled.
AC-18.2 Each decision shows who, when, how they authenticated, and from where.
AC-18.3 Shows elapsed time from raised to done — feeds the approver responsiveness report.
AC-18.4 One-click PDF evidence pack.
AC-18.5 The record is append-only. Nobody — including support and engineering — can edit or delete a decision.

Tables

ApprovalRequest R · ApprovalDecision R (full chain) · ApprovalAuditLog R · CustomerUsers R · Roles R

The PDF evidence pack is generated from ApprovalDecision plus ApprovalAuditLog, including PreviousDecisionHash / DecisionHash so an auditor can verify the chain.


S19 · Cards — freeze vs unfreeze

Cards freeze vs unfreeze

Purpose. Demonstrates the asymmetry that teams most often get backwards.

ID Requirement
AC-19.1 Freeze executes immediately. It carries no [RequiresApproval] attribute, enforced by an automated test.
AC-19.2 Unfreeze creates an approval request.
AC-19.3 The UI states the reason for the asymmetry, so it does not read as a bug.

APIs

POST /api/v1/cards/{id}/freeze     → 200 always. Never 202.
POST /api/v1/cards/{id}/unfreeze   → 200 | 202
POST /api/v1/cards                 → 200 | 202

Tables

Freeze: your existing card tables W only — no Approval* table is touched.

Unfreeze / issue: ApprovalRequest W · ApprovalRequestStage W · ApprovalRequestStageApprover W · ApprovalOutboxMessage W, then your card tables W on execution.

The asymmetry is visible in the data path: a freeze never creates a row in this feature's schema.


S20 · Team — role changes are gated too

Team role changes gated

Purpose. Closes the privilege-escalation path. Without this, a compromised admin simply grants themselves the permission they need and every other control is decoration.

ID Requirement
AC-20.1 Changing a CustomerUser's RoleId goes through the same gate as a withdrawal.
AC-20.2 On execution, the CustomerUsers.RoleId is actually updated and the person's eligibility changes immediately.
AC-20.3 Inviting and removing a CustomerUser are equally gated.

APIs

PUT  /api/v1/customer-users/{id}/role   { roleId }   → 200 | 202
POST /api/v1/customer-users/invite                   → 200 | 202
DELETE /api/v1/customer-users/{id}                   → 200 | 202

Tables

ApprovalRequest W · ApprovalRequestStage W · ApprovalRequestStageApprover W · CustomerUsers W (RoleId, on execution only) · Roles R · ApprovalAuditLog W

CustomerUsers.RoleId is written only by the executor after full approval. On execution, re-run EligibilityRefresher — the person's approver pools change immediately.


S21 · Audit log

Audit log

ID Requirement
AC-21.1 Append-only. No edit or delete anywhere in API or UI.
AC-21.2 Every entry: actor, role at the time, action, timestamp, before/after, IP, device, auth method.
AC-21.3 One-click PDF evidence pack for a date range.
AC-21.4 Overrides and declines are visually distinct.
AC-21.5 An Auditor role sees this read-only, scoped by date.

APIs

GET /api/v1/approvals/audit?from=&to=&format=json|csv|pdf
GET /api/v1/approvals/reports/ageing | approver-activity | overrides

Tables

ApprovalAuditLog R · ApprovalDecision R · ApprovalRequest R · CustomerUsers R

Read-only. The application principal holds INSERT/SELECT on these two tables and nothing else.


S22 · Declined

Declined state

AC-22.1 One decline is terminal. No decline quorum — a concern must not be outvotable. AC-22.2 Reason mandatory, shown to the initiator. AC-22.3 The funds hold is released. AC-22.4 The initiator may raise a new request; the declined one is never reopened.

Tables

ApprovalDecision W (DecisionType = 2) · ApprovalRequest W (Status, ClosedAtUtc, ClosureReason) · FundsHold W (→ Released) · ApprovalAuditLog W · ApprovalOutboxMessage W


S23 · Expired

Expired state

AC-23.1 Reminders fire every RemindEveryHours until expiry. AC-23.2 On expiry: release the hold, notify initiator and approvers, escalate to the owner on repeated expiries. AC-23.3 Every expiry is a failed business process — owners get a weekly digest.

Tables

ApprovalRequest W (Status, ClosedAtUtc) · FundsHold W (→ Released) · ApprovalAuditLog W · ApprovalOutboxMessage W

Written by ApprovalExpiryWorker, not by a request. ActorCustomerUserId is NULL — the audit entry is a system event.


S24 · Question asked — the clock pauses

Question asked clock paused

AC-24.1 "Ask a question" returns the request to the initiator without killing it. AC-24.2 Approvals already given remain valid. AC-24.3 The expiry clock pauses while the question is open and resumes on answer, shifting ExpiresAtUtc by the paused duration.

Tables

ApprovalRequestThread W · ApprovalRequest W (Status = 2, ClockPausedAtUtc) · ApprovalDecision W (DecisionType 3/4) · ApprovalOutboxMessage W

On answer, ExpiresAtUtc += (now − ClockPausedAtUtc) and ClockPausedAtUtc is cleared. Existing approvals are untouched.


S25 · Version history

Version history

AC-25.1 Versions are immutable. Editing creates a new one. AC-25.2 A pending request keeps the version it was raised under, so an approver always sees the rules that actually applied to it. AC-25.3 One-click revert creates a new version equal to the old one — it never mutates history.


8. API reference

8.1 Conventions

8.2 Endpoint list

# ── Preview (highest-value endpoint) ─────────────────────
POST   /approvals/preview

# ── Inbox ───────────────────────────────────────────────
GET    /approvals?filter=&sort=&cursor=&limit=
GET    /approvals/counts
GET    /approvals/{id}
GET    /approvals/{id}/timeline

# ── Decisions (Idempotency-Key + stepUpToken) ───────────
POST   /approvals/{id}/step-up/challenge
POST   /approvals/{id}/step-up/verify
POST   /approvals/{id}/approve       { comment?, stepUpToken }
POST   /approvals/{id}/decline       { reason,   stepUpToken }
POST   /approvals/{id}/ask           { question }
POST   /approvals/{id}/answer        { response }
POST   /approvals/{id}/cancel        { reason? }
POST   /approvals/{id}/remind        { customerUserIds[] }
POST   /approvals/bulk-approve       { ids[], stepUpToken }
POST   /approvals/{id}/override      { justification, stepUpToken }

# ── Policy ──────────────────────────────────────────────
GET    /approval-policies
GET    /approval-policies/facts
GET    /approval-policies/action-types
GET    /approval-policies/templates
GET    /approval-policies/recommendation
POST   /approval-policies/{id}/versions
PUT    /approval-policies/{id}/versions/{v}
POST   /approval-policies/{id}/versions/{v}/validate
POST   /approval-policies/{id}/versions/{v}/simulate
POST   /approval-policies/{id}/versions/{v}/publish
GET    /approval-policies/{id}/versions
POST   /approval-policies/{id}/versions/{v}/revert

# ── Groups and cover ────────────────────────────────────
GET/POST/PUT/DELETE  /approver-groups
POST/DELETE          /approver-groups/{id}/members
GET/POST/DELETE      /approval-delegations

# ── Audit and reports ───────────────────────────────────
GET    /approvals/audit?from=&to=&format=
GET    /approvals/reports/ageing
GET    /approvals/reports/approver-activity
GET    /approvals/reports/overrides
GET    /approvals/reports/exposure?days=

8.3 Key payloads

POST /approvals/preview

// request
{ "actionKey": "CryptoWithdrawal",
  "payload": { "walletId":"…", "asset":"ETH", "amount":12.5, "destinationId":"…" } }

// response
{ "willRequireApproval": true,
  "totalRequired": 3,
  "expiresInHours": 48,
  "stages": [
    { "order":1, "name":"Finance",  "requiredCount":1,
      "eligibleApprovers":[{"customerUserId":"…","fullName":"Anand Rao"},
                           {"customerUserId":"…","fullName":"Sunita Desai"}] },
    { "order":2, "name":"Director", "requiredCount":1, "eligibleApprovers":[…] },
    { "order":3, "name":"Owner",    "requiredCount":1, "eligibleApprovers":[…] }
  ],
  "reasons": [
    "$25,000 – $250,000 → 1 × Finance, then 1 × Director",
    "Destination added in the last 24 hours → 1 × Owner"
  ],
  "unsatisfiable": [] }

GET /approvals/{id} — the viewer block is computed server-side

{ "id":"…", "reference":"WD-2026-004871",
  "actionKey":"CryptoWithdrawal", "actionLabel":"Crypto withdrawal",
  "status":"PendingApproval",
  "title":"12.5 ETH to Nordwind Systems",
  "detail":[["Amount","12.5 ETH"],["Value","$41,250"],["To","Nordwind Systems"],
            ["Address","0x7a3f4b2e…9c21"],["Added","6 hours ago"]],
  "initiator":{"customerUserId":"…","fullName":"Priya Sharma","roleName":"Finance Analyst"},
  "initiatedAtUtc":"2026-08-01T06:52:00Z",
  "approvalsGiven":2, "approvalsRequired":3,
  "expiresAtUtc":"2026-08-03T06:52:00Z", "clockPausedAtUtc":null,
  "stages":[
    { "order":1,"name":"Finance","requiredCount":1,"approvedCount":1,"status":"Complete",
      "approvedBy":[{"customerUser":{"fullName":"Anand Rao"},"decidedAtUtc":"…",
                     "comment":"Verified against invoice NW-2026-0331.",
                     "authMethod":"Biometric","location":"Chennai","onBehalfOf":null}],
      "pendingApprovers":[] },
    { "order":3,"name":"Owner","requiredCount":1,"approvedCount":0,"status":"Open",
      "approvedBy":[], "pendingApprovers":[{"customerUserId":"…","fullName":"Nadia Rahman"}] }
  ],
  "reasons":["$25,000 – $250,000 → 1 × Finance, then 1 × Director",
             "Destination added in the last 24 hours → 1 × Owner"],
  "thread":[],
  "viewer":{ "canApprove":false,"canDecline":false,"canAsk":false,
             "canCancel":true,"canOverride":false,
             "blockedReason":"Initiator",
             "blockedExplanation":"You raised this, so you cannot approve it." } }

8.4 Error contract

Errors render as information, not failures. A user half a second late must see an explanation, not a red box.

{ "code":"APPROVAL_ALREADY_COMPLETED",
  "message":"This was fully approved by Meera Iyer at 15:02.",
  "currentStatus":"Executed", "approvalsGiven":3, "approvalsRequired":3 }
Code HTTP UI treatment
APPROVAL_ALREADY_COMPLETED 409 Info toast, refetch, back to inbox
APPROVAL_ALREADY_GIVEN 409 Info toast
APPROVAL_EXPIRED 409 Warning, offer to raise again
SELF_APPROVAL_NOT_PERMITTED 403 Unreachable — the button is not rendered. Log if it fires.
NOT_ELIGIBLE_FOR_STAGE 403 Sentence naming the required role
STAGE_LOCKED 409 Explain which stage must finish first
STEP_UP_REQUIRED 401 Re-prompt
POLICY_UNSATISFIABLE 422 Block publish, name the stage and the fix
INSUFFICIENT_AVAILABLE_BALANCE 422 Show the hold breakdown
REASON_REQUIRED 400 Inline field error

9. .NET Core backend structure

9.1 The central architectural decision

Where does the approval gate sit?

Option Verdict
In the UI Unacceptable. Bypassed by any direct API call.
In each service method Rejected. Requires touching dozens of existing paths, and a module added in six months will ship without a gate and nobody will notice. Enforcement that depends on remembering is not enforcement.
In the command pipeline Recommended. One enforcement point, correct by construction for every current and future action.

Every state-changing action becomes a command. One pipeline stage sits between the API layer and the handler. It inspects the command, evaluates policy, and either lets it through or intercepts it — serialising the command, persisting it as a pending ApprovalRequest, and returning "pending approval" to the caller. When approvals complete, the stored command is deserialised and dispatched to the same handler it would have reached originally.

HTTP request
    │
    ▼
Controller  →  maps to a Command
    │
    ▼
┌──────────────────────────────────────────────────────┐
│  MediatR pipeline                                     │
│    1. Validation                                      │
│    2. Authorisation (existing Roles/RBAC)             │
│    3. Preflight  ← balance, beneficiary, limits       │
│    4. ► APPROVAL GATE ◄            (new)              │
│    5. Unit of work                                    │
│    6. Handler  ← EXISTING business logic, unchanged   │
└──────────────────────────────────────────────────────┘

Preflight must run before the gate. Otherwise: user submits an invalid withdrawal → gate creates a request → three people approve over two days → execution fails validation. You have burned three executives' time on a request that was never valid.

9.2 Project layout

src/
├── Meridian.Approvals.Domain/
│   ├── Entities/
│   │   ├── ApprovalRequest.cs          ← the aggregate root; ALL invariants live here
│   │   ├── ApprovalRequestStage.cs
│   │   ├── ApprovalDecision.cs
│   │   ├── ApprovalPolicy.cs
│   │   └── ApprovalPolicyVersion.cs
│   ├── ValueObjects/
│   │   ├── PolicyDefinition.cs
│   │   ├── StageSpec.cs
│   │   ├── Eligibility.cs
│   │   └── ApprovalFacts.cs
│   ├── Rules/
│   │   ├── IRuleEvaluator.cs
│   │   ├── RuleEvaluator.cs            ← PURE. No I/O. Unit-testable without a DB.
│   │   ├── ConditionTester.cs
│   │   └── RequirementMerger.cs        ← union + strictest-wins
│   ├── Events/                          ← domain events → outbox
│   └── Exceptions/
│
├── Meridian.Approvals.Application/
│   ├── Abstractions/
│   │   ├── IApprovalGate.cs
│   │   ├── IApproverResolver.cs
│   │   ├── IApprovalFactBuilder.cs
│   │   ├── IApprovalExecutionContext.cs
│   │   └── IApprovalCommandRegistry.cs
│   ├── Behaviors/
│   │   ├── PreflightBehavior.cs
│   │   └── ApprovalGateBehavior.cs      ← THE enforcement point
│   ├── Requests/                        ← Create, Approve, Decline, Ask, Cancel, Override
│   ├── Policies/                        ← Draft, Validate, Simulate, Publish
│   └── Queries/                         ← Inbox, Detail, Timeline, Reports
│
├── Meridian.Approvals.Infrastructure/
│   ├── Persistence/
│   │   ├── ApprovalsDbContext.cs
│   │   ├── Configurations/              ← EF configs, incl. the CustomerId query filter
│   │   └── Migrations/
│   ├── Resolvers/ApproverResolver.cs    ← joins CustomerUsers + Roles + groups + cover
│   ├── Facts/ApprovalFactBuilder.cs     ← parallel fan-out; budget < 100ms p95
│   ├── Execution/ApprovedRequestExecutor.cs
│   ├── Workers/
│   │   ├── ApprovalExpiryWorker.cs      ← expiry, reminders, escalation
│   │   ├── OutboxDispatcher.cs
│   │   └── EligibilityRefresher.cs
│   ├── Audit/HashChainedAuditWriter.cs
│   └── Realtime/ApprovalHub.cs          ← SignalR
│
└── Meridian.Api/
    └── Controllers/
        ├── ApprovalsController.cs
        ├── ApprovalPoliciesController.cs
        ├── ApproverGroupsController.cs
        └── ApprovalDelegationsController.cs

9.3 Marking a command approvable

[AttributeUsage(AttributeTargets.Class)]
public sealed class RequiresApprovalAttribute(string actionKey, string category) : Attribute
{
    public string ActionKey { get; } = actionKey;
    public string Category  { get; } = category;
}

public interface IApprovableCommand
{
    Guid CustomerId { get; }
    ApprovalSubject GetSubject();
}

[RequiresApproval("CryptoWithdrawal", "MoneyOut")]
public sealed record CreateCryptoWithdrawalCommand : IRequest<ActionResult>, IApprovableCommand
{
    public Guid CustomerId { get; init; }
    public Guid WalletId { get; init; }
    public string Asset { get; init; } = default!;
    public decimal Amount { get; init; }
    public Guid DestinationId { get; init; }
    public string? Justification { get; init; }

    public ApprovalSubject GetSubject() => new()
    {
        Amount            = Amount,
        CurrencyCode      = Asset,
        SourceWalletId    = WalletId,
        ResourceId        = DestinationId,
        Title             = $"{Amount} {Asset}",
        DisplayFields     = new Dictionary<string, string>
        {
            ["Amount"] = $"{Amount} {Asset}",
            ["Asset"]  = Asset
        }
    };
}

Wrapper handler — the existing service is not touched:

public sealed class CreateCryptoWithdrawalHandler(
    IWithdrawalService service,                   // EXISTING, unchanged
    IApprovalExecutionContext exec)
    : IRequestHandler<CreateCryptoWithdrawalCommand, ActionResult>
{
    public async Task<ActionResult> Handle(CreateCryptoWithdrawalCommand cmd, CancellationToken ct)
    {
        var dto = await service.CreateAsync(
            cmd.ToServiceRequest(),
            idempotencyKey: exec.CurrentIdempotencyKey,   // enables safe retry
            ct);

        return ActionResult.Completed(dto.Id);
    }
}

9.4 The gate

public sealed class ApprovalGateBehavior<TRequest, TResponse>(
    IApprovalGate gate, IApprovalExecutionContext exec)
    : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    public async Task<TResponse> Handle(TRequest request,
        RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        // Re-entry: executing an already-approved request, or a nested/system call.
        if (exec.IsExecutingApprovedRequest || exec.IsInsideGatedOperation || exec.IsSystemInitiated)
            return await next();

        var attr = typeof(TRequest).GetCustomAttribute<RequiresApprovalAttribute>();
        if (attr is null || request is not IApprovableCommand cmd)
            return await next();

        var decision = await gate.EvaluateAsync(cmd, attr, ct);

        return decision.Kind switch
        {
            GateDecisionKind.NoApprovalRequired => await next(),
            GateDecisionKind.ShadowLoggedOnly   => await next(),      // watch mode
            GateDecisionKind.ApprovalRequired   =>
                ToPending<TResponse>(await gate.CreatePendingRequestAsync(cmd, attr, decision.Requirement!, ct)),
            _ => throw new UnreachableException()
        };
    }
}

IApprovalExecutionContext must be backed by AsyncLocal, not HttpContext. Approved execution runs in the outbox consumer, which has no HttpContext — so the gate would fire again and you would get an infinite loop of approval requests creating approval requests.

9.5 The aggregate — where every invariant lives

public sealed class ApprovalRequest
{
    public void Approve(ApproverContext ctx, string? comment, DateTime nowUtc)
    {
        if (Status is not (ApprovalRequestStatus.PendingApproval or ApprovalRequestStatus.InfoRequested))
            throw new ApprovalConflictException($"Request is {Status}.", Status);

        if (ExpiresAtUtc is { } exp && ClockPausedAtUtc is null && nowUtc >= exp)
            throw new ApprovalExpiredException(Id);

        // BR-01 four eyes. Absolute.
        if (ctx.CustomerUserId == InitiatedByCustomerUserId)
            throw new SelfApprovalNotPermittedException(Id);

        // Cover must not create a self-approval loop.
        if (ctx.OnBehalfOfCustomerUserId == InitiatedByCustomerUserId)
            throw new SelfApprovalNotPermittedException(Id);

        var stage = _stages.SingleOrDefault(s => s.Status == StageStatus.Open && s.IsEligible(ctx.CustomerUserId))
            ?? throw new NotEligibleForStageException(Id, ctx.CustomerUserId);

        if (_decisions.Any(d => d.StageId == stage.Id
                             && d.DecidedByCustomerUserId == ctx.CustomerUserId
                             && d.DecisionType == DecisionType.Approve && !d.IsInvalidated))
            throw new AlreadyApprovedException(Id, ctx.CustomerUserId);

        if (!ctx.StepUpVerified)
            throw new StepUpRequiredException(Id);

        _decisions.Add(ApprovalDecision.Approve(Id, stage, ctx, comment, nowUtc,
            previousHash: _decisions.LastOrDefault()?.DecisionHash));
        stage.RecordApproval();
        TotalApprovalsGiven++;

        if (stage.IsComplete) { stage.Complete(nowUtc); OpenNextStages(nowUtc); }
        if (_stages.All(s => s.Status is StageStatus.Complete or StageStatus.Skipped))
            TransitionToApproved(nowUtc);
    }

    public void Decline(ApproverContext ctx, string reason, DateTime nowUtc)
    {
        if (string.IsNullOrWhiteSpace(reason)) throw new ReasonRequiredException(Id);
        // ONE decline is terminal. No decline quorum — a concern must not be outvotable.
        Status = ApprovalRequestStatus.Declined;
        ClosedAtUtc = nowUtc; ClosureReason = reason;
        _events.Add(new ReleaseFundsHoldEvent(Id, FundsHoldId));
    }

    /// Editing destroys every approval collected so far and forces re-evaluation.
    /// Non-negotiable — this is the primary bypass vector.
    public void InvalidateApprovalsForAmendment(string reason, DateTime nowUtc)
    {
        foreach (var d in _decisions.Where(d => d.DecisionType == DecisionType.Approve && !d.IsInvalidated))
            d.Invalidate(reason);
        foreach (var s in _stages) s.Reset();
        TotalApprovalsGiven = 0; CurrentStageOrder = 1;
        Status = ApprovalRequestStatus.PendingApproval;
    }
}

9.6 Concurrency — two approvers at the same instant

Three redundant layers on the correctness-critical path:

await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);

// 1) pessimistic row lock — short-lived, removes the retry storm
var request = await _db.ApprovalRequests
    .FromSqlInterpolated($@"SELECT * FROM ApprovalRequest WITH (UPDLOCK, ROWLOCK)
                            WHERE ApprovalRequestId = {cmd.ApprovalRequestId}")
    .Include(r => r.Stages).Include(r => r.Decisions)
    .SingleAsync(ct);

request.Approve(ctx, cmd.Comment, _clock.UtcNow);

foreach (var e in request.DequeueEvents())
    _db.OutboxMessages.Add(ApprovalOutboxMessage.From(e, _clock.UtcNow));

await _db.SaveChangesAsync(ct);   // 2) ROWVERSION optimistic concurrency
await tx.CommitAsync(ct);
// 3) UQ_Decision_OnePerStage makes a duplicate physically impossible

Execution runs from the outbox consumer, not inline in the approving user's HTTP request. Otherwise a slow payment rail makes the approver's app spin and a timeout leaves ambiguous state.

9.7 Background workers

Worker Cadence Job
ApprovalExpiryWorker 30s Expire, release holds, notify
Reminder sender 30s Nudge pending approvers
Escalation 30s Widen the eligible pool per policy
OutboxDispatcher 1–2s Publish domain events, run execution
Execution retry 1min Retry retryable failures with backoff
EligibilityRefresher on CustomerUsers/Roles change Recompute ApprovalRequestStageApprover; detect newly-unsatisfiable requests
Feasibility auditor nightly Flag policies that have become unsatisfiable

Run on a single leader or take a distributed lock. Otherwise every replica expires the same requests and sends duplicate notifications.

9.8 Command payload versioning — the highest-probability incident

A CreateCryptoWithdrawalCommand serialised on Monday may not deserialise on Thursday after a deploy. This will happen, and it will happen to a pending high-value payment.

  1. Store CommandSchemaVersion. Bump on any shape change.
  2. Additive changes only. New properties optional with a safe default. Never rename, remove, retype, or change the meaning of a field.
  3. Use CommandTypeKey against a registry, not Type.GetType(assemblyQualifiedName) — that breaks on version bump, assembly rename or moving the class.
  4. Breaking change unavoidable → ship an IApprovalPayloadUpcaster in the same PR.
  5. A CI test deserialises a stored corpus of golden payloads against the current build and fails on any error. This test is the actual protection.
  6. On deserialisation failure at execution: move to RequiresManualIntervention, alert engineering, keep the hold, tell the customer. Never fail silently.

10. React frontend structure

10.1 Folder layout

src/
├── features/approvals/
│   ├── api/
│   │   ├── generated/                  ← NSwag/Kiota output. DO NOT hand-edit.
│   │   ├── queryKeys.ts
│   │   └── hooks/
│   │       ├── useApprovalPreview.ts
│   │       ├── useApprovalInbox.ts
│   │       ├── useApprovalDetail.ts
│   │       ├── useApproveRequest.ts
│   │       └── useApprovalRealtime.ts
│   ├── components/
│   │   ├── ApprovalPreviewPanel.tsx    ★ embed on EVERY action form
│   │   ├── ApprovalTracker.tsx         ★ the stage timeline
│   │   ├── ExpiryCountdown.tsx
│   │   ├── WhyRequired.tsx
│   │   ├── PendingBadge.tsx
│   │   ├── BalanceBreakdown.tsx
│   │   ├── DecisionPanel.tsx
│   │   └── modals/{Approve,Decline,Ask,Answer,Override}Modal.tsx
│   ├── pages/
│   │   ├── ApprovalInboxPage.tsx
│   │   ├── RequestDetailPage.tsx
│   │   └── AuditLogPage.tsx
│   └── settings/
│       ├── ApprovalsSettingsPage.tsx
│       ├── TemplateGalleryPage.tsx
│       ├── RuleEditorPage.tsx
│       ├── CustomRuleBuilderPage.tsx
│       ├── ApproverGroupsPage.tsx
│       ├── PolicyTesterPage.tsx
│       ├── PublishReviewPage.tsx
│       └── VersionHistoryPage.tsx
└── shared/hooks/useActionResult.ts     ← handles 200 vs 202 in ONE place

10.2 Types — generate, do not hand-write

# package.json
"scripts": {
  "api:generate": "nswag openapi2tsclient /input:http://localhost:5000/swagger/v1/swagger.json /output:src/features/approvals/api/generated/client.ts /template:Axios"
}

Drift between .NET and TypeScript is the failure mode. Generate on every build.

// The one type that forces every call site to handle deferral.
export type ActionResult =
  | { outcome: 'Completed';       resultId: string }
  | { outcome: 'PendingApproval'; approval: ApprovalPendingInfo };

Make this a discriminated union. Every existing call site then fails to compile until it handles the pending branch. That is how you find all of them — not by grepping for success toasts and hoping.

10.3 The two components that must be built once

ApprovalPreviewPanel — one prop pair, dropped onto every action form.

export function ApprovalPreviewPanel({ actionKey, payload }: {
  actionKey: ActionKey; payload: unknown;
}) {
  const debounced = useDebounce(payload, 400);
  const { data: preview } = useQuery({
    queryKey: ['approvals','preview',actionKey,debounced],
    queryFn: () => api.previewApproval({ actionKey, payload: debounced }),
    enabled: isComplete(debounced),
    staleTime: 30_000,
  });

  if (!preview) return null;
  if (!preview.willRequireApproval)
    return <Note variant="ok">No approval needed — this goes straight through.</Note>;

  return (
    <Card variant="warning">
      <Badge>Needs approval</Badge>
      <h3>This will wait for {preview.totalRequired} people</h3>
      <StageList stages={preview.stages} />   {/* NAMES, not counts */}
      {preview.unsatisfiable.length > 0 && (
        <Note variant="error">
          <b>Nobody could approve this.</b> {preview.unsatisfiable[0].reason}
        </Note>
      )}
      <details><summary>Why does this need approval?</summary>
        <ul>{preview.reasons.map(r => <li key={r}>{r}</li>)}</ul>
      </details>
    </Card>
  );
}

ApprovalTracker — the stage timeline used on the detail screen and in the preview.

10.4 Data layer rules

// The socket INVALIDATES the cache. It never writes into it.
// Trusting socket payloads as the source of truth produces bugs that appear
// only when messages arrive out of order.
connection.on('ApprovalRecorded', e => {
  qc.invalidateQueries({ queryKey: ['approvals'] });
  qc.invalidateQueries({ queryKey: ['approvals','detail', e.requestId] });
});
connection.onreconnected(() => qc.invalidateQueries({ queryKey: ['approvals'] }));

No optimistic updates on approve or decline. An approval is a legal record and the server may legitimately refuse it — someone else just completed it, it expired a second ago, the approver's role changed. Showing "approved" then reverting is worse than a 400ms spinner.

Handle 202 in one place:

export function useActionResult() {
  const navigate = useNavigate();
  return useCallback((result: ActionResult) => {
    if (result.outcome === 'PendingApproval') {
      const a = result.approval;
      toast.info(`Submitted for approval (${a.reference}). ${a.approvalsRequired} needed.`,
        { action: { label:'Track', onClick: () => navigate(`/approvals/${a.id}`) } });
      return { done: false, approvalId: a.id };
    }
    return { done: true, resultId: result.resultId };
  }, [navigate]);
}

10.5 Frontend rules that are not negotiable

Rule Why
The client never decides who may approve — render viewer.canApprove Otherwise the rules are readable in the JS bundle and enforceable only by a cooperative client
The initiator sees no Approve control, not a disabled one A disabled button invites "why can't I?" tickets
Button label switches to "Send for approval" Cheapest change with the biggest comprehension effect
Available balance is always net of holds, with a breakdown Otherwise: "my balance is wrong" tickets on day one
Pending rows appear in lists, excluded from statements Otherwise: "my money vanished" tickets on day one
Preserve focus and caret across re-render on live-preview fields Otherwise users can type exactly one character

That last one was a real bug in the prototype. Every unit test passed. It only surfaced when the UI was driven with simulated keystrokes in a real DOM. Test the UI the way a person uses it.


11. State machine

                          ┌──────────────────┐
                          │ PendingApproval  │◄────────┐
                          └───┬────┬─────┬───┘         │ answer (clock resumes)
                              │    │     │             │
            all stages quorum │    │     │ ask    ┌────┴──────────┐
                              │    │     └───────►│ InfoRequested │
                              │    │               └───────────────┘
                              │    │ decline (ONE is enough) ──────► Declined ●
                              │    │ cancel (initiator) ───────────► Cancelled ●
                              │    │ timer elapsed ────────────────► Expired ●
                              ▼    │
                       ┌──────────┐│ override (owner, logged, rate-limited)
                       │ Approved │◄┘
                       └────┬─────┘
                            ▼
                      ┌───────────┐  ok   ┌──────────┐
                      │ Executing ├──────►│ Executed │ ●
                      └─────┬─────┘       └──────────┘
                            ├──────────────► Failed ●
                            └──────────────► RequiresManualIntervention
Rule Detail
One decline is terminal No decline quorum. Reason mandatory.
Initiator can never approve Aggregate + DB constraint + button not rendered. Three layers.
One approval per person per stage UQ_Decision_OnePerStage.
Stages open in order Unless IsParallelWithPrevious. Locked-stage approvers are not notified and cannot act.
Amendment invalidates everything Editing clears all approvals and re-evaluates. Primary bypass vector.
Ask pauses the clock ExpiresAtUtc shifts by the paused duration on answer. Prior approvals stay valid.
Expiry releases the hold Notifies initiator + approvers; escalates to owner on repeats.
Removing an approver does not void their approval It was valid when given. But recompute eligibility and alert if quorum became unreachable.
Execution is idempotent Idempotency key passed to the downstream rail. Retries must not double-pay.
Override skips outstanding stages only Never platform-mandated policies. Notifies every admin. Rate-limited.

12. Notifications

Event Recipients Channels
Request raised Stage-1 approvers Push, email, in-app
Stage completed Next stage's approvers Push, email, in-app
Approval recorded Initiator In-app, optional push
Question asked Initiator Push, email
Declined Initiator, approvers who already approved Push, email
Expiring in 12h / 2h Pending approvers, initiator Push, email
Expired Initiator, approvers, owner Email
Executed / failed Initiator, all approvers Push, email
Rules changed Owner, everyone who can move money Email
Override used All admins + platform compliance Push, email, immediate

Quiet hours respected except for expiry warnings and overrides. A batch of 200 payments produces one notification, not 200. Every notification deep-links to the specific request.


13. Test matrix

13.1 Invariants — one test each, named after the rule

Initiator_cannot_approve_own_request
Same_approver_cannot_approve_twice_in_a_stage
Single_decline_terminates_the_request
Decline_without_reason_is_refused
Stage_two_approver_cannot_act_while_stage_one_is_open
Approval_after_expiry_is_refused
Amending_amount_invalidates_all_collected_approvals
Delegated_approval_to_the_initiator_is_refused
Delegation_cannot_be_chained
Approval_without_step_up_is_refused
Removing_an_approver_does_not_invalidate_their_prior_approval
Unsatisfiable_policy_cannot_be_published
Unsatisfiable_request_is_refused_at_submission
Risk_reducing_actions_are_never_gated
Question_pauses_the_expiry_clock_and_preserves_approvals
Override_requires_a_reason_and_writes_an_audit_entry
Cross_customer_read_is_impossible

13.2 Concurrency — against a real database (Testcontainers, not mocks)

[Fact]
public async Task Ten_concurrent_approvals_produce_exactly_the_required_count()
{
    var request = await SeedPendingRequest(requiredCount: 2, eligibleApprovers: 10);
    await Task.WhenAll(request.EligibleUserIds.Select(id => TryApprove(request.Id, id)));

    (await CountDecisions(request.Id)).Should().Be(2);
    (await GetStatus(request.Id)).Should().Be(ApprovalRequestStatus.Approved);
    (await CountExecutions(request.Id)).Should().Be(1);   // never two, never zero
}

13.3 Evaluator — golden files

(policy.json, facts.json) → expected-requirement.json. Every production surprise becomes a new fixture. This is how merge semantics stop regressing.

13.4 Architecture tests — run in CI

[Fact] public void Every_money_moving_command_carries_RequiresApproval() { }
[Fact] public void Gated_commands_return_ActionResult() { }
[Fact] public void Controllers_do_not_depend_on_money_moving_services_directly() { }
[Fact] public void Risk_reducing_commands_are_never_gated() { }   // freeze, lock, lower limit
[Fact] public void Every_Approval_entity_has_a_CustomerId_query_filter() { }

13.5 Security

13.6 Frontend


14. Delivery plan

Phase Scope Weeks
0 — Foundations Command pipeline; ActionResult; AsyncLocal execution context; feature gate + kill switch; architecture tests; payload versioning harness 2–3
1 — Engine Tables, evaluator, merger, approver resolver, feasibility, aggregate with all invariants, audit + hash chain, outbox 3–4
2 — First slice ⚠️ Fiat withdrawal, one stage, amount thresholds only, end to end, in production. S11–S14, S16. Hardcoded default policy — no builder. 2–3
3 — Depth Multi-stage, expiry, reminders, ask/answer, groups + cover, funds holds, SignalR 3–4
4 — Breadth All money-out actions, cards (S19), role changes (S20), non-amount rules, preview on every form 3–4
5 — Configuration S1–S9: templates, rule editor, custom builder, simulator, publish, version history 3–4
6 — Compliance Platform-mandated policies, override, auditor role, audit screen (S21), reports, bulk approve 2–3
7 — Rollout Internal watch → internal enforce → pilot watch → pilot enforce → GA opt-in → individual security hold ongoing

Phase 2 is the whole risk of the project. It is deliberately narrow — one action, one stage, one condition type — because it exercises every part of the architecture end to end while the problems are still cheap. Phases 3 onward are repetition of a proven recipe.

Do not start Phase 5 first, however loudly it is requested. The configuration screens are the most expensive UI in the project and their requirements will change substantially once someone has actually used the engine. Hardcode a default policy until then.


15. Failure modes — read before writing code

Ranked by cost.

# Failure Prevention
1 Non-idempotent execution retried → double payment Idempotency key + unique index + provider-side key
2 Serialised command breaks on deploy Schema versions, additive-only, upcasters, golden-corpus CI test
3 A frontend call site reports success for a pending action Discriminated union in generated TS; compiler rejects missing branches
4 Validation runs after the gate Preflight behaviour ahead of the gate
5 HttpContext-based execution context AsyncLocal, or the outbox re-triggers the gate in an infinite loop
6 Recurring payments gated per occurrence EnterSystemInitiated scope — the schedule was approved, not each run
7 Pending items missing from lists Union read model
8 Balance semantics split Rename to LedgerBalance; let the compiler find every reader
9 Admin panel exempted "because it's internal" Gate it more strictly than customers
10 Customer configures a deadlock Feasibility at save, nightly re-check, historical replay, watch mode
11 Approve button on a push notification Never build it — a stolen unlocked phone becomes an approval device
12 Offline approvals queued for replay Refuse honestly; a stale approval could land against an amended request
13 Risk service outage over-fires controls Fail closed but visibly, with an audited ops switch
14 Gate latency degrades every action Fact-building budget < 100ms p95; precomputed velocity counters
15 No kill switch Config-driven, seconds, no deploy
16 Focus lost on re-render Preserve activeElement and caret

Non-functional targets

# Requirement Target
NFR-1 Gate evaluation on the action path < 150ms p95
NFR-2 Fact building < 100ms p95
NFR-3 Rule evaluation itself < 5ms
NFR-4 Approval visible to other viewers < 2s
NFR-5 Notification delivery < 30s
NFR-6 Audit retention 7 years, tamper-evident
NFR-7 Kill switch No deploy, seconds
NFR-8 Accessibility WCAG 2.1 AA
NFR-9 Evidence pack export < 1 minute, self-serve

Alert on these

Metric Alert
approval.payload_deserialisation_failures Any value above zero
approval.hash_chain_verification_failures Page someone
approval.break_glass.used Always
approval.gate.evaluation_duration p95 > 150ms
approval.fact.unavailable Spike = dependency down, controls over-firing
approval.expired Rate — every expiry is a failed business process
approval.policy.unsatisfiable Any customer with a broken policy

16. Decisions needed

# Decision Recommendation
D1 Ship off by default for existing customers? Yes — imposing it breaks live workflows
D2 Is watch-first mandatory before enforce? Mandatory, first 14 days — main protection against blocking payroll
D3 Gate our own internal admin and support tools? Yes, more strictly than customers
D4 Individual-customer security hold in v1? Defer to phase 7
D5 Which action ships first? Fiat withdrawal — highest value at risk, simplest rail
D6 Separate Releaser role in v1? Defer — few customers will use it
D7 Per-currency thresholds, or base currency only? Base currency only for v1

Open questions needing an owner: who signs off the final approvable-action list with Compliance; who owns the support runbook for stuck requests; what retention period our largest prospects' auditors actually require.


17. Generation guide for VS Code

Work in this order. Each step is a prompt you can paste; each assumes the previous step is complete and compiling. Attach this document and the folder in question as context.

Step 1 — Domain

Using §5.2 (tables) and §6 (rule engine) of the attached spec, create the
Meridian.Approvals.Domain project per the layout in §9.2.

Requirements:
- ApprovalRequest is an aggregate root. Every invariant in §11 lives in it,
  never in a controller or service.
- RuleEvaluator is PURE: no I/O, no DbContext, no async. It takes
  (actionKey, ApprovalFacts, PolicyDefinition) and returns EvaluationResult.
- RequirementMerger implements union + strictest-wins per §6.4: same audience
  takes the higher count, never the sum.
- StageSpec.ExcludeInitiator is `bool` but always true; add a private setter
  and no public way to set it false.
- Use CustomerId and CustomerUserId throughout. Never "AccountId" or "UserId".
Include xunit tests for every invariant listed in §13.1.

Step 2 — Persistence

Using §5.2 and §5.4, create Meridian.Approvals.Infrastructure/Persistence.

- EF Core configurations for every Approval* entity.
- MANDATORY: a global query filter on CustomerId for every entity.
- No navigation properties into Customer, CustomerUsers or Roles — those are a
  different bounded context. Reference them by id only.
- ApprovalDecision and ApprovalAuditLog are append-only: no UPDATE or DELETE
  in any repository method.
- Generate the initial migration, plus the triggers in §5.3.

Step 3 — Approver resolution

Implement IApproverResolver per §6.6, joining the EXISTING CustomerUsers and
Roles tables. Include active delegations from ApprovalDelegation, exclude the
initiator unconditionally, exclude inactive users. Add a Feasibility method
that returns hard problems (a stage nobody can satisfy) and soft warnings
(a stage with no spare approver).

Step 4 — Pipeline and gate

Using §9.1, §9.3 and §9.4, create the Application layer:
- RequiresApprovalAttribute, IApprovableCommand, ApprovalSubject
- PreflightBehavior, then ApprovalGateBehavior — IN THAT ORDER in the pipeline
- IApprovalExecutionContext backed by AsyncLocal, NOT HttpContext
- ActionResult as a discriminated result type with Completed / PendingApproval
- Wrapper command + handler for CreateFiatWithdrawalCommand that calls the
  EXISTING IWithdrawalService without modifying it (§9.3)
Add the architecture tests from §13.4.

Step 5 — API

Using §8, create the controllers listed in §9.2.
- Derive CustomerId from the token claim, never from route or body.
- Every mutating endpoint requires an Idempotency-Key header.
- Approve/decline require a step-up token bound to that request id.
- GET /approvals/{id} must compute the `viewer` block server-side per §8.3.
- Errors follow §8.4 exactly, including the code strings.
Generate OpenAPI annotations so NSwag can produce the TypeScript client.

Step 6 — Workers and execution

Using §9.6 and §9.7, implement ApprovedRequestExecutor and the background
workers. Execution runs from the outbox consumer, not inline. Verify the
payload hash before deserialising. On deserialisation failure move to
RequiresManualIntervention and alert — never fail silently (§9.8).
Take a distributed lock so only one replica sweeps.

Step 7 — Shared React components

Using §7 screenshots and §10.3, build ApprovalPreviewPanel and ApprovalTracker
in React + TypeScript. Types come from the generated client only — do not
hand-write API types. ApprovalPreviewPanel takes { actionKey, payload },
debounces 400ms, and shows NAMED approvers, not counts.

Step 8 — Approver screens

Build ApprovalInboxPage (S13) and RequestDetailPage (S16, S17, S18) with the
acceptance criteria in §7. Render viewer.canApprove from the server — never
compute permission client-side. The initiator sees NO Approve control, not a
disabled one, plus viewer.blockedExplanation. Sort the inbox by soonest expiry.

Step 9 — Wire into existing forms

For each existing action form, add ApprovalPreviewPanel and handle the 202
response through useActionResult (§10.4). The submit button label must switch
to "Send for approval" when preview.willRequireApproval is true.
Update balance displays to show ledger / held / available per §7 S10.

Step 10 — Configuration screens

Build S1–S9 from §7. RuleEditorPage keeps bands contiguous (editing band n's
ceiling sets band n+1's floor). CustomRuleBuilderPage reads its condition
fields from GET /approval-policies/facts — never hardcode them. Both show live
feasibility and a plain-English summary.

Prompt suffix — paste with every step

Constraints that apply to all generated code:
- Account = Customer / CustomerId. Person = CustomerUser / CustomerUserId.
  Role = the existing Roles table. Never invent parallel concepts.
- Never add columns to Customer, CustomerUsers or Roles.
- Approval invariants live in the domain aggregate, never in controllers.
- The client never decides who may approve; render the server's viewer block.
- No expression/script/formula operator anywhere in the rule format.
- Risk-reducing actions (freeze, lock, lower limit) are NEVER gated.
- Every Approval* query is filtered by CustomerId.

Tables

ApprovalPolicyVersion R · ApprovalPolicy R/W (ActiveVersionId on revert) · ApprovalAuditLog W

Revert inserts a new version equal to the old one. It never mutates or resurrects history.



18. Reference tables (were missing — add these)

§8 exposes /approval-policies/action-types, /facts and /templates, but §5 defined no tables behind them. Three reference tables are needed. They are platform-owned: seeded once per environment, identical for every Customer, and never edited by a customer.

-- ═══════════════════════════════════════════════════════════
--  PLATFORM REFERENCE DATA — seeded, not customer-editable
-- ═══════════════════════════════════════════════════════════

-- The catalogue of actions the gate knows about.
-- Adding a row here does NOT gate anything; the command must also carry
-- [RequiresApproval]. This table drives the UI tabs and validation.
CREATE TABLE ApprovalActionType (
    ActionKey            NVARCHAR(100)  PRIMARY KEY,   -- 'CryptoWithdrawal'
    DisplayName          NVARCHAR(200)  NOT NULL,      -- 'Crypto withdrawal'
    Category             NVARCHAR(50)   NOT NULL,      -- 'MoneyOut','Cards','People','Account'
    ValuationKind        TINYINT        NOT NULL,      -- 1=Monetary(bands) 2=Flat 3=NeverGated
    DefaultExpiryHours   INT            NOT NULL DEFAULT 48,
    DefaultRemindHours   INT            NOT NULL DEFAULT 8,
    SupportsFundsHold    BIT            NOT NULL DEFAULT 0,
    RiskDirection        NVARCHAR(20)   NOT NULL,      -- 'Increases','Reduces','Neutral'
    SortOrder            INT            NOT NULL,
    IsEnabled            BIT            NOT NULL DEFAULT 1
);

-- What a rule condition is allowed to look at. The rule builder reads this.
-- Never hardcode this list in React.
CREATE TABLE ApprovalFactDefinition (
    FactKey              NVARCHAR(100)  PRIMARY KEY,   -- 'amountInBaseCurrency'
    DisplayName          NVARCHAR(200)  NOT NULL,      -- 'Amount in USD'
    DataType             NVARCHAR(20)   NOT NULL,      -- 'number','bool','enum','guid'
    Unit                 NVARCHAR(20)   NULL,          -- 'currency','hours','days'
    AllowedOperatorsJson NVARCHAR(500)  NOT NULL,      -- ["gt","gte","lt","lte","between"]
    OptionsJson          NVARCHAR(MAX)  NULL,          -- enum options, or NULL to resolve at runtime
    SourceHint           NVARCHAR(200)  NOT NULL,      -- where the fact builder gets it
    FailClosed           BIT            NOT NULL DEFAULT 1,
    SortOrder            INT            NOT NULL,
    IsEnabled            BIT            NOT NULL DEFAULT 1
);

-- Starting points offered on S2.
CREATE TABLE ApprovalPolicyTemplate (
    TemplateKey          NVARCHAR(50)   PRIMARY KEY,   -- 'tiered'
    DisplayName          NVARCHAR(200)  NOT NULL,
    Tagline              NVARCHAR(200)  NOT NULL,
    Description          NVARCHAR(1000) NOT NULL,
    SuitableForMinUsers  INT            NOT NULL DEFAULT 1,
    SuitableForMaxUsers  INT            NULL,
    DefinitionJson       NVARCHAR(MAX)  NOT NULL,      -- a full PolicyDefinition
    SortOrder            INT            NOT NULL,
    IsEnabled            BIT            NOT NULL DEFAULT 1
);

-- Optional but recommended: notification copy, so wording changes are not deploys.
CREATE TABLE ApprovalNotificationTemplate (
    TemplateKey   NVARCHAR(100) NOT NULL,
    Channel       NVARCHAR(20)  NOT NULL,   -- 'Push','Email','InApp'
    LanguageCode  NVARCHAR(10)  NOT NULL DEFAULT 'en',
    Subject       NVARCHAR(300) NULL,
    Body          NVARCHAR(MAX) NOT NULL,   -- {{reference}}, {{initiatorName}}, {{amount}}, {{expiresAt}}
    PRIMARY KEY (TemplateKey, Channel, LanguageCode)
);

ApprovalActionType.RiskDirection is not decoration. An automated test asserts that every row with RiskDirection = 'Reduces' has ValuationKind = 3 (NeverGated), and that no command class for such an action carries [RequiresApproval]. That is how "freezing a card must never wait" survives a future refactor.


19. API → table reference

R read · W write · A append-only. Tables in italics are your existing ones and must not be altered.

19.1 Preview and raising

Endpoint Reads Writes
POST /approvals/preview ApprovalPolicy, ApprovalPolicyVersion, ApprovalActionType, ApprovalFactDefinition, ApproverGroup, ApproverGroupMember, ApprovalDelegation, FundsHold, CustomerUsers, Roles
POST /wallets/{id}/withdrawals → 202 all of the above ApprovalRequest, ApprovalRequestStage, ApprovalRequestStageApprover, FundsHold, ApprovalAuditLog A, ApprovalOutboxMessage
POST /wallets/{id}/withdrawals → 200 policy tables your existing ledger/transaction tables
POST /cards · POST /cards/{id}/unfreeze policy tables, your card tables same request tables as above (no FundsHold for unfreeze)
POST /cards/{id}/freeze your card tables your card tables only
PUT /customer-users/{id}/role policy tables, Roles request tables; CustomerUsers on execution only

19.2 Inbox and detail

Endpoint Reads Writes
GET /approvals ApprovalRequest, ApprovalRequestStage, ApprovalRequestStageApprover, ApprovalDecision, CustomerUsers
GET /approvals/counts ApprovalRequestStageApprover, ApprovalRequest
GET /approvals/{id} the four request tables + ApprovalRequestThread, CustomerUsers, Roles
GET /approvals/{id}/timeline ApprovalDecision, ApprovalAuditLog, ApprovalRequestThread

19.3 Decisions

Endpoint Reads Writes
POST /approvals/{id}/step-up/challenge your existing step-up/challenge store
POST /approvals/{id}/approve ApprovalRequest UPDLOCK, ApprovalRequestStage, ApprovalRequestStageApprover, ApprovalDecision, ApprovalDelegation ApprovalDecision A, ApprovalRequestStage, ApprovalRequest, ApprovalAuditLog A, ApprovalOutboxMessage, FundsHold (→ Captured on final approval)
POST /approvals/{id}/decline same ApprovalDecision A, ApprovalRequest, FundsHold (→ Released), ApprovalAuditLog A, ApprovalOutboxMessage
POST /approvals/{id}/ask ApprovalRequest ApprovalRequestThread, ApprovalRequest (ClockPausedAtUtc), ApprovalDecision A, ApprovalOutboxMessage
POST /approvals/{id}/answer ApprovalRequest ApprovalRequestThread, ApprovalRequest (ExpiresAtUtc shifted), ApprovalOutboxMessage
POST /approvals/{id}/cancel ApprovalRequest ApprovalRequest, FundsHold (→ Released), ApprovalDecision A, ApprovalAuditLog A
POST /approvals/{id}/remind ApprovalRequestStageApprover ApprovalRequestStageApprover (LastRemindedAtUtc), ApprovalOutboxMessage
POST /approvals/bulk-approve as approve, per id as approve, one ApprovalDecision row per request
POST /approvals/{id}/override ApprovalRequest ApprovalDecision A (type 7), all stages → Complete, ApprovalRequest, ApprovalAuditLog A, ApprovalOutboxMessage (all admins)

19.4 Policy

Endpoint Reads Writes
GET /approval-policies/action-types ApprovalActionType
GET /approval-policies/facts ApprovalFactDefinition
GET /approval-policies/templates ApprovalPolicyTemplate
GET /approval-policies/recommendation ApprovalPolicyTemplate, CustomerUsers, your transaction tables
POST /approval-policies/{id}/versions ApprovalPolicyTemplate ApprovalPolicy, ApprovalPolicyVersion (Draft)
PUT /…/versions/{v} ApprovalPolicyVersion ApprovalPolicyVersion (Draft only — reject if Active)
POST /…/versions/{v}/validate ApprovalPolicyVersion, ApproverGroup*, ApprovalDelegation, CustomerUsers, Roles
POST /…/versions/{v}/simulate as validate + your transaction tables (replay)
POST /…/versions/{v}/publish ApprovalPolicyVersion ApprovalPolicyVersion (→ Active, previous → Superseded), ApprovalPolicy, ApprovalAuditLog A, ApprovalOutboxMessage
POST /…/versions/{v}/revert ApprovalPolicyVersion new ApprovalPolicyVersion, ApprovalPolicy, ApprovalAuditLog A

19.5 Groups, cover, audit

Endpoint Reads Writes
GET/POST/PUT/DELETE /approver-groups ApproverGroup, ApprovalPolicyVersion (usage check) ApproverGroup
POST/DELETE /approver-groups/{id}/members ApproverGroup, CustomerUsers ApproverGroupMember, ApprovalRequestStageApprover (recompute)
GET/POST/DELETE /approval-delegations ApprovalDelegation, CustomerUsers ApprovalDelegation, ApprovalRequestStageApprover (recompute)
GET /approvals/audit ApprovalAuditLog, ApprovalDecision, CustomerUsers, Roles
GET /approvals/reports/* ApprovalRequest, ApprovalDecision, ApprovalRequestStage

19.6 Background workers

Worker Reads Writes
ApprovalExpiryWorker ApprovalRequest (index IX_Req_Expiry) ApprovalRequest, FundsHold, ApprovalAuditLog A, ApprovalOutboxMessage
Reminder sender ApprovalRequest, ApprovalRequestStageApprover ApprovalRequestStageApprover, ApprovalOutboxMessage
OutboxDispatcher ApprovalOutboxMessage ApprovalOutboxMessage, plus execution writes
ApprovedRequestExecutor ApprovalRequest ApprovalRequest, FundsHold (→ Captured), your domain tables
EligibilityRefresher CustomerUsers, Roles, ApproverGroupMember, ApprovalDelegation ApprovalRequestStageApprover
Feasibility auditor ApprovalPolicyVersion, CustomerUsers, Roles ApprovalOutboxMessage (alert)

20. Seed data

Four tiers. Tier 1 and 2 are required in every environment including production. Tier 3 runs per customer. Tier 4 is dev and QA only.

All scripts are idempotent — safe to run repeatedly. Use MERGE or IF NOT EXISTS, never bare INSERT. They belong in an EF Core migration or a DbSeeder that runs at startup, not in a runbook a human has to remember.

20.1 Tier 1 — Action types

MERGE ApprovalActionType AS t
USING (VALUES
--  ActionKey                DisplayName              Category     Kind Exp  Rem Hold Risk         Sort
   ('FiatWithdrawal',        'Fiat withdrawal',       'MoneyOut',   1,  48,   8, 1, 'Increases',  10),
   ('CryptoWithdrawal',      'Crypto withdrawal',     'MoneyOut',   1,  48,   8, 1, 'Increases',  20),
   ('InternalTransfer',      'Internal transfer',     'MoneyOut',   1,  48,   8, 1, 'Increases',  30),
   ('BeneficiaryPayment',    'Payment to beneficiary','MoneyOut',   1,  48,   8, 1, 'Increases',  40),
   ('BulkPayment',           'Bulk payment file',     'MoneyOut',   1,  24,   4, 1, 'Increases',  50),
   ('ScheduledPaymentCreate','Scheduled payment',     'MoneyOut',   1, 168,  24, 0, 'Increases',  60),
   ('FxConversion',          'FX conversion',         'MoneyOut',   1,  12,   4, 1, 'Increases',  70),
   ('CardTopUp',             'Card top-up',           'MoneyOut',   1,  48,   8, 1, 'Increases',  80),

   ('CardIssue',             'New card',              'Cards',      2, 168,  24, 0, 'Increases', 110),
   ('CardLimitIncrease',     'Raise a card limit',    'Cards',      1, 168,  24, 0, 'Increases', 120),
   ('CardUnfreeze',          'Unfreeze a card',       'Cards',      2,  72,  12, 0, 'Increases', 130),
   ('CardTerminate',         'Terminate a card',      'Cards',      2, 168,  24, 0, 'Neutral',   140),
   ('CardCredentialReissue', 'Reissue card details',  'Cards',      2,  72,  12, 0, 'Increases', 150),

   ('AddBeneficiary',        'Add a beneficiary',     'Counterparty',2,168,  24, 0, 'Increases', 210),
   ('WhitelistAddress',      'Whitelist an address',  'Counterparty',2,168,  24, 0, 'Increases', 220),

   ('InviteUser',            'Invite someone',        'People',     2, 168,  24, 0, 'Increases', 310),
   ('RoleChange',            'Change a role',         'People',     2, 168,  24, 0, 'Increases', 320),
   ('RemoveUser',            'Remove someone',        'People',     2, 168,  24, 0, 'Neutral',   330),
   ('ChangeOwner',           'Change the owner',      'People',     2, 168,  24, 0, 'Increases', 340),

   ('ChangePayoutAccount',   'Change payout account', 'Account',    2, 168,  24, 0, 'Increases', 410),
   ('RaiseAccountLimit',     'Raise account limits',  'Account',    2, 168,  24, 0, 'Increases', 420),
   ('CloseAccount',          'Close the account',     'Account',    2, 168,  24, 0, 'Neutral',   430),

-- ── NEVER GATED (ValuationKind = 3). Shown in the UI with an explanation, no controls. ──
   ('CardFreeze',            'Freeze a card',         'Cards',      3,   0,   0, 0, 'Reduces',   900),
   ('CardReportLost',        'Report card lost',      'Cards',      3,   0,   0, 0, 'Reduces',   910),
   ('LockAccount',           'Lock the account',      'Account',    3,   0,   0, 0, 'Reduces',   920),
   ('BlockUser',             'Block someone',         'People',     3,   0,   0, 0, 'Reduces',   930),
   ('LowerLimit',            'Lower a limit',         'Account',    3,   0,   0, 0, 'Reduces',   940),
   ('CancelOwnRequest',      'Cancel your request',   'MoneyOut',   3,   0,   0, 0, 'Reduces',   950)
) AS s (ActionKey,DisplayName,Category,ValuationKind,DefaultExpiryHours,
        DefaultRemindHours,SupportsFundsHold,RiskDirection,SortOrder)
ON t.ActionKey = s.ActionKey
WHEN MATCHED THEN UPDATE SET
    DisplayName=s.DisplayName, Category=s.Category, ValuationKind=s.ValuationKind,
    DefaultExpiryHours=s.DefaultExpiryHours, DefaultRemindHours=s.DefaultRemindHours,
    SupportsFundsHold=s.SupportsFundsHold, RiskDirection=s.RiskDirection, SortOrder=s.SortOrder
WHEN NOT MATCHED THEN INSERT (ActionKey,DisplayName,Category,ValuationKind,DefaultExpiryHours,
    DefaultRemindHours,SupportsFundsHold,RiskDirection,SortOrder,IsEnabled)
VALUES (s.ActionKey,s.DisplayName,s.Category,s.ValuationKind,s.DefaultExpiryHours,
    s.DefaultRemindHours,s.SupportsFundsHold,s.RiskDirection,s.SortOrder,1);

20.2 Tier 1 — Fact definitions

MERGE ApprovalFactDefinition AS t
USING (VALUES
 ('amountInBaseCurrency','Amount in USD','number','currency',
  '["gt","gte","lt","lte","between"]', NULL,
  'Command payload amount x FX rate to Customer.BaseCurrency', 1, 10),

 ('destinationAgeHours','Hours since the destination was added','number','hours',
  '["lt","gt","gte","lte"]', NULL,
  'Beneficiary / whitelisted address CreatedAtUtc', 1, 20),

 ('destinationIsNew','First ever payment to this destination','bool',NULL,
  '["eq"]', NULL,
  'Payment history for this Customer + destination', 1, 30),

 ('destinationCountryRisk','Destination country risk','enum',NULL,
  '["eq","in","notIn"]', '["low","medium","high"]',
  'Risk / sanctions screening service', 1, 40),

 ('initiatorRoleName','Role of the person raising it','enum',NULL,
  '["eq","neq","in"]', NULL,
  'Roles.Name via CustomerUsers.RoleId — options resolved per Customer at runtime', 1, 50),

 ('initiatorTenureDays','How long they have been on the account','number','days',
  '["lt","gt","gte","lte"]', NULL,
  'CustomerUsers.CreatedAtUtc', 1, 60),

 ('initiatorRolling24hOutflow','What they have already moved today','number','currency',
  '["gt","gte"]', NULL,
  'Velocity projection, NOT a SUM over the transaction table', 1, 70),

 ('outsideBusinessHours','Raised outside working hours','bool',NULL,
  '["eq"]', NULL,
  'Clock resolved against Customer.TimeZone', 1, 80),

 ('isRecognisedDevice','From a known device','bool',NULL,
  '["eq"]', NULL,
  'Device fingerprint service', 1, 90),

 ('asset','Asset','enum',NULL,
  '["eq","in","notIn"]', '["BTC","ETH","USDT","USDC"]',
  'Command payload', 1, 100),

 ('walletId','Specific wallet','guid',NULL,
  '["eq","in"]', NULL,
  'Command payload — options resolved per Customer at runtime', 1, 110)
) AS s (FactKey,DisplayName,DataType,Unit,AllowedOperatorsJson,OptionsJson,SourceHint,FailClosed,SortOrder)
ON t.FactKey = s.FactKey
WHEN MATCHED THEN UPDATE SET
    DisplayName=s.DisplayName, DataType=s.DataType, Unit=s.Unit,
    AllowedOperatorsJson=s.AllowedOperatorsJson, OptionsJson=s.OptionsJson,
    SourceHint=s.SourceHint, FailClosed=s.FailClosed, SortOrder=s.SortOrder
WHEN NOT MATCHED THEN INSERT (FactKey,DisplayName,DataType,Unit,AllowedOperatorsJson,
    OptionsJson,SourceHint,FailClosed,SortOrder,IsEnabled)
VALUES (s.FactKey,s.DisplayName,s.DataType,s.Unit,s.AllowedOperatorsJson,
    s.OptionsJson,s.SourceHint,s.FailClosed,s.SortOrder,1);

FailClosed = 1 on every fact. If the risk service is down and destinationCountryRisk cannot be resolved, the condition evaluates true and the extra approval fires. A dependency outage must never silently disable a control.

20.3 Tier 1 — Policy templates

DefinitionJson is a full PolicyDefinition per §6.2. Abbreviated below; the complete JSON is in seed/templates/*.json in the repo.

DECLARE @tiered NVARCHAR(MAX) = N'{
  "schemaVersion": 1,
  "combination": "UnionStrictest",
  "rules": {
    "FiatWithdrawal": {
      "bands": [
        { "min": 0,      "max": 1000,   "stages": [] },
        { "min": 1000,   "max": 25000,  "stages": [
            {"name":"Finance","requiredCount":1,
             "eligibility":{"type":"role","value":"Finance Manager"},
             "parallelWithPrevious":false,"excludeInitiator":true}]},
        { "min": 25000,  "max": 250000, "stages": [
            {"name":"Finance","requiredCount":1,
             "eligibility":{"type":"role","value":"Finance Manager"},
             "parallelWithPrevious":false,"excludeInitiator":true},
            {"name":"Director","requiredCount":1,
             "eligibility":{"type":"role","value":"Director"},
             "parallelWithPrevious":false,"excludeInitiator":true}]},
        { "min": 250000, "max": null,   "stages": [
            {"name":"Finance","requiredCount":1,
             "eligibility":{"type":"role","value":"Finance Manager"},
             "parallelWithPrevious":false,"excludeInitiator":true},
            {"name":"Director","requiredCount":1,
             "eligibility":{"type":"role","value":"Director"},
             "parallelWithPrevious":false,"excludeInitiator":true},
            {"name":"Owner","requiredCount":1,
             "eligibility":{"type":"role","value":"Owner"},
             "parallelWithPrevious":false,"excludeInitiator":true}]}
      ],
      "extra": [
        { "id":"fresh-destination", "enabled":true,
          "label":"Destination added in the last 24 hours",
          "conditions":[{"fact":"destinationAgeHours","op":"lt","value":24}],
          "stages":[{"name":"Owner","requiredCount":1,
                     "eligibility":{"type":"role","value":"Owner"},
                     "parallelWithPrevious":false,"excludeInitiator":true}]},
        { "id":"velocity", "enabled":false,
          "label":"They have already moved more than $50,000 today",
          "conditions":[{"fact":"initiatorRolling24hOutflow","op":"gt","value":50000}],
          "stages":[{"name":"Owner","requiredCount":1,
                     "eligibility":{"type":"role","value":"Owner"},
                     "parallelWithPrevious":false,"excludeInitiator":true}]},
        { "id":"after-hours", "enabled":false,
          "label":"Raised outside working hours",
          "conditions":[{"fact":"outsideBusinessHours","op":"eq","value":true}],
          "stages":[{"name":"Director","requiredCount":1,
                     "eligibility":{"type":"role","value":"Director"},
                     "parallelWithPrevious":false,"excludeInitiator":true}]}
      ],
      "expiryHours": 48, "remindHours": 8, "escalateAfterHours": 24
    },
    "CryptoWithdrawal": { "...same bands and extras as FiatWithdrawal..." },
    "CardIssue":    { "stages":[{"name":"Finance","requiredCount":1,
                        "eligibility":{"type":"role","value":"Finance Manager"},
                        "parallelWithPrevious":false,"excludeInitiator":true}],
                      "expiryHours":168,"remindHours":24 },
    "CardUnfreeze": { "stages":[{"name":"Director","requiredCount":1,
                        "eligibility":{"type":"role","value":"Director"},
                        "parallelWithPrevious":false,"excludeInitiator":true}],
                      "expiryHours":72,"remindHours":12 },
    "RoleChange":   { "stages":[{"name":"Owner","requiredCount":1,
                        "eligibility":{"type":"role","value":"Owner"},
                        "parallelWithPrevious":false,"excludeInitiator":true}],
                      "expiryHours":168,"remindHours":24 }
  }
}';

MERGE ApprovalPolicyTemplate AS t
USING (VALUES
 ('small','One extra pair of eyes','Small team',
  'Payments over $10,000 need one approval from a Director. Role changes need the owner.',
  1, 8, N'{ "...": "see seed/templates/small.json" }', 10),
 ('tiered','Tiered sign-off','Recommended',
  'Bands from $1,000 up, across payments, cards and role changes. Finance first, then a Director, then the owner for the largest.',
  5, 60, @tiered, 20),
 ('treasury','Dual control on everything out','Treasury',
  'Two approvals on every outbound payment whatever the amount, plus the owner for freshly added destinations.',
  10, NULL, N'{ "...": "see seed/templates/treasury.json" }', 30)
) AS s (TemplateKey,DisplayName,Tagline,Description,
        SuitableForMinUsers,SuitableForMaxUsers,DefinitionJson,SortOrder)
ON t.TemplateKey = s.TemplateKey
WHEN MATCHED THEN UPDATE SET
    DisplayName=s.DisplayName, Tagline=s.Tagline, Description=s.Description,
    SuitableForMinUsers=s.SuitableForMinUsers, SuitableForMaxUsers=s.SuitableForMaxUsers,
    DefinitionJson=s.DefinitionJson, SortOrder=s.SortOrder
WHEN NOT MATCHED THEN INSERT (TemplateKey,DisplayName,Tagline,Description,
    SuitableForMinUsers,SuitableForMaxUsers,DefinitionJson,SortOrder,IsEnabled)
VALUES (s.TemplateKey,s.DisplayName,s.Tagline,s.Description,
    s.SuitableForMinUsers,s.SuitableForMaxUsers,s.DefinitionJson,s.SortOrder,1);

Recommendation logic (GET /approval-policies/recommendation): pick the template whose SuitableForMinUsers ≤ active CustomerUsers count ≤ SuitableForMaxUsers, then override to treasury if 90-day outbound value exceeds a configured ceiling. Return the reasoning string with it — S2 displays it.

20.4 Tier 1 — Notification copy

MERGE ApprovalNotificationTemplate AS t
USING (VALUES
 ('RequestRaised','Push','en', NULL,
  N'Your approval is needed: {{title}}, raised by {{initiatorName}}. Expires {{expiresAtLocal}}.'),
 ('RequestRaised','Email','en', N'Approval needed — {{reference}}',
  N'{{initiatorName}} has raised {{title}}. You are one of {{eligibleCount}} people who can approve it. It expires {{expiresAtLocal}}.'),
 ('StageCompleted','Push','en', NULL,
  N'{{title}} is now with you — {{approvalsGiven}} of {{approvalsRequired}} approvals collected.'),
 ('ApprovalRecorded','InApp','en', NULL,
  N'{{approverName}} approved {{reference}}. {{approvalsGiven}} of {{approvalsRequired}}.'),
 ('QuestionAsked','Push','en', NULL,
  N'{{approverName}} asked a question about {{reference}}. The clock is paused until you answer.'),
 ('RequestDeclined','Push','en', NULL,
  N'{{approverName}} declined {{reference}}: {{reason}}'),
 ('ExpiringSoon','Push','en', NULL,
  N'{{title}} expires in {{hoursLeft}} hours and still needs your approval.'),
 ('RequestExpired','Email','en', N'Expired without approval — {{reference}}',
  N'{{title}} expired at {{expiredAtLocal}}. Nothing was carried out and the hold has been released.'),
 ('RequestExecuted','Push','en', NULL,
  N'{{title}} has been carried out.'),
 ('OverrideUsed','Email','en', N'Emergency override used on {{reference}}',
  N'{{actorName}} overrode the outstanding approvals on {{title}}. Reason: {{reason}}. This is permanently recorded in the audit log.'),
 ('PolicyPublished','Email','en', N'Approval rules have changed',
  N'{{actorName}} published version {{versionNumber}} of your approval rules, {{mode}}. {{announcement}}')
) AS s (TemplateKey,Channel,LanguageCode,Subject,Body)
ON t.TemplateKey=s.TemplateKey AND t.Channel=s.Channel AND t.LanguageCode=s.LanguageCode
WHEN MATCHED THEN UPDATE SET Subject=s.Subject, Body=s.Body
WHEN NOT MATCHED THEN INSERT (TemplateKey,Channel,LanguageCode,Subject,Body)
VALUES (s.TemplateKey,s.Channel,s.LanguageCode,s.Subject,s.Body);

20.5 Tier 2 — Platform-mandated policy

One ApprovalPolicy row with CustomerId = NULL and Origin = 2. It applies to every customer, is merged on top of whatever they configure, and cannot be reduced or overridden — including by break-glass.

DECLARE @platformPolicyId UNIQUEIDENTIFIER = '00000000-0000-0000-0000-0000000000P1';
DECLARE @platformVersionId UNIQUEIDENTIFIER = '00000000-0000-0000-0000-0000000000V1';

IF NOT EXISTS (SELECT 1 FROM ApprovalPolicy WHERE ApprovalPolicyId = @platformPolicyId)
BEGIN
    INSERT INTO ApprovalPolicy (ApprovalPolicyId, CustomerId, Name, Description, Origin,
                                IsEnabled, EnforcementMode, ActiveVersionId,
                                CreatedAtUtc, CreatedByCustomerUserId)
    VALUES (@platformPolicyId, NULL,
            'Platform compliance holds',
            'Sanctions, PEP and Travel Rule holds. Applies to every customer. Cannot be reduced.',
            2, 1, 1, @platformVersionId, SYSUTCDATETIME(), '00000000-0000-0000-0000-000000000000');

    INSERT INTO ApprovalPolicyVersion (ApprovalPolicyVersionId, ApprovalPolicyId, VersionNumber,
        Status, DefinitionJson, DefinitionHash, EffectiveFromUtc, ChangeNote,
        PublishedAtUtc, CreatedAtUtc, CreatedByCustomerUserId)
    VALUES (@platformVersionId, @platformPolicyId, 1, 3, N'{
      "schemaVersion": 1,
      "combination": "UnionStrictest",
      "platformMandated": true,
      "rules": {
        "FiatWithdrawal": { "bands": [], "extra": [
          { "id":"sanctions-hit", "enabled":true, "overridable":false,
            "label":"Sanctions or PEP match on the destination",
            "conditions":[{"fact":"destinationCountryRisk","op":"eq","value":"high"}],
            "stages":[{"name":"Compliance","requiredCount":1,
                       "eligibility":{"type":"role","value":"PlatformCompliance"},
                       "parallelWithPrevious":false,"excludeInitiator":true}]}
        ], "expiryHours": 720, "remindHours": 24 },
        "CryptoWithdrawal": { "bands": [], "extra": [
          { "id":"sanctions-hit", "enabled":true, "overridable":false,
            "label":"Sanctions or PEP match on the destination",
            "conditions":[{"fact":"destinationCountryRisk","op":"eq","value":"high"}],
            "stages":[{"name":"Compliance","requiredCount":1,
                       "eligibility":{"type":"role","value":"PlatformCompliance"},
                       "parallelWithPrevious":false,"excludeInitiator":true}]}
        ], "expiryHours": 720, "remindHours": 24 }
      }
    }', HASHBYTES('SHA2_256', 'platform-v1'), SYSUTCDATETIME(),
        'Initial platform compliance holds', SYSUTCDATETIME(), SYSUTCDATETIME(),
        '00000000-0000-0000-0000-000000000000');
END

Expiry is 720 hours (30 days), not 48 — a compliance hold must not quietly expire and release the payment. "overridable": false is checked by the override handler: break-glass skips customer stages, never platform ones.

20.6 Tier 2 — Roles

The Roles table is yours and probably already holds these. The seed only ensures the names the templates reference exist as system roles (CustomerId IS NULL). It never modifies a role a customer created.

MERGE Roles AS t
USING (VALUES
 ('Owner',              NULL, 'Full control. Configures approval rules and can use break-glass.'),
 ('Director',           NULL, 'Senior approver. Second stage on higher-value payments.'),
 ('Finance Manager',    NULL, 'Day-to-day approver. First stage on payments.'),
 ('Finance Analyst',    NULL, 'Raises payments. Not an approver by default.'),
 ('Auditor',            NULL, 'Read-only across requests, decisions and policies. Cannot raise or approve.'),
 ('PlatformCompliance', NULL, 'Our staff. Approves platform-mandated compliance holds.')
) AS s (Name, CustomerId, Description)
ON t.Name = s.Name AND t.CustomerId IS NULL
WHEN NOT MATCHED THEN INSERT (RoleId, Name, CustomerId, Description)
VALUES (NEWID(), s.Name, s.CustomerId, s.Description);

Do not rename these. Eligibility.value in every template stores the role name, so a rename breaks live policies. If renaming is ever required, migrate DefinitionJson in the same transaction. A stronger alternative is to store RoleId in eligibility instead of the name — decide this before Phase 1 ships, because migrating later means rewriting every stored policy version.

20.7 Tier 3 — Per-customer onboarding

Runs when a Business Customer is created. Individual customers get nothing until Phase 7.

-- @customerId, @ownerCustomerUserId supplied by the onboarding handler.

-- 1. Two approver groups, so rules can point at panels rather than people.
INSERT INTO ApproverGroup (ApproverGroupId, CustomerId, Name, Description, IsDeleted)
SELECT NEWID(), @customerId, v.Name, v.Description, 0
FROM (VALUES
  ('Finance', 'Day-to-day payment approvers.'),
  ('Board',   'Senior approvers for high-value payments.')
) AS v(Name, Description)
WHERE NOT EXISTS (SELECT 1 FROM ApproverGroup g
                  WHERE g.CustomerId = @customerId AND g.Name = v.Name);

-- 2. The owner is the only member of Board initially.
INSERT INTO ApproverGroupMember (ApproverGroupId, CustomerUserId, AddedAtUtc, AddedByCustomerUserId)
SELECT g.ApproverGroupId, @ownerCustomerUserId, SYSUTCDATETIME(), @ownerCustomerUserId
FROM ApproverGroup g
WHERE g.CustomerId = @customerId AND g.Name = 'Board'
  AND NOT EXISTS (SELECT 1 FROM ApproverGroupMember m
                  WHERE m.ApproverGroupId = g.ApproverGroupId
                    AND m.CustomerUserId = @ownerCustomerUserId);

-- 3. A DRAFT policy from the recommended template. Approvals stay OFF (D1).
--    The owner sees S1's empty state until they publish.
INSERT INTO ApprovalPolicy (ApprovalPolicyId, CustomerId, Name, Description, Origin,
                            IsEnabled, EnforcementMode, ActiveVersionId,
                            CreatedAtUtc, CreatedByCustomerUserId)
VALUES (NEWID(), @customerId, 'Approval rules',
        'Created from the recommended template at onboarding.',
        1, 0 /* IsEnabled = 0 → OFF */, 2 /* default Shadow when first enabled */,
        NULL, SYSUTCDATETIME(), @ownerCustomerUserId);

Deliberately not seeded: no ApprovalDelegation rows (cover is set when someone actually goes away), and IsEnabled = 0 so nothing is gated until the owner completes S1–S7. EnforcementMode defaults to 2 (Shadow), implementing decision D2.

20.8 Tier 4 — Dev and QA seed

Reproduces the demo exactly, so a developer can run the full journey locally and QA can write deterministic tests. Guard it so it can never run in production.

public sealed class DevApprovalSeeder(ApprovalsDbContext db, IHostEnvironment env, ILogger<DevApprovalSeeder> log)
{
    // Fixed GUIDs so tests can reference them by constant.
    public static readonly Guid CustomerId = Guid.Parse("11111111-0000-0000-0000-000000000001");
    public static readonly Guid Nadia      = Guid.Parse("22222222-0000-0000-0000-00000000000A"); // Owner
    public static readonly Guid Priya      = Guid.Parse("22222222-0000-0000-0000-00000000000B"); // Finance Analyst
    public static readonly Guid Anand      = Guid.Parse("22222222-0000-0000-0000-00000000000C"); // Finance Manager
    public static readonly Guid Sunita     = Guid.Parse("22222222-0000-0000-0000-00000000000D"); // Finance Manager
    public static readonly Guid Rahul      = Guid.Parse("22222222-0000-0000-0000-00000000000E"); // Finance Manager
    public static readonly Guid Joel       = Guid.Parse("22222222-0000-0000-0000-00000000000F"); // Finance Manager, away
    public static readonly Guid Meera      = Guid.Parse("22222222-0000-0000-0000-000000000010"); // Director
    public static readonly Guid Karthik    = Guid.Parse("22222222-0000-0000-0000-000000000011"); // Director

    public async Task SeedAsync(CancellationToken ct)
    {
        if (env.IsProduction())
            throw new InvalidOperationException("DevApprovalSeeder must never run in production.");
        // ... see seed/DevApprovalSeeder.cs
    }
}
Entity Seeded rows
Customer 1 — Meridian Labs, Business, base USD, Asia/Kolkata
CustomerUsers 8 — the names above, mapped to system Roles
ApproverGroup 2 — Finance (Anand, Sunita, Rahul, Joel), Board (Meera, Karthik)
ApprovalDelegation 1 — Joel → Anand, 14 days, cap $50,000
ApprovalPolicy + Version 1 policy, version 1 from the tiered template, Active, Enforce
Destinations 3 — Nordwind (added 6h ago), Halden (40 days), Kestrel (3 months)
Wallets ETH 96.7 @ $3,300; USD 412,000
Cards 2 — one active, one frozen
ApprovalRequest 0 — tests create their own

Why these particular people. The set is chosen to exercise the edge cases, not to look realistic:

// Seeded scenarios QA can assert against
public static class DevScenarios
{
    // 12.5 ETH ($41,250) by Priya to Nordwind → 3 stages: Finance, Director, Owner
    public const string ThreeStage = "12.5 ETH to Nordwind";

    // 0.2 ETH ($660) by Priya to Halden → no approval, executes immediately
    public const string NoApproval = "0.2 ETH to Halden";

    // 12.5 ETH by NADIA to Nordwind → refused: nobody can satisfy the Owner stage
    public const string Unsatisfiable = "Owner raises an Owner-gated payment";

    // Unfreeze card c2 by Anand → 1 stage: Director
    public const string CardUnfreeze = "Unfreeze Rahul's card";

    // Freeze card c1 by anyone → executes immediately, NO ApprovalRequest row
    public const string NeverGated = "Freeze a card";
}

20.9 Running order

1. EF Core migrations                       → all Approval* tables + triggers
2. Tier 1  seed (§20.1–20.4)                → every environment, every deploy, idempotent
3. Tier 2  seed (§20.5–20.6)                → every environment, once
4. Tier 3  runs per Customer at onboarding  → application code, not a script
5. Tier 4  dev/QA only, guarded by IsProduction()

Tiers 1 and 2 belong in DbSeeder.RunAsync() called at startup after Database.MigrateAsync(). They must be safe to run on every pod start.

20.10 Verification queries

Run after seeding in any environment.

-- 1. Every risk-reducing action is marked never-gated. Must return 0.
SELECT COUNT(*) AS ShouldBeZero FROM ApprovalActionType
WHERE RiskDirection = 'Reduces' AND ValuationKind <> 3;

-- 2. Every role referenced by a template exists. Must return 0.
SELECT COUNT(*) AS ShouldBeZero
FROM ApprovalPolicyTemplate t
CROSS APPLY OPENJSON(t.DefinitionJson, '$.rules') r
CROSS APPLY OPENJSON(r.value, '$.bands') b
CROSS APPLY OPENJSON(b.value, '$.stages') s
WHERE JSON_VALUE(s.value, '$.eligibility.type') = 'role'
  AND NOT EXISTS (SELECT 1 FROM Roles
                  WHERE Name = JSON_VALUE(s.value, '$.eligibility.value')
                    AND CustomerId IS NULL);

-- 3. Every fact used by a template is defined. Must return 0.
SELECT COUNT(*) AS ShouldBeZero
FROM ApprovalPolicyTemplate t
CROSS APPLY OPENJSON(t.DefinitionJson, '$.rules') r
CROSS APPLY OPENJSON(r.value, '$.extra') x
CROSS APPLY OPENJSON(x.value, '$.conditions') c
WHERE NOT EXISTS (SELECT 1 FROM ApprovalFactDefinition
                  WHERE FactKey = JSON_VALUE(c.value, '$.fact'));

-- 4. Exactly one platform-mandated policy, and it is active.
SELECT COUNT(*) AS ShouldBeOne FROM ApprovalPolicy
WHERE CustomerId IS NULL AND Origin = 2 AND IsEnabled = 1;

-- 5. No customer policy is enabled by default (decision D1). Must return 0.
SELECT COUNT(*) AS ShouldBeZero FROM ApprovalPolicy
WHERE CustomerId IS NOT NULL AND Origin = 1 AND IsEnabled = 1
  AND ActiveVersionId IS NULL;

-- 6. No orphaned funds holds.
SELECT COUNT(*) AS ShouldBeZero FROM FundsHold h
WHERE h.Status = 1 AND NOT EXISTS (
    SELECT 1 FROM ApprovalRequest r
    WHERE r.ApprovalRequestId = h.ApprovalRequestId AND r.Status IN (1,2,3,4));

Queries 1, 2 and 3 should also be automated tests, not just a checklist. Query 1 is the one that keeps "freezing a card is never delayed" true after a future refactor.

Appendix A — Prototype to production map

Prototype symbol Production equivalent
evaluate(actionKey, facts) IRuleEvaluator.Evaluate — pure
addStage() RequirementMerger — union + strictest-wins
poolFor(stage, initiator) IApproverResolver.ResolveAsync (§6.6)
unsat(stages, initiator) Feasibility check at submission and at policy save
feasibility() POST /versions/{v}/validate
runTest() POST /versions/{v}/simulate
sweep() ApprovalExpiryWorker
S.requests ApprovalRequest aggregate + repository
S.audit ApprovalAuditLog, hash-chained
canAct(r, uid) viewer.canApprove, computed server-side
tracker(r) <ApprovalTracker />
preview(ev) <ApprovalPreviewPanel />
S.mode === 'watch' EnforcementMode.Shadow
Persona switcher Demo device only — not shipped
"Skip 24h" Demo device only — not shipped

Appendix B — Reading order for a new engineer

  1. Open approval-workflow-demo.html. Run the full journey twice.
  2. This document: §6 (the engine) and §11 (the state machine).
  3. §9 (backend) or §10 (frontend) depending on your role.
  4. §15 (failure modes) before writing any code.
  5. §2 (standards) so you know why the invariants are non-negotiable.