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
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
Customer = the account (business or individual). Never called "account" in code.CustomerUser = a person on that account. Never called "user" alone in code.Role = the existing role record. We add nothing to it.Approval* so it is obvious what belongs to this feature.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
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.
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 |
| 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 |
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.
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.
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 |
-- 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
CustomerIdand references people byCustomerUserId. 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 anApproval*table instead.
-- ═══════════════════════════════════════════════════════════
-- 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)
);
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.
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
CustomerIdis mandatory on everyApproval*entity. A cross-tenant read on this data is a reportable incident. Do not rely on remembering a.Where().
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.
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
expressionorscriptoperator, 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.
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.
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.
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));
}
}
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();
}
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.
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 ApprovalsSettingsPage → EmptyState, 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.
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 TemplateGalleryPage → TemplateCard, RecommendationBanner
Tables
ApprovalPolicyTemplate R · ApprovalPolicy W · ApprovalPolicyVersion W (Draft)
Adopting a template inserts one
ApprovalPolicyrow and oneApprovalPolicyVersionwithStatus = 1 (Draft). Nothing becomes live.
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 RuleEditorPage → ActionTypeTabs, BandTable → BandRow, ExtraCheckList, TimingPanel, FeasibilityBanner
Tables
ApprovalPolicyVersion R/W (draft only) · ApprovalActionType R · ApproverGroup R · CustomerUsers R · Roles R
Autosave writes
DefinitionJsonon the draft version only. A version withStatus = 3 (Active)is never updated — editing creates a new version.
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 CustomRuleBuilderPage → ConditionList → ConditionRow, StageList → StageRow, 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.
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 ApproverGroupsPage → GroupCard → MemberToggleList, 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
ApprovalRequestStageApproverfor every live request, then re-check feasibility.
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 PolicyTesterPage → HypotheticalForm, 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.
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 PublishReviewPage → ConsequenceDiff, 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.
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
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)
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.
Balanceused to mean one thing and now means three. Rename the raw property toLedgerBalanceand 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 WalletPage → BalanceBreakdown, ActivityTable → PendingRow
Tables
FundsHold R · ApprovalRequest R (live only) · your existing wallet/ledger/transaction tables R
The activity list is a union: real transactions ∪
ApprovalRequestrows withStatus IN (1 Pending, 2 InfoRequested, 3 Approved, 4 Executing). Available balance = ledger −SUM(FundsHold.Amount WHERE Status = 1).
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 SendMoneyPage → ApprovalPreviewPanel ★ (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.
| 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 SentForApprovalPage → PendingConfirmation, NextStepHint
Tables
ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R · CustomerUsers R
| 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 ApprovalInboxPage → InboxTabs, RequestTable, BulkApproveBar, ExpiryCountdown
Tables
ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R · ApprovalDecision R · CustomerUsers R · ApprovalDelegation R
The inbox query is
ApprovalRequestStageApproverjoined to open stages — that is why the table is materialised. Do not resolve roles and groups per row at read time.
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
| 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
UPDLOCKonApprovalRequest.UQ_Decision_OnePerStagemakes a duplicate approval physically impossible even if the lock and rowversion both fail.
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 RequestDetailPage → ApprovalTracker ★, RequestPayload, WhyRequired, DecisionPanel
Tables
ApprovalRequest R · ApprovalRequestStage R · ApprovalRequestStageApprover R/W (LastRemindedAtUtc) · ApprovalDecision R · ApprovalRequestThread R · CustomerUsers R
Remind writes only
LastRemindedAtUtcand enqueues an outbox row. It never touches the decision tables.
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
viewerblock is derived from these four tables server-side. The client is never sent the eligibility rules.
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
ApprovalDecisionplusApprovalAuditLog, includingPreviousDecisionHash/DecisionHashso an auditor can verify the chain.
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.
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.RoleIdis written only by the executor after full approval. On execution, re-runEligibilityRefresher— the person's approver pools change immediately.
| 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/SELECTon these two tables and nothing else.
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
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.ActorCustomerUserIdisNULL— the audit entry is a system event.
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)andClockPausedAtUtcis cleared. Existing approvals are untouched.
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.
/api/v1. All routes require a bearer token.CustomerId claim, never from a route parameter or body.Idempotency-Key header.202 Accepted means pending approval, not queued.# ── 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=
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." } }
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 |
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.
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
[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);
}
}
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()
};
}
}
IApprovalExecutionContextmust be backed byAsyncLocal, notHttpContext. Approved execution runs in the outbox consumer, which has noHttpContext— so the gate would fire again and you would get an infinite loop of approval requests creating approval requests.
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;
}
}
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.
| 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.
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.
CommandSchemaVersion. Bump on any shape change.CommandTypeKey against a registry, not Type.GetType(assemblyQualifiedName) — that breaks on version bump, assembly rename or moving the class.IApprovalPayloadUpcaster in the same PR.RequiresManualIntervention, alert engineering, keep the hold, tell the customer. Never fail silently.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
# 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.
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.
// 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]);
}
| 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.
┌──────────────────┐
│ 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. |
| 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 | |
| Executed / failed | Initiator, all approvers | Push, email |
| Rules changed | Owner, everyone who can move money | |
| 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.
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
[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
}
(policy.json, facts.json) → expected-requirement.json. Every production surprise becomes a new fixture. This is how merge semantics stop regressing.
[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() { }
CommandPayloadJson → detected at execution, security incident raisedexpression operator → refused at validationCustomerId read → impossibleApprovalPreviewPanel updates on input, debounced| 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.
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 |
| # | 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 |
| 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 |
| # | 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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
§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.RiskDirectionis not decoration. An automated test asserts that every row withRiskDirection = 'Reduces'hasValuationKind = 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.
R read · W write · A append-only. Tables in italics are your existing ones and must not be altered.
| 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 |
| 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 |
— |
| 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) |
| 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 |
| 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 |
— |
| 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) |
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
MERGEorIF NOT EXISTS, never bareINSERT. They belong in an EF Core migration or aDbSeederthat runs at startup, not in a runbook a human has to remember.
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);
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 = 1on every fact. If the risk service is down anddestinationCountryRiskcannot be resolved, the condition evaluates true and the extra approval fires. A dependency outage must never silently disable a control.
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.
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);
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": falseis checked by the override handler: break-glass skips customer stages, never platform ones.
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.valuein every template stores the role name, so a rename breaks live policies. If renaming is ever required, migrateDefinitionJsonin the same transaction. A stronger alternative is to storeRoleIdin eligibility instead of the name — decide this before Phase 1 ships, because migrating later means rewriting every stored policy version.
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.
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:
ApprovalDecision.fresh-destination extra rule regardless of amount.// 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";
}
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.
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.
| 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 |
approval-workflow-demo.html. Run the full journey twice.