Treasury & Cash Management — Combined Requirements & Build Specification

Single file. Screenshots, requirements, data model, APIs, .NET and React structure, seed data.

Version 4.0 — final
Stack .NET Web API · React · SQL Server · Azure Functions
Context Existing live application. Existing solutions, existing database, existing React app, existing Function App.
Reconciliation There is no reconciliation module. Treasury polls providers directly and verifies balances against our own records — §6.
Prototype treasury-prototype.html — every screen and every action in this document is live and clickable.
Scope Internal treasury workspace — 18 screens, 38 endpoints, 25 tables
Screenshots Every image below is the working prototype

How to use this document

It is written to be handed to several 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 (tables), §6 (the gate), §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. §5, §18 and §20 for tables and seed data.

Conventions used throughout

Before writing any code, produce TREASURY-CONVENTIONS.md per §11. This feature goes into a live system; its structure, auth, logging, error shape, migrations and React components are already decided. Find those decisions and copy them.


1. Executive summary

The platform moves customer money through eleven third parties across five modules. Customers see wallets, cards, payments and bank accounts, and those work. But the money backing those features is spread across wallet custody on three blockchains, collateral at three card issuers, bank accounts at four banking partners, prefunded balances at four payment providers, and trading balances at two exchanges.

Twenty-six accounts. Eleven providers. Fourteen connections. Nobody can open one screen and say how much the company holds, whether customer balances are covered, or which account is about to run dry.

Today that picture is assembled by hand from provider portals. It takes two to four hours and is always as at yesterday. Every funding decision is made on stale data.

The consequence is not theoretical. When card collateral runs low, customer card authorisations start declining within minutes, and nobody connects the support tickets to the collateral balance for another day.

This feature answers four questions continuously, and closes the loop without a human noticing something:

# Question What breaks without it
1 How much do we hold, and where? Every funding decision runs on yesterday's numbers
2 Do we cover what we owe customers? Safeguarding regimes require customer assets to at least equal customer liabilities at all times. Falling below is a regulatory event, not an accounting one.
3 Is anything about to run dry? Card declines, failed payouts, stuck on-chain transfers
4 Who moved this and who approved it? One person can move company and customer funds unchecked — fraud risk and audit finding

A worked example. At 08:02 card spend has drawn collateral down to 185,000 USDC against a 200,000 minimum. Without treasury, an analyst notices around 11:00, funds go out at 14:30, and customers see declines for five hours. With treasury, the threshold rule fires at 08:02, checks the connection is reconciled, checks the source keeps its own floor, and creates a movement for approval. Funds are on-chain by 08:11. No customer sees a decline.


2. Standards this must satisfy

Standard What it demands Where it lands
Safeguarding / client money rules Customer assets segregated from company assets and at least equal to customer liabilities at all times §6.2 segregation, §7 S15, KPI coverage ratio
Maker–checker (segregation of duties) The person who initiates a value movement cannot be the person who approves it BR-05, §7 S9, enforced in the aggregate
Auditability Every state change attributable to an actor, with a reason, permanently AuditLog, append-only, §7 S17
Four-eyes on privileged configuration Changing an approval threshold or an allowlist destination is itself a privileged act BR-09, §5 Destination
Data integrity for regulatory reporting Historical positions reproducible as at any past date PositionSnapshot append-only, BR-10
Operational resilience Automation must not act on unverified data The verification gate, §6.2

3. Scope

3.1 What treasury owns

Capability Notes
Aggregate positions across every provider and module Reads from the existing module solutions, does not re-integrate providers
Thresholds — minimum, target, maximum per account Drives alerting and automation
Movements between accounts we own With preview, policy enforcement and maker–checker approval
Funding rules Automated top-up and sweep, gated on verification state
Client coverage monitoring Reads the liability figure from the module ledgers; shows nothing when any module is silent
Forecast and FX exposure Planning views
Audit trail Immutable

3.2 What treasury must NEVER do

Never Because
Send money to a third party Treasury moves money between accounts we own. It is not a payments system.
Re-integrate a provider Every module solution already has working provider clients. Two code paths to one provider will drift.
Claim to reconcile The variance check compares totals. It does not match transactions and it is not an attestation — §12.1.
Compute client liabilities Same. Treasury reads the figure with its source timestamp.
Offer an override on the verification gate An override would be used routinely and the control would be worth nothing
Replace the general ledger The ledger stays authoritative for accounting
Be shown to a customer No screen in this document is customer-facing

3.3 There is no reconciliation module — what that means

Treasury polls each provider through the module solutions and stores what it gets. That is the whole data pipeline.

Polling gives you freshness, not correctness. Knowing that Rain reports 185,000 USDC is not the same as knowing 185,000 is right. A stale balance and a wrong balance are different failures, and polling only solves the first.

So treasury runs its own verification, built from data it already has. See §6 for the mechanics.

What a reconciliation module would give Available here?
Feed arrival monitoring Yes — sync health
Balance freshness Yes — data age
Provider figure vs our books Mostly — the variance check, §6.3
Transaction-level matching No
Break investigation and explanation No
Signed daily attestation No
Client liability figure Yes — it comes from our own ledgers, §6.6

The two real losses are transaction-level matching and attestation. Both matter for audit. Neither is what the gate is for — the gate exists to stop automation acting on bad data, and the three checks in §6.2 do that.

BR-25 MUST Every screen showing verification state says it is a self-check against our own records, not an independent reconciliation. An operator who believes this is reconciliation will trust it further than it deserves, and misplaced confidence is more dangerous than no check at all.

3.4 Keep reconciliation on the roadmap

Build treasury now — it delivers on its own and the gate is real. But when reconciliation arrives the change is small: ConnectionSyncState and BalanceCheck remain as an operational health layer, and the gate gains a fourth condition reading reconciliation's certification. Design IGateEvaluator so a fourth check can be added without touching the other three.


4. Roles in the process

Role View Initiate Approve Configure
TREASURY_VIEWER Yes No No No
TREASURY_ANALYST Yes Yes No No
TREASURY_MANAGER Yes Yes Yes No
TREASURY_ADMIN Yes Yes No Yes
TREASURY_AUDITOR Yes No No No

TREASURY_ADMIN cannot approve. The person who configures a funding rule must not be the sole approver of the movements that rule produces. This separation is the reason both roles exist.

Map these onto the existing role/claim model. Do not build a parallel permission system.


5. Data model

5.1 The four-layer model

Everything hangs off four nested concepts. Learn these and the database, the API and the screens all become predictable.

MODULE       wallets · cards · banks · payments · exchange
   ↓
PROVIDER     DFNS · Rain · StraitsX · Wasabi · Noah · Tazapay
             OpenPayd · Thunes · Mesta · Kraken · Binance
   ↓
CONNECTION   one provider serving one module — 14 of them
             SHARED WITH RECONCILIATION
   ↓
ACCOUNT      a real bank account or wallet at that provider
             carries a classification and thresholds
   ↓
POSITION     asset × network × state
Module Providers Connections
wallets DFNS dfns-wallets
cards Rain, StraitsX, Wasabi rain-cards, straitsx-cards, wasabi-cards
banks Noah, Tazapay, OpenPayd, Thunes noah-banks, tazapay-banks, openpayd-banks, thunes-banks
payments Noah, Mesta, Tazapay, Thunes noah-payments, mesta-payments, tazapay-payments, thunes-payments
exchange Kraken, Binance kraken-exchange, binance-exchange

Noah, Tazapay and Thunes each serve two modules. Those are two separate connections, two separate balance checks, and two separate positions with separate thresholds. Never sum them, never net them, never show them as one balance. A healthy-looking combined figure hides a payout rail about to fail.

5.2 Account classification

Every account carries exactly one. This drives the segregation control and the coverage calculation, so it is enforced logic, not a display label.

Classification Meaning May send to
CLIENT_SAFEGUARDED Backs customer balances Only other CLIENT_SAFEGUARDED
COLLATERAL Pledged to a card issuer so cards can authorise Per the pledge terms
PREFUND Sitting at a rail so payouts settle Corporate accounts
CORPORATE_OPERATING Day-to-day company money Any corporate
CORPORATE_RESERVE Buffer and regulatory capital Any corporate
GAS_TANK Native tokens solely for network fees Corporate only
FEE_REVENUE Fees collected, awaiting recognition CORPORATE_OPERATING

5.3 The five position states

Not interchangeable. Showing the wrong one is the most common and most damaging bug in a treasury system.

State Meaning Common mistake
available Free to move right now
pendingIn Incoming, not yet confirmed Counting it as available
pendingOut Submitted outbound, not final Forgetting it — the money is effectively gone
reserved Held against card authorisations Treating it as available
total available + pendingIn + reserved Using it on funding screens — it overstates capacity

Movable is different again: MAX(0, available − minimumThreshold). The New Movement screen validates against movable, not available.

Any screen offering a funding action uses available. Any screen reporting holdings uses total. Never display a single unlabelled "balance" anywhere in the product.

5.4 Existing tables — read only, not modified

Table Used for Access
Your user table Resolving initiators and approvers R
Your role/claim tables Permission checks R
Your ledger / transaction tables Forecast inputs, exposure reporting R
Your FX rate table, if one exists Base-currency conversion R — reuse, do not duplicate
Your audit log, if one exists All treasury audit entries R/W — reuse, do not duplicate
Your customer wallet / account tables The client-liability figure, via the module contract R

No existing table is altered. No new columns, no changed types, no new constraints. This database is live.

5.5 New tables

Full DDL is in §18. This is the map.

Table Rows The thing to remember
Module 5 Reference — check whether a registry already exists
Provider 11 Add a row to onboard a provider. No code change.
Connection 14 Unique on (ProviderId, ModuleId). Carries poll interval, staleness threshold, variance tolerance and verification mode.
Asset 11 Carries Decimals — drives rounding everywhere
Network 3 NULL network = fiat
ConnectionSyncState 14 One row per connection. Poll outcome, consecutive failures, last error.
BalanceCheck per account per poll The variance check. Append-only — the history is the evidence.
ClientLiabilitySnapshot per module, 15 min Coverage denominator, from our own ledgers
TreasuryAccount 26 Carries Classification
AccountThreshold 22 Min / target / max. History kept.
PositionSnapshot millions Append-only. Never UPDATE.
Movement thousands IdempotencyKey unique
MovementApproval 2× movements Unique on (MovementId, ApproverUserId) — enforces one approval per person
MovementEvent 5× movements Every state transition
Destination tens Allowlist. Adding one needs its own dual approval.
LiquidityRule ~10 Config as columns, not JSON, so it can be constrained
RuleExecution thousands Every evaluation, including ones that did nothing
CoverageSnapshot 24/day Ratio plus the liability figure and its module source timestamp
ForecastPoint ~2,300/day Rebuilt daily
FxRate many Never delete — historical conversion must be reproducible
AlertInstance hundreds Deduplicated on DedupKey
AuditLog millions No UPDATE or DELETE grant
IdempotencyRecord thousands 24-hour TTL
SystemSetting 7 Kill switch, thresholds, buffer
JobRun many One row per job execution

6. Verification and the policy engine

6.1 Why a gate exists at all

An automated rule decides how much to move by reading a balance. If that balance has not been verified, the system moves real money on a number nobody checked. A balance reading high causes under-funding and card declines; reading low causes over-funding and idle capital. At machine speed, on a five-minute timer, either compounds before a human notices.

That is the entire justification for everything in this section.

6.2 The verification gate

Three checks, all computed by treasury from its own data.

# Check Question Blocks automation when
1 Freshness How old is this number? dataAgeSeconds > Connection.StalenessThresholdSeconds
2 Sync health Did recent polls actually succeed? ConsecutiveFailures >= 2
3 Variance Does the provider's balance match what our records say it should be? ABS(varianceBase) > Connection.VarianceToleranceBase

Manual movements remain allowed against a blocked connection with +1 approver, exactly as before. Automation is refused, exactly as before.

There is still no override. Clearing a block means fixing the sync, or explaining the variance. A button that waves it through would be used routinely and the control would be worth nothing.

6.2.1 Gate evaluation

public GateResult Evaluate(ConnectionSyncState s, BalanceCheck check, Connection c, TimeProvider clock)
{
    class="cm">// 1 — freshness
    var age = clock.GetUtcNow() - s.LastSuccessAtUtc;
    if (s.LastSuccessAtUtc == default)
        return GateResult.Blocked(class="str">"No balance has ever been retrieved for this connection.");
    if (age.TotalSeconds > c.StalenessThresholdSeconds)
        return GateResult.Blocked(
            $class="str">"Last successful sync was {Humanise(age)} ago, beyond the {c.StalenessThresholdSeconds}s threshold.");

    class="cm">// 2 — sync health
    if (s.ConsecutiveFailures >= 2)
        return GateResult.Blocked(
            $class="str">"{s.ConsecutiveFailures} consecutive sync failures. Last error: {s.LastError}");

    class="cm">// 3 — variance
    if (c.VerificationMode == VerificationMode.FreshnessOnly)
        return GateResult.Degraded(                       class="cm">// allowed, but with reduced caps
            class="str">"Variance checking is unavailable for this connection.");

    if (check == null)
        return GateResult.Blocked(class="str">"No balance check has been performed.");

    if (clock.GetUtcNow() - check.CheckedAtUtc > TimeSpan.FromSeconds(c.StalenessThresholdSeconds * 2))
        return GateResult.Blocked(class="str">"The last balance check is too old to rely on.");

    if (Math.Abs(check.VarianceBase) > c.VarianceToleranceBase)
        return GateResult.Blocked(
            $class="str">"Provider balance differs from our records by {check.VarianceBase:N0}, " +
            $class="str">"beyond the {c.VarianceToleranceBase:N0} tolerance.");

    return GateResult.Clean(s.LastSuccessAtUtc, check.VarianceBase);
}

6.2.2 Degraded mode — be deliberate about it

Some providers will not support the activity feed the variance check needs. For those, set VerificationMode = FRESHNESS_ONLY.

Rule
Degraded is opt-in per connection and requires TREASURY_ADMIN plus an audit entry. It is never the default.
A degraded connection carries halved MaxAmountPerRun and MaxDailyValue on every rule touching it.
The Providers screen shows Freshness only in amber, never Allowed in green.
Any connection whose module could supply activity but currently errors is blocked, not degraded.

The failure to avoid is degraded mode quietly becoming the norm because it is easier. Report the count of degraded connections on S1 and review it monthly.


6.3 The variance check

The one piece of genuine verification treasury can do alone.

expected = lastVerifiedBalance
         + Σ module-recorded inflows since lastVerifiedAt
         − Σ module-recorded outflows since lastVerifiedAt
         + Σ treasury movements settled since lastVerifiedAt

variance     = providerReportedBalance − expected
varianceBase = variance × fxRate

Your module solutions already record every customer transaction at each provider. That record is the second opinion the variance check needs.

Worked example. Rain reports 185,000 USDC. Our last verified figure was 250,000. Since then the cards module recorded 66,250 of settled card spend, and treasury settled no movements. Expected is 183,750. Variance is +1,250 — inside a 50,000 tolerance, so the gate stays clean and the figure becomes the new verified baseline.

If instead Rain reported 130,000, the variance would be −53,750: outside tolerance, automation blocked on rain-cards, and an alert raised. That is precisely the case where a funding rule reading 130,000 would over-fund by 55,000.

6.3.1 What it does not catch

A matching error on both sides is invisible. If the module failed to record a transaction and the provider never processed it, provider and expected agree and the check passes. Real reconciliation matches transaction by transaction and would catch it.

State this on the screen. An operator who believes the variance check is reconciliation will trust it further than it deserves.

6.3.2 Baseline management

Rule
A check inside tolerance updates LastVerifiedBalance and LastVerifiedAtUtc — this is the new baseline
A check outside tolerance does not update the baseline. It stays anchored to the last good figure so the next check measures the same gap rather than resetting.
An operator with TREASURY_ADMIN may accept a variance with a mandatory reason. That rebaselines and writes an audit entry. It does not delete history.
Accepting a variance never happens automatically, on any schedule, for any size

6.4 Polling and refresh

6.4.1 Cadence

Connection type Interval Why
Wallets, on-chain 60 s Cheap, chain RPC, and gas tanks fail fast
Exchange 120 s Cheap API, balances move
Cards 300 s Rate-limited issuer APIs, collateral moves on spend
Banks, payments 900 s Slow-moving, strict rate limits

Set per connection via PollIntervalSeconds. Do not run every provider on one global timer — you will hit rate limits on the slow ones and under-poll the fast ones.

6.4.2 Rate limits are a first-class concern

Provider APIs are your only source of balances, and you are now calling them continuously.

Rule
Record each provider's documented rate limit on Connection.RateLimitPerMinute during discovery
Every connector call goes through the existing Polly policy with a per-provider rate limiter, not a global one
On 429, back off exponentially and increment ConsecutiveFailures. Do not retry immediately in a loop — that is how an API key gets suspended.
Two consecutive failures blocks automation on that connection, which is the correct outcome
Skip writing a snapshot when the position is unchanged. Most polls return the same numbers; storing them all is what makes PositionSnapshot unmanageable.

6.4.3 The Refresh button

Manual refresh is genuinely useful — an analyst about to fund an account wants the current number, not one from four minutes ago.

Rule
Available on S1 (refresh all), S4, S5 (this account), S11 and S12 (this connection)
Rate-limited server-side per connection, default one manual refresh per 30 s. Returns 429 REFRESH_TOO_SOON with seconds remaining.
The button uses the existing button-loading mechanism and shows the completed timestamp on success
A refresh runs the variance check too — a fresh balance with a stale check is not much use
Refreshes are audit-logged with the actor. Bursts of manual refreshes are a signal that a poll interval is set too slow.
A refresh never bypasses the gate. It may clear a freshness block by producing fresh data; it cannot clear a variance block.

6.4.4 Webhooks where available

Where a provider pushes balance changes, take them — but treat them as an accelerator, not a replacement.

Rule
A webhook writes a PositionSnapshot with SourceType = WEBHOOK
Polling continues regardless. A missed webhook is silent; a missed poll shows as a freshness block.
Verify the signature and reject replays, per §9 SEC-06 of the main specification

6.5 Segregation

public PolicyResult CheckSegregation(TreasuryAccount source, TreasuryAccount dest,
                                     MovementType type)
{
    if (source.Classification != Classification.ClientSafeguarded)
        return PolicyResult.Pass();

    if (dest.Classification == Classification.ClientSafeguarded)
        return PolicyResult.Pass();

    class="cm">// The single documented exception.
    if (type == MovementType.FeeSweep &&
        source.Classification == Classification.FeeRevenue)
        return PolicyResult.Pass();

    return PolicyResult.Block(class="str">"SEGREGATION_VIOLATION",
        $class="str">"Client funds cannot move to a {dest.Classification} account.");
}

6.5.1 Approval quorum — frozen at creation

requiredApprovals = amountBase > settings.ApprovalThresholdBase ? 2 : 1;
if (!sourceGate.IsClean || !destGate.IsClean) requiredApprovals += 1;

If a movement needs two approvers because it was worth $150k at 09:00, it still needs two at 15:00 even if FX moved and it is now worth $95k. Freeze both RequiredApprovals and AmountBase at creation. Recomputing them on read is a subtle and serious bug.

6.6 Client liabilities — where the coverage denominator comes from

This is less affected than it appears. Liabilities always came from your books, never from reconciliation — reconciliation verified them, it did not produce them.

Each module solution knows what it owes customers: the wallets module holds every customer wallet balance, the banks module every customer account balance. Sum them.

6.6.1 New module contract endpoint

class="cm">// GET /api/treasury/client-liabilities
public record ClientLiabilityDto(
    string   ModuleCode,          class="cm">// class="str">"wallets"
    string   AssetCode,           class="cm">// class="str">"USDC"
    decimal  TotalOwedToCustomers,
    int      CustomerAccountCount,
    DateTime AsOfUtc);            class="cm">// when the module computed it

Aggregated into ClientLiabilitySnapshot, which becomes the K-2 denominator.

6.6.2 What changes on S15

Before Now
Sublabel "From our own ledgers — not independently reconciled"
Unavailable when reconciliation is down Unavailable when any module fails to report
Attested daily Not attested

BR-26 MUST If any module fails to report its liabilities, the coverage ratio shows an em dash and states which module is missing. Never compute a ratio from a partial denominator — an incomplete liability figure makes coverage look better than it is, which is the most dangerous possible error on that screen.

This preserves the behaviour of T-12, which remains the test to gate every release on. Only the failing dependency changes.


6.7 Movement lifecycle

DRAFT ─→ PENDING_APPROVAL ─→ APPROVED ─→ SUBMITTED ─→ CONFIRMING ─→ SETTLED
              │        │                      │             │
              │        └──→ CANCELLED         └──→ FAILED ──┴──→ MANUALLY_RESOLVED
              └──→ REJECTED

No state may be skipped. Every transition writes a MovementEvent with actor, timestamp and reason.

6.8 The six rule-engine guards

The highest-risk code in this feature — it moves real money unattended. Each guard is unit tested independently.

# Guard Skip outcome
1 Global kill switch KILL_SWITCH
2 Verification gate, both sides GATE_BLOCKED
3 Position freshness STALE_DATA
4 Trigger condition NOT_TRIGGERED
5 Source keeps its own floor SOURCE_FLOOR
6 Daily caps DAILY_CAP

Every evaluation writes a RuleExecution row, including evaluations that decided to do nothing. Without skip records, "why didn't the rule fire last night?" is unanswerable — and that question is asked after every incident.


7. Screens

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

The layouts show information hierarchy. Colours, spacing, typography and component appearance come from your existing design system — see §10 and §11.


S1 · Overview

Overview
Prototype — click to enlarge

Purpose. The five-second test. An analyst opening this screen knows within five seconds whether anything is wrong and whether anything needs them. Everything else here is a drill-down.

Requirements

ID Requirement
AC-1.1 Four KPI cards: total holdings, client coverage, accounts below minimum, connections not verified. Definitions in §12.
AC-1.2 Total holdings uses total, never available, and is rendered in a neutral tone — a total is not good or bad.
AC-1.3 Accounts below minimum uses available, never total. Reserved funds cannot prevent a decline.
AC-1.4 0 below minimum is green; ≥1 is red. There is no amber — one account below minimum is an incident.
AC-1.5 Five module tiles, fixed order, each a click target. A 3px top accent is red when the module has any issue.
AC-1.6 Needs attention is severity-ordered PAGE → CRITICAL → WARN → INFO. Messages are server-composed; the frontend never builds these strings.
AC-1.7 Threshold status lists accounts with a minimum, sorted by available ÷ minimum ascending.
AC-1.8 Five independent queries. Each card renders as its own query resolves; first paint is never blocked on the slowest.
AC-1.9 A red banner appears on every treasury screen while any connection is blocked, and an amber one while any runs on freshness only.

APIs

GET /api/v1/treasury/positions/summary
GET /api/v1/treasury/coverage
GET /api/v1/treasury/alerts?status=OPEN&limit=8
GET /api/v1/treasury/movements?limit=5&sort=createdAt:desc
GET /api/v1/treasury/accounts?hasThreshold=true&sort=thresholdRatio:asc&limit=9

React TreasuryOverviewPageKpiCard ×4, ModuleTile ×5, AlertRow, ThresholdBar, MovementRow

Tables

vw_PositionBase R · vw_ConnectionGate R · AlertInstance R · CoverageSnapshot R · Movement R · AccountThreshold R

The coverage KPI reads CoverageSnapshot, whose liability figure came from the module ledgers. If any module is silent, the card shows an em dash — never a ratio from a partial denominator.


S2 · By module

Position by module
Prototype — click to enlarge

Purpose. How each business area sits, side by side, without drilling in. One card per module, each containing the same account table used on S4 so users learn one table.

Requirements

ID Requirement
AC-2.1 Five cards in fixed module order. A module with no accounts still renders, with an empty state.
AC-2.2 Card header reads {module} · {n} connections · {m} accounts with the module total right-aligned.
AC-2.3 The header total equals the sum of that card's USD equiv column. QA verifies this by hand against §20.
AC-2.4 Column set is identical to S4 minus nothing. Do not invent a per-module column layout.

APIs

GET /api/v1/treasury/positions/by-module
GET /api/v1/treasury/accounts

React ModulesPageModuleCard, AccountTable

Tables

vw_PositionBase R · vw_ConnectionGate R

Group client-side by moduleCode. Do not issue five separate account queries.


S3 · Module detail

Cards
Prototype — click to enlarge

Purpose. One business area, grouped by connection. This grouping is the point of the screen.

Requirements

ID Requirement
AC-3.1 Grouping is by connection, not by provider. Noah appears once under Banks and once under Payments, each with its own gate box and accounts.
AC-3.2 Each connection card carries a gate box before its account table.
AC-3.3 The gate box shows verdict, reason, and a mandatory provenance line reading "Source: treasury balance sync · {provider} · {module}".
AC-3.4 A blocked connection's gate box states what must be fixed — the sync, or an explained variance — and that there is no override.
AC-3.5 Module totals never merge two connections of the same provider.

APIs

GET /api/v1/treasury/positions/by-module?moduleCode=cards
GET /api/v1/treasury/connections?moduleCode=cards
GET /api/v1/treasury/accounts?moduleCode=cards

React ModuleDetailPageKpiCard ×3, ConnectionCard, GateBanner, AccountTable

Tables

vw_PositionBase R · vw_ConnectionGate R · Connection R · ConnectionSyncState R · BalanceCheck R

The provenance line is mandatory. Without it, people raise treasury bugs for provider sync problems.


S4 · Accounts

Accounts
Prototype — click to enlarge

Purpose. The working list. Every account in the platform, filterable three ways. An analyst who knows roughly what they are looking for starts here.

Requirements

ID Requirement
AC-4.1 Search debounced 300 ms, matching account name, provider, asset and network, case-insensitive.
AC-4.2 All three filters and the sort persist in the URL query string so a view can be shared.
AC-4.3 Available and Reserved are separate columns. No column shows a combined figure.
AC-4.4 USD equiv uses total; the column header must read "USD equiv" so that is unambiguous.
AC-4.5 Reserved appends (est.) with a tooltip when the connector cannot report it separately.
AC-4.6 Threshold pill: Below min / Near min / Above max / OK / em dash. Near is available < minimum × 1.15.
AC-4.7 Recon pill shows Clean or Stale, with the gate reason on the tooltip.
AC-4.8 On filter change the existing rows stay visible under a loading overlay. The table never blanks.
AC-4.9 Sorting is server-side against a whitelist: available, totalBase, accountName, healthStatus.

APIs

GET /api/v1/treasury/accounts?q=&moduleCode=&classification=&sort=&page=1&pageSize=50

React AccountsPageFilterBar, AccountTable, PositionCell, ClassificationPill, HealthPill, GatePill

Tables

vw_PositionBase R · vw_ConnectionGate R

PositionCell exists so available-vs-total cannot be got wrong by accident. Use it everywhere a balance appears.


S5 · Account detail

Rain card collateral
Prototype — click to enlarge

Purpose. One account in full: the five position states, thresholds, the gate, and the actions to fund it or move out of it.

Requirements

ID Requirement
AC-5.1 Three separate cards — Available, Reserved, In flight. A single combined balance never appears on this screen.
AC-5.2 Available renders in a danger tone when the account is below its minimum.
AC-5.3 Reserved appends "(estimated)" with an info icon when reservedIsEstimate is true.
AC-5.4 In flight is signed, with {n} in · {m} out beneath.
AC-5.5 The gate box carries the mandatory provenance line.
AC-5.6 Threshold bar is hidden entirely when no threshold is configured; the pill then reads an em dash.
AC-5.7 Fund this account opens S6 with destination preset; Move out presets the source.
AC-5.8 Edit thresholds requires TREASURY_ADMIN; others see it disabled with a tooltip naming the role.
AC-5.9 Thresholds enforce 0 ≤ critical ≤ min < target ≤ max, where max 0 means unbounded.
AC-5.10 Saving a minimum above current available is allowed but must warn before save.
AC-5.11 A threshold change writes an audit row carrying old and new values.

APIs

GET   /api/v1/treasury/accounts/{id}                 → returns ETag
GET   /api/v1/treasury/accounts/{id}/positions
GET   /api/v1/treasury/movements?accountId={id}&limit=20
PATCH /api/v1/treasury/accounts/{id}/thresholds      → If-Match required

React AccountDetailPageKpiCard ×3, GateBanner, DefinitionList, ThresholdBar, ThresholdModal, MovementList

Tables

TreasuryAccount R · AccountThreshold R/W · vw_CurrentPosition R · vw_ConnectionGate R · Movement R · AuditLog W

PATCH requires If-Match. On mismatch return 409 and tell the user someone else changed the account.


S6 · New movement

New movement — valid
Prototype — click to enlarge

Purpose. The most safety-critical screen in the product. It must make a correct movement easy and an incorrect movement impossible.

Requirements

ID Requirement
AC-6.1 The frontend implements no business validation. It calls preview on every change and renders blockers and warnings exactly as returned.
AC-6.2 Preview is debounced 300 ms and the in-flight request is cancelled when a new one starts.
AC-6.3 Account selects are grouped by module and searchable. Changing the source clears the destination.
AC-6.4 The amount hint quotes movable, not available, and respects the asset's decimal places.
AC-6.5 The preview panel shows route, classification route, source after, destination after, fee, all-in cost and approvals required.
AC-6.6 Submit is disabled if and only if blockers.length > 0. Never enabled optimistically.
AC-6.7 If the preview request itself fails, Submit is disabled. Fail closed.
AC-6.8 One idempotency key is generated when the modal opens and reused for every submit attempt of that form.
AC-6.9 A double-click creates exactly one movement; the second response returns the original with HTTP 200.
AC-6.10 On success the modal closes, a toast names the movement and the approvals needed, and the user lands on S8.

APIs

POST /api/v1/treasury/movements/preview      # debounced 300ms, cancel in-flight
POST /api/v1/treasury/movements              # Idempotency-Key: the modal's UUID

React NewMovementModalAccountSelect ×2, AmountInput, PreviewPanel, BlockerList, WarningList

Tables

R vw_CurrentPosition · AccountThreshold · TreasuryAccount · vw_ConnectionGate · Destination · vw_LatestFxRate · SystemSetting · Asset · W Movement · MovementEvent · IdempotencyRecord · AuditLog

Preview is the authority on validity. Duplicating its rules in React guarantees they drift, and the server is the one that matters.


S6b · New movement — blocked

New movement — blocked
Prototype — click to enlarge

Purpose. The same screen refusing. Two blockers are shown together, Submit is disabled, and the classification route is rendered in a danger tone so the reason is visible before the message is read.

Requirements

ID Requirement
AC-6b.1 Blockers and warnings can appear together; both are rendered.
AC-6b.2 A client-to-corporate route returns SEGREGATION_VIOLATION from preview and 422 from create if forged directly.
AC-6b.3 A movement exceeding movable quotes the movable figure in the message, not just "insufficient funds".
AC-6b.4 With any blocker present the preview panel shows no fee, cost or approval count — there is nothing to preview.

APIs

POST /api/v1/treasury/movements/preview      → { valid: false, blockers: [...] }

React PreviewPanelBlockerList

Tables

R TreasuryAccount (classification) · AccountThreshold · vw_CurrentPosition

Server-side enforcement is the control. The disabled button is a convenience.


S7 · Movements

Movements
Prototype — click to enlarge

Purpose. Every transfer, filterable. The finance team's audit-adjacent view.

Requirements

ID Requirement
AC-7.1 Filters: status, date range, module, initiator — all persisted in the URL. Default range is the last 30 days.
AC-7.2 The initiator filter includes "Rules" as an option; rule-created movements show Rule: {name}, never a user name.
AC-7.3 Amount shows the native figure above and the base-currency equivalent beneath in a muted tone.
AC-7.4 A failed movement shows its failure reason beneath the route, truncated with a tooltip.
AC-7.5 Status pill colours are fixed: settled green, pending amber, in-flight info, failed red, rejected and cancelled grey.

APIs

GET /api/v1/treasury/movements?status=&from=&to=&moduleCode=&initiatedBy=&sort=&page=1&pageSize=50

React MovementsPageFilterBar, MovementTable, AccountRoute, MovementStatusPill

Tables

Movement R · TreasuryAccount R · Connection R · Provider R · AppUser R · LiquidityRule R

Export CSV honours the current filter and is capped at 100,000 rows.


S8 · Movement detail

MV-4472
Prototype — click to enlarge

Purpose. What this movement did, who decided, and what happens next.

Requirements

ID Requirement
AC-8.1 The lifecycle timeline is built from MovementEvent, filled nodes for completed steps and a hollow node for the pending one.
AC-8.2 The classification route is rendered prominently — it is the segregation story.
AC-8.3 Action buttons are contextual by status. A settled movement renders none.
AC-8.4 Retry clears all prior approvals. The confirm dialog says so explicitly before the user commits.
AC-8.5 Reject and Mark manually resolved both require a non-empty reason; an empty reason sends no request.
AC-8.6 Mark manually resolved warns that the money moved outside the system and will show as a variance until an admin accepts it.
AC-8.7 A direct approve call on a settled movement returns 409 INVALID_STATE_TRANSITION.
AC-8.8 The provenance note explains that on settlement the amount is recorded as treasury net in the balance check, so our own transfer is not read as a variance.

APIs

GET  /api/v1/treasury/movements/{code}
POST /api/v1/treasury/movements/{code}/approve   # Idempotency-Key, optional X-Step-Up-Token
POST /api/v1/treasury/movements/{code}/reject    # { reason }
POST /api/v1/treasury/movements/{code}/cancel
POST /api/v1/treasury/movements/{code}/retry
POST /api/v1/treasury/movements/{code}/resolve   # { reason }

React MovementDetailPageDefinitionList, LifecycleTimeline, GateBanner, ReasonModal, ConfirmDialog

Tables

R Movement · MovementEvent · MovementApproval · AppUser · vw_ConnectionGate · W MovementApproval · Movement · MovementEvent · AuditLog

After any mutation invalidate: this movement, the movements list, the approvals queue, positions, summary and the nav badges.


S9 · Approvals

Approvals — waiting on you
Prototype — click to enlarge

Purpose. The queue of decisions waiting on the signed-in user. This screen is the most likely to be under-designed and the most likely to cause a control failure: if an approver cannot decide from the row alone, they will approve on autopilot and maker–checker becomes theatre.

Requirements

ID Requirement
AC-9.1 The initiator can never approve. The button is disabled with a tooltip, and a direct API call returns 403 SELF_APPROVAL_FORBIDDEN.
AC-9.2 A refused self-approval writes an AuditLog row with action APPROVAL_BLOCKED. A control that refuses silently cannot be evidenced to an auditor.
AC-9.3 When the initiator is the current user, the row states "You initiated this" in a danger tone.
AC-9.4 The classification route is emphasised on every row and is never truncated.
AC-9.5 A movement touching a connection that is not verified shows a danger line and a raised quorum, e.g. 1 / 3.
AC-9.6 Reject opens a modal with a mandatory reason; an empty reason shows an inline error and sends no request.
AC-9.7 Above the step-up threshold, Approve triggers the existing MFA re-challenge before the request.
AC-9.8 On the final approval the movement transitions to SUBMITTED, the row leaves the queue and the nav badge decrements.
AC-9.9 Approving affects only that row's button; other rows stay interactive.

APIs

GET  /api/v1/treasury/approvals/queue            # refetch 30s
POST /api/v1/treasury/movements/{code}/approve
POST /api/v1/treasury/movements/{code}/reject

React ApprovalsPageApprovalRow, AccountRoute, ReasonModal, StepUpChallenge

Tables

R Movement (filtered index) · MovementApproval · AppUser · TreasuryAccount · vw_ConnectionGate · W MovementApproval · Movement · MovementEvent · AuditLog

In the seed data MV-4473 is initiated by Anita K. Log in as Anita and try to approve it — that is the test of the whole control.


S10 · Funding rules

Funding rules
Prototype — click to enlarge

Purpose. What runs without a human, and whether it currently can. A rule that cannot run must look different from one that is merely switched off.

Requirements

ID Requirement
AC-10.1 State pill precedence, in this exact order: BLOCKED (gate) → HALTED (kill switch) → ARMEDOFF.
AC-10.2 A blocked rule shows the gate reason on the row, in a danger tone.
AC-10.3 Dry run is always available, even when blocked — you must be able to see what a rule would do.
AC-10.4 A rule cannot be armed without a successful dry run against its current configuration; editing the config disarms it.
AC-10.5 Saving a rule whose route violates segregation returns 422 at save time, not at run time.
AC-10.6 The kill switch requires a confirm dialog stating that manual movements still work.
AC-10.7 With the kill switch engaged every enabled rule shows HALTED and the global banner appears on every screen.
AC-10.8 Enable and disable require TREASURY_ADMIN.

APIs

GET   /api/v1/treasury/rules
POST  /api/v1/treasury/rules
PATCH /api/v1/treasury/rules/{id}                # If-Match
POST  /api/v1/treasury/rules/{id}/dry-run
POST  /api/v1/treasury/automation/kill-switch    # { halted: true|false }

React RulesPageRuleRow, RuleStatePill, DryRunModal, KillSwitchToggle, ConfirmDialog

Tables

R LiquidityRule · TreasuryAccount · RuleExecution · vw_ConnectionGate · vw_CurrentPosition · SystemSetting · W LiquidityRule · SystemSetting · AuditLog

CK_LiquidityRule_DryRun enforces AC-10.4 in the database as well as the service.


S10b · Dry run — blocked rule

Dry run
Prototype — click to enlarge

Purpose. Dry run against live balances, moving nothing. Shown here for a rule the gate has blocked: the calculation still runs so the operator can see what would happen, but the action to create the movement is withheld.

Requirements

ID Requirement
AC-10b.1 A blocked rule shows a danger banner at the top quoting the gate reason.
AC-10b.2 The banner states what must be fixed and that there is no override on this screen.
AC-10b.3 The modal shows: would-fire verdict, source and destination with live balances, strategy, uncapped amount, amount after cap, approval needed, and executions in the last 30 days.
AC-10b.4 "Create this movement" is rendered only when the gate is clean and the rule would fire.
AC-10b.5 A successful dry run stamps LastDryRunAtUtc and LastDryRunConfigHash.

APIs

POST /api/v1/treasury/rules/{id}/dry-run

React DryRunModalGateBanner, DefinitionList

Tables

R LiquidityRule · vw_CurrentPosition · vw_ConnectionGate · RuleExecution · W LiquidityRule (dry-run stamp)

Dry run never writes a Movement and never moves money.


S11 · Providers

Providers with sync and variance
Prototype — click to enlarge

Purpose. What we are connected to, how fresh each balance is, whether recent polls succeeded, whether the provider's figure agrees with our records — and therefore whether automation is permitted.

Requirements

ID Requirement
AC-11.1 One row per connection, not per provider. Noah appears twice with independent state.
AC-11.2 Two connections of the same provider are never summed into one row.
AC-11.3 Last sync shows data age, in a danger tone beyond the connection's staleness threshold.
AC-11.4 Sync shows OK, STALE, RATE LIMITED, FAILING or NEVER RUN, with the last error on the tooltip.
AC-11.5 Variance shows the worst account's base-currency variance, signed, coloured against the tolerance.
AC-11.6 Automation shows Allowed, Blocked or Freshness only in amber.
AC-11.7 The explanatory panel is mandatory and states that variance is a self-check, not an independent reconciliation.
AC-11.8 A per-connection Refresh is available, rate-limited server-side; a repeat inside the window returns 429 REFRESH_TOO_SOON.
AC-11.9 The multi-module panel and its note are mandatory and appear verbatim.
AC-11.10 Adding a provider validates that (provider, module) is unique, and the new connection starts automation-blocked.

APIs

GET  /api/v1/treasury/connections?moduleCode=&gate=
POST /api/v1/treasury/connections
POST /api/v1/treasury/connections/{id}/refresh      → 429 REFRESH_TOO_SOON when too soon

React ConnectionsPageConnectionTable, SyncPill, VarianceCell, GatePill, RefreshButton, AddProviderModal

Tables

Connection R/W · ConnectionSyncState R · BalanceCheck R · vw_ConnectionGate R · vw_PositionBase R · AuditLog W

A provider cannot be automated against on trust. The gate enforces the first-certified rule, not process discipline.


S12 · Connection detail

Rain · Cards
Prototype — click to enlarge

Purpose. One provider × module in full: its gate, its configuration, what the module contract says it can do, and the accounts and rules that depend on it.

Requirements

ID Requirement
AC-12.1 The gate box is expanded here, including break count, break value and the tolerance they are measured against.
AC-12.2 Capabilities come from the module contract's /capabilities endpoint, not from configuration.
AC-12.3 Reserved reported separately: No must be visually flagged — it is the reason accounts on that connection show (est.).
AC-12.4 Automation tolerance is editable by TREASURY_ADMIN and the change is audit-logged.
AC-12.5 Rules using this connection are listed with their current state pill.

APIs

GET   /api/v1/treasury/connections/{id}
GET   /api/v1/treasury/accounts?connectionId={id}
GET   /api/v1/treasury/movements?connectionId={id}&limit=10
PATCH /api/v1/treasury/connections/{id}          # tolerance, ADMIN, If-Match

React ConnectionDetailPageKpiCard ×4, GateBanner, CapabilityList, AccountTable, RuleList

Tables

Connection R/W · ConnectionSyncState R · BalanceCheck R · vw_ConnectionGate R · TreasuryAccount R · LiquidityRule R · Movement R · AuditLog W

Capabilities are reported by the module solution, which knows what its provider client can actually do.


S13 · Forecast

Forecast
Prototype — click to enlarge

Purpose. What will need funding, and when. The chart is only useful if the operator trusts it, which is why the inputs panel is mandatory.

Requirements

ID Requirement
AC-13.1 The account selector lists only accounts with a minimum set; others cannot be forecast against.
AC-13.2 The minimum line is dashed red and the target line dashed grey; points below the minimum render in a danger tone.
AC-13.3 "Next shortfall" names the day number and the date, and must agree with the first breaching point on the chart.
AC-13.4 Days of coverage: under 3 days red, 3–7 amber, above 7 neutral, >90d above ninety, em dash when outflow is zero.
AC-13.5 The call to action states the amount that holds the buffer for the whole horizon, and Fund now preloads S6 with it.
AC-13.6 The inputs panel is mandatory. A forecast whose inputs are invisible will not be trusted and therefore will not be used.
AC-13.7 An account with no minimum shows an empty state linking to S5 to set one.

APIs

GET /api/v1/treasury/forecast/{accountId}?horizonDays=14

React ForecastPageAccountSelect, KpiCard ×3, ForecastChart, ShortfallBanner, InputsPanel

Tables

ForecastPoint R · AccountThreshold R · vw_CurrentPosition R · Movement R (history)

ForecastPoint is deleted and reinserted per run, not merged. The nightly job runs at 17:00 UTC.


S14 · FX exposure

FX exposure
Prototype — click to enlarge

Purpose. Are we over-exposed to any single asset? And the reminder that the same asset on two networks is not one pool.

Requirements

ID Requirement
AC-14.1 Concentration is computed per asset against Asset.ConcentrationLimitPct; over the limit renders a danger pill and a banner.
AC-14.2 The asset table shows one combined row per asset for concentration purposes.
AC-14.3 The cross-network table lists every non-fiat position separately by network.
AC-14.4 The note about bridges is mandatory and appears verbatim.
AC-14.5 "Held at" lists distinct provider names, truncated with a tooltip.

APIs

GET /api/v1/treasury/positions/exposure

React FxExposurePageExposureTable, ConcentrationBar, CrossNetworkTable

Tables

vw_PositionBase R · Asset R (limits) · vw_LatestFxRate R

This is the one screen that groups by asset across networks. Everywhere else, asset × network is the position key.


S15 · Client coverage

Client coverage
Prototype — click to enlarge

Purpose. Do the assets we hold for customers cover what we owe them? Treasury computes the numerator from provider balances and reads the denominator from our own module ledgers.

Requirements

ID Requirement
AC-15.1 Client liabilities come from the module client-liabilities endpoints. The sublabel reads "From our own ledgers — not independently reconciled".
AC-15.2 Segregated assets are the treasury sum of CLIENT_SAFEGUARDED accounts, using total.
AC-15.3 Bands: below 100% red plus a PAGE alert; 100–101.99% amber; 102% and above green. Boundaries inclusive at the floor and at the buffer.
AC-15.4 Below the buffer, the banner states the exact top-up that restores 102% and Create top-up preloads S6 with it.
AC-15.5 Below 100%, the banner states the exact shortfall in a danger tone.
AC-15.6 The composition table sums to the segregated total; each row links to S5.
AC-15.7 A per-module breakdown shows each module's figure and its AsOfUtc.
AC-15.8 The provenance note is mandatory.

APIs

GET /api/v1/treasury/coverage
GET /api/v1/treasury/coverage/history?days=30

React CoveragePageKpiCard ×3, ShortfallBanner, CompositionTable, LiabilitySourceTable, CoverageChart

Tables

CoverageSnapshot R · ClientLiabilitySnapshot R · vw_PositionBase R (client accounts)

Liabilities always came from your books — a reconciliation module would have verified them, not produced them. What is missing here is attestation, not the figure.


S15b · Coverage — a module is not reporting

Client coverage — unavailable
Prototype — click to enlarge

Purpose. The failure path, which is the point of the screen. When any module cannot report its client liabilities, the ratio is not computed, not estimated, and not shown.

Requirements

ID Requirement
AC-15b.1 Liabilities and ratio both show an em dash.
AC-15b.2 An amber banner names the module that is not reporting.
AC-15b.3 No ratio is computed from a partial denominator under any circumstances. An incomplete liability figure makes coverage look better than it is.
AC-15b.4 A CoverageSnapshot row is written with Status = UNAVAILABLE and a null ratio.
AC-15b.5 A warning alert is raised so the outage is visible on S1 and S16.

APIs

GET /api/v1/treasury/coverage    → 200 { status: 'UNAVAILABLE', ratioPct: null, missingModules: ['wallets'] }

React CoveragePageKpiCard (unavailable variant), UnavailableBanner

Tables

CoverageSnapshot W (status UNAVAILABLE) · ClientLiabilitySnapshot R · AlertInstance W

This is the single most important failure behaviour in the product. A treasury system that quietly invents a client-liability number when its source is down is more dangerous than one that shows nothing. Test T-12 gates every release.


S16 · Exceptions

Exceptions
Prototype — click to enlarge

Purpose. Everything abnormal, from five sources, in one queue. This must be a real screen, not a notification bell — otherwise operators learn about problems in chat and the workspace becomes a place they only visit to execute decisions made elsewhere.

Requirements

ID Requirement
AC-16.1 Five sources feed it: threshold breaches, blocked connections, failed movements, blocked rules, coverage below buffer.
AC-16.2 Ordering is PAGE → CRITICAL → WARN → INFO, newest first within each severity.
AC-16.3 Messages are server-composed; the frontend never builds these strings.
AC-16.4 Open navigates to the server-supplied targetRoute.
AC-16.5 Acknowledging dims the row but keeps it visible; it disappears only when the underlying condition resolves.
AC-16.6 Alerts are deduplicated on DedupKey; one open alert per condition, reopened if the condition returns.
AC-16.7 The empty state reads "Clean board." and the nav badge is hidden.

APIs

GET  /api/v1/treasury/alerts?severity=&sourceType=&status=&page=1&pageSize=50
POST /api/v1/treasury/alerts/{id}/acknowledge    # { note? }

React ExceptionsPageAlertRow, SeverityPill, AckModal

Tables

AlertInstance R/W · AuditLog W

UX_AlertInstance_OpenDedup is a filtered unique index on DedupKey where status is not resolved.


S17 · Audit log

Audit log
Prototype — click to enlarge

Purpose. Who did what, permanently. All roles can read it. No role can change it.

Requirements

ID Requirement
AC-17.1 Header states "Immutable · read only · retained 7 years".
AC-17.2 No edit or delete control is rendered anywhere on this screen, for any role.
AC-17.3 Timestamps are UTC and the timezone is labelled.
AC-17.4 Action is shown as the raw constant — auditors want the exact token, not a friendly label.
AC-17.5 Non-human actors are suffixed (rule) or (system).
AC-17.6 Rows with before/after JSON expand to show old and new values.
AC-17.7 A blocked self-approval appears as APPROVAL_BLOCKED and is visible to every role.
AC-17.8 Export honours the current filter and is capped at 100,000 rows, with a warning toast when the cap is hit.

APIs

GET /api/v1/treasury/audit?from=&to=&actorUserId=&action=&resourceType=&page=1&pageSize=100

React AuditPageFilterBar, AuditTable, ExpandableRow, ExportButton

Tables

AuditLog R · AppUser R

The application SQL login has no UPDATE or DELETE grant on this table, and a trigger throws 50002 as a second line of defence.


S18 · Balance verification

Balance verification
Prototype — click to enlarge

Purpose. The evidence behind the gate. What each provider reports, what our own records expect, and the difference. This is what an operator opens when a connection is blocked on variance.

Requirements

ID Requirement
AC-18.1 One row per account: provider balance, expected balance, variance in native and base currency, status, and when it was checked.
AC-18.2 Status is Within, Outside, Stale or No data. Only Within permits automation.
AC-18.3 An account whose module cannot supply activity shows No data and em dashes — never a computed variance.
AC-18.4 Refresh all balances re-polls every connection and re-runs the checks, respecting per-connection rate limits.
AC-18.5 Row click opens a drawer showing the arithmetic: baseline, module inflow, module outflow, treasury net, expected, provider, variance.
AC-18.6 TREASURY_ADMIN may accept a variance with a mandatory reason. This rebaselines and writes an audit entry.
AC-18.7 Accepting is never automatic, on any schedule, at any size.
AC-18.8 A check outside tolerance does not move the baseline — the next check measures the same gap rather than resetting.
AC-18.9 The footer note stating this is not an independent reconciliation is mandatory.

APIs

GET  /api/v1/treasury/balance-checks
GET  /api/v1/treasury/balance-checks/{accountId}/history
POST /api/v1/treasury/balance-checks/refresh-all
POST /api/v1/treasury/balance-checks/{id}/accept      # ADMIN, { reason }

React BalanceVerificationPageKpiCard ×4, VarianceTable, VarianceDrawer, AcceptVarianceModal

Tables

BalanceCheck R/W · TreasuryAccount R/W (baseline) · Connection R · ConnectionSyncState R · AlertInstance W · AuditLog W

Route /treasury/verification, under Providers in the sidebar. It is evidence, not a daily destination.


8. API reference

8.1 Conventions

Item Rule
Base path /api/v1/treasuryor match your existing versioning scheme
Auth Existing bearer scheme. Every endpoint requires it.
Response envelope Yours. The payloads below show the body, not the wrapper.
Money in JSON Strings, 8 dp: "315000.00000000"
Timestamps ISO 8601 UTC with Z
Paging ?page=1&pageSize=50, max 200. Response has items, page, pageSize, totalCount.
Idempotency Idempotency-Key header required on every money-moving POST
Concurrency ETag on single-resource GET, If-Match required on PATCH

8.2 Endpoint list

# ── Positions ───────────────────────────────────────────
GET    /positions/summary                      # S1
GET    /positions/by-module                    # S2, S3
GET    /positions/exposure                     # S14

# ── Accounts ────────────────────────────────────────────
GET    /accounts                               # S2, S3, S4
GET    /accounts/{id}                          # S5   → ETag
GET    /accounts/{id}/positions                # S5
PATCH  /accounts/{id}/thresholds               # S5   → If-Match

# ── Movements (Idempotency-Key on every POST) ───────────
POST   /movements/preview                      # S6   highest-value endpoint
POST   /movements                              # S6
GET    /movements                              # S7
GET    /movements/{code}                       # S8
POST   /movements/{code}/approve               # S8, S9  + optional X-Step-Up-Token
POST   /movements/{code}/reject                # S8, S9  { reason }
POST   /movements/{code}/cancel                # S8
POST   /movements/{code}/retry                 # S8   clears approvals
POST   /movements/{code}/resolve               # S8   { reason }
GET    /approvals/queue                        # S9

# ── Rules and automation ────────────────────────────────
GET    /rules                                  # S10
POST   /rules                                  # S10  segregation checked at save
PATCH  /rules/{id}                             # S10  → If-Match
POST   /rules/{id}/dry-run                     # S10b
POST   /automation/kill-switch                 # global

# ── Connections ─────────────────────────────────────────
GET    /connections                            # S11
POST   /connections                            # S11  starts gate-blocked
GET    /connections/{id}                       # S12
PATCH  /connections/{id}                       # S12  tolerance, ADMIN

# ── Verification ────────────────────────────────────────
GET    /balance-checks                         # S18
GET    /balance-checks/{accountId}/history     # S18
POST   /balance-checks/refresh-all             # S18  rate-limited per connection
POST   /balance-checks/{id}/accept             # S18  ADMIN, { reason }
POST   /connections/{id}/refresh               # S11, S12  → 429 REFRESH_TOO_SOON

# ── Coverage, forecast, alerts, audit ───────────────────
GET    /coverage                               # S1, S15
GET    /coverage/history                       # S15
GET    /forecast/{accountId}                   # S13
GET    /alerts                                 # S1, S16
POST   /alerts/{id}/acknowledge                # S16
GET    /audit                                  # S17

8.3 Key payloads

POST /movements/preview — the endpoint the whole New Movement screen depends on.

// Request
{
  "sourceAccountId": 4,
  "destinationAccountId": 8,
  "assetCode": "USDC",
  "networkCode": "ethereum",
  "amount": "315000.00000000"
}

// Response 200 — valid
{
  "valid": true,
  "sourceAvailable": "5400000.00000000",
  "sourceMovable": "3900000.00000000",
  "sourceAfter": "5085000.00000000",
  "destinationAfter": "500000.00000000",
  "estimatedFee": { "amount": "11.80000000", "assetCode": "USDC" },
  "allInCostBase": "315011.80000000",
  "fxRate": null,
  "requiredApprovals": 2,
  "approvalReason": "Amount exceeds the 100,000 base threshold",
  "classificationRoute": { "from": "CORPORATE_OPERATING", "to": "COLLATERAL" },
  "gate": { "sourceStatus": "CLEAN", "destinationStatus": "CLEAN",
            "extraApproverRequired": false },
  "blockers": [],
  "warnings": []
}

// Response 200 — blocked (S6b)
{
  "valid": false,
  "blockers": [
    { "code": "SEGREGATION_VIOLATION", "field": "destinationAccountId",
      "message": "Client funds cannot move to a corporate operating account." },
    { "code": "SOURCE_BELOW_MINIMUM", "field": "amount",
      "message": "This would leave the source below its minimum of 1,500,000 USDC." }
  ],
  "warnings": []
}

blockers disable Submit. warnings do not, but may raise requiredApprovals. A connection that is not verified is a warning for a manual movement and a blocker for a rule.

GET /positions/summary

{
  "baseCurrency": "USD",
  "businessDate": "2026-07-31",
  "generatedAtUtc": "2026-08-01T09:14:02.000Z",
  "totalBase": "86339500.00000000",
  "movableBase": "41208300.00000000",
  "oldestDataAgeSeconds": 372,
  "byModule": [
    { "moduleCode": "cards", "moduleName": "Cards",
      "totalBase": "6281000.00000000", "accountCount": 4, "connectionCount": 3,
      "accountsBelowMinimum": 1, "connectionsBlocked": 1,
      "providers": ["Rain", "StraitsX", "Wasabi"] }
  ],
  "counts": { "accountsBelowMinimum": 2, "connectionsBlocked": 3,
              "movementsPendingApproval": 3, "openAlerts": 8 }
}

POST /movements/{code}/approve — the refusal that matters most

// 403
{
  "errorCode": "SELF_APPROVAL_FORBIDDEN",
  "status": 403,
  "detail": "You initiated MV-4473. Segregation of duties requires a different approver."
}

A refused self-approval writes an AuditLog row with action APPROVAL_BLOCKED. A control that refuses silently cannot be evidenced to an auditor.

8.4 Error contract

Use your existing error envelope. What matters is the machine-readable errorCodethe frontend switches on it, never on message text.

errorCode HTTP When
SEGREGATION_VIOLATION 422 Client → non-client movement
INSUFFICIENT_MOVABLE 422 Amount exceeds available minus source minimum
SOURCE_BELOW_MINIMUM 422 Movement would push the source under its floor
SAME_ACCOUNT 422 Source equals destination
DESTINATION_NOT_ALLOWLISTED 422 Destination not approved
INVALID_THRESHOLD 422 Violates 0 ≤ min < target ≤ max
GATE_BLOCKED 409 Rule execution against a connection that is not verified
SELF_APPROVAL_FORBIDDEN 403 Initiator attempting to approve
DUPLICATE_APPROVAL 409 Same user approving twice
INVALID_STATE_TRANSITION 409 e.g. approving a settled movement
AUTOMATION_HALTED 409 Kill switch engaged
STALE_POSITION_DATA 409 Automated action against data older than the threshold
VARIANCE_BREACH 409 Automated action on a connection whose variance is outside tolerance
REFRESH_TOO_SOON 429 Manual refresh inside the per-connection cooldown
CONCURRENCY_CONFLICT 409 ETag mismatch
STEP_UP_REQUIRED 401 High-value approval without a fresh MFA claim
REASON_REQUIRED 422 Reject or resolve without a reason
DRY_RUN_REQUIRED 422 Arming a rule with no dry run against the current config
IDEMPOTENCY_KEY_REQUIRED 400 Money-moving POST without the header

9. .NET backend structure

9.1 The central architectural decision

Do not build provider connectors in the Treasury solution.

Provider integration already exists and works inside each module solution. Duplicating it creates two code paths to the same provider, two sets of credentials, and guaranteed drift.

Instead, each module solution gains a thin, additive treasury-facing controller that wraps what it already does. Treasury composes across those five contracts and integrates nothing.

Treasury itself is a new solution alongside the module solutions, not code inside one of them. Treasury is inherently cross-module; placing it inside Cards would make Cards depend on Payments, Banks, Wallets and Exchange, inverting the dependency direction and coupling five deployables.

9.2 The module treasury contract

Define once in your shared package; each module implements it over its existing provider clients. Namespaces and response envelope must match what §11 discovery found.

class="cm">// GET /api/treasury/positions
public record TreasuryPositionDto(
    string   ProviderCode,        class="cm">// class="str">"rain"
    string   ModuleCode,          class="cm">// class="str">"cards"
    string   ProviderAccountRef,  class="cm">// the module's own identifier
    string   AccountName,
    string   AssetCode,
    string?  NetworkCode,         class="cm">// null for fiat
    decimal  Available,
    decimal  PendingIn,
    decimal  PendingOut,
    decimal  Reserved,
    bool     ReservedIsEstimate,  class="cm">// true if derived, not reported by the provider
    string   SourceType,          class="cm">// PROVIDER_API | WEBHOOK | CHAIN_RPC | CACHED
    DateTime AsOfUtc);            class="cm">// the PROVIDER's timestamp, not now()

class="cm">// GET /api/treasury/capabilities
public record TreasuryCapabilityDto(
    string ProviderCode,
    bool   SupportsRealtimeBalance,
    bool   SupportsProgrammaticTransfer,
    bool   ExposesReservedSeparately,
    bool   SupportsWebhooks,
    int    TypicalSettlementSeconds);

class="cm">// POST /api/treasury/transfers
public record TreasuryTransferRequest(
    string IdempotencyKey, string SourceProviderAccountRef,
    string DestinationProviderAccountRef, string AssetCode,
    string? NetworkCode, decimal Amount, string Reference);

public record TreasuryTransferResult(
    string Status,                class="cm">// ACCEPTED | REJECTED | DUPLICATE
    string? ExternalRef, string? FailureReason);

class="cm">// GET /api/treasury/transfers/{externalRef}
public record TreasuryTransferStatusDto(
    string Status,                class="cm">// PENDING | CONFIRMING | SETTLED | FAILED
    string? ExternalRef, string? FailureReason, DateTime? SettledAtUtc);

class="cm">// GET /api/treasury/client-liabilities
class="cm">// What this module owes customers right now. The coverage denominator.
public record ClientLiabilityDto(
    string   ModuleCode,
    string   AssetCode,
    decimal  TotalOwedToCustomers,
    int      CustomerAccountCount,
    DateTime AsOfUtc);

class="cm">// GET /api/treasury/account-activity?ref={providerAccountRef}&sinceUtc={iso}
class="cm">// The module's own record of what moved. Feeds the variance check.
public record AccountActivityDto(
    string   ProviderAccountRef,
    string   AssetCode,
    decimal  TotalIn,
    decimal  TotalOut,
    int      TransactionCount,
    DateTime SinceUtc,
    DateTime UntilUtc,
    bool     IsComplete);      class="cm">// false if the full window cannot be guaranteed
Rule Why
IsComplete = false makes the variance check unusable. Record Status = NO_DATA and block the connection. A partial activity window produces a false variance, which is worse than none
These read the module's existing transaction records. No new tracking is added to a live module. Additive only
A module that cannot implement account-activity at all puts its connections in FRESHNESS_ONLY with an audit entry §6.2.2
Rule Why
Additive only. New controller, new DTOs. Do not modify existing provider clients, endpoints or DTOs. The module is live. Changing its surface risks customer-facing regressions.
Wrap, do not reimplement. The new controller calls existing provider client methods. Single code path to each provider.
AsOfUtc comes from the provider. If none is supplied, return the fetch time and set SourceType accordingly. Otherwise every balance looks fresh and the staleness guard silently stops working.
Be honest in ExposesReservedSeparately. Treasury labels it "(estimated)". A silently wrong collateral figure is how card programs run dry.
If a provider has no transfer method, return SupportsProgrammaticTransfer = false. Do not throw, do not fake it. Treasury marks those accounts manual-only rather than creating movements that cannot execute.
Secure with a service-to-service policy, following whatever machine-to-machine auth already exists.

9.3 Project layout

Mirror the structure of whichever existing module solution is cleanest. Identify it in discovery and state which one you copied. Do not blend structures from several.

Treasury.sln
├── Treasury.Api/              controllers — thin: validate, dispatch, map
├── Treasury.Application/      commands, queries, validators
├── Treasury.Domain/           entities, policies, NO infrastructure references
│   └── Policies/              PolicyEngine, GateService — pure, unit tested
├── Treasury.Infrastructure/   persistence, module clients, recon client
└── Treasury.Jobs/             Azure Functions

If a shared package is referenced by every module solution, reference it — do not copy its contents.

9.4 Where the invariants live

Put every rule in §11's table in the domain layer, as pure logic with no infrastructure dependencies, and unit test them first.

If testing the segregation rule requires spinning up a DbContext, the logic is in the wrong layer.

Specifically pure: segregation policy · gate evaluation · approval quorum calculation · movement state machine · rule amount calculation and its six guards.

9.5 Concurrency — two approvers at the same instant

Two managers press Approve on a movement requiring two approvals, at the same moment. Both read approvalsRecorded = 1.

9.6 Background jobs

Azure Functions in the existing Function App, following its existing structure, DI, logging and cron conventions.

Function Schedule Writes
TreasuryPositionSync per connection, 60 s – 900 s PositionSnapshot, ConnectionSyncState, JobRun
TreasuryBalanceVerify after each connection's sync BalanceCheck, TreasuryAccount (baseline), AlertInstance
TreasuryClientLiabilitySync 15 min ClientLiabilitySnapshot, AlertInstance
TreasuryThresholdEval 5 min AlertInstance
TreasuryRuleEngine 5 min RuleExecution, Movement, MovementEvent
TreasuryMovementStatusPoll 1 min Movement, MovementEvent, PositionSnapshot
TreasuryCoverageCalc hourly CoverageSnapshot, AlertInstance
TreasuryForecastRebuild daily 17:00 ForecastPoint
TreasuryFxRateRefresh 10 min FxRate — reuse an existing FX service if one exists
TreasuryAlertEscalation 5 min AlertInstance
TreasuryIdempotencyPurge daily 03:00 IdempotencyRecord

Universal rules: idempotent · singleton (two rule-engine instances could double-fund) · fail per item not per run · record every run · call domain services, never raw SQL.

9.7 The highest-probability incident

Position sync setting its own timestamp. If AsOfUtc = DateTime.UtcNow at ingestion, every balance looks fresh even when the provider returned a six-hour-old figure. The data-age indicator becomes a lie, the staleness guard stops firing, and rules act on stale numbers at machine speed. Assert on this in the sync tests.


10. React frontend structure

10.1 The rule

Zero new visual primitives.

Every button, input, table, modal, spinner, shimmer, badge, toast and empty state must be an existing component from your library. If treasury appears to need something that does not exist, check how an existing screen solved the same problem. Complete the component map in §11 before writing any JSX.

What treasury adds is composition: the components in §10.3, and new screens built from existing parts.

10.2 Folder layout

Mirror exactly the shape an existing feature occupies. State which one you copied.

features/treasury/
├── api/          one module per resource, matching the existing API-module style
├── components/   the composites in §10.3
├── hooks/        query hooks, matching the existing server-state convention
├── pages/        one per screen in §7
├── types/        generated or hand-written per your convention
└── utils/        treasury-specific ONLY — no formatters, those already exist

10.3 The composites to build once

Component Built from Why it exists
PositionCell existing text + tooltip Prevents the available-vs-total bug. Renders available with reserved beneath, never a combined figure.
MoneyText existing text + the existing formatter Amounts arrive as strings; this is the only place they are formatted
GateBanner existing alert Carries the mandatory provenance line
GatePill existing badge + tooltip Gate reason on hover
ClassificationPill · HealthPill · MovementStatusPill existing badge One mapping, used everywhere
ThresholdBar existing progress bar min / target / max labels beneath
AccountRoute layout + text The three-line route used on S7, S8, S9, S10
AccountSelect existing searchable select Grouped by module, 26 options
KpiCard existing card label, value, sublabel, tone
DataAge existing text + tooltip Age with staleness tone

10.4 Data layer rules

Concern Rule
API client Existing instance with its interceptors. Do not create a new one.
Server state Whatever the app uses. Do not introduce TanStack Query into a Redux app because this document mentions it.
Money in JS Amounts are strings. Never parseFloat for arithmetic. Format for display only.
Formatting Find and use the existing formatters. If they cannot handle 8-decimal crypto, extend the existing helper — do not add a second one — and flag it.
Polling S1 at 60 s and S9 at 30 s, silent. Nothing else polls.
Invalidation Approving invalidates: approvals queue, movement detail, movements list, positions, summary, nav badges.
Permissions Existing hook. Hide or disable per the existing convention — mixing them looks broken.
Colours Existing semantic tokens. No new hex values.

10.5 Frontend rules that are not negotiable

Never Because
Implement business validation The server decides. Preview is the authority.
Enable Submit optimistically Fail closed
Compute a coverage ratio from a partial denominator If any module is not reporting, there is no ratio to show
Sum positions across networks USDC-Ethereum and USDC-Polygon are different money
Sum a provider's balances across modules Noah banks and Noah payments are different money
Build alert or gate message strings Server-composed, so wording stays consistent
Blank a table on refetch Use the loading overlay
Add a spinner beside a button that has a loading prop Use the existing mechanism

10.6 Loading patterns

Situation Pattern
Page first load Shimmer at the existing granularity
Card in a multi-card page Shimmer per card, resolving independently. Never block first paint on the slowest query.
Table refetch Keep rows, apply the existing loading overlay
Background poll No visual change
Button action Existing button-loading mechanism; button does not resize
Preview recompute (S6) Subtle inline spinner in the panel header. Do not shimmer the panel on every keystroke — it flickers.

11. Discovery — run before writing any code

This feature goes into a live system whose conventions are already decided. Produce TREASURY-CONVENTIONS.md at the repo root first. Every entry must cite a real file path and include a real code excerpt from these repos; an entry with no file path is assumption, not discovery.

11.1 What to record

Area Record
Solutions Every repo and solution, .NET version, project layout, shared packages, how solutions talk to each other today, deployment units
Backend Project structure and layer names · Program.cs DI style · controller style (show one full controller) · response envelope (exact type and namespace) · validation library · MediatR or not · EF or Dapper · DbContext location · entity base class and audit columns · migration tool and naming · authorization scheme, policy names, claim types (show one protected endpoint and its policy) · logging library and structured property names · correlation id header and propagation · global exception handler · options pattern · typed HTTP clients and Polly values · testing framework and how integration tests get a database · API versioning
Providers For each of the eleven: client class path, auth mechanism, and whether it has a balance method, a transfer method, a status method and webhooks. Flag any provider with no transfer method, and any that cannot separate reserved from available.
Database Server and database names · schema usage · table naming · PK convention · FK and index naming · audit columns · exact money column type in use · datetime type and UTC convention · soft delete · migration history table · any existing account, balance, FX rate, settings or audit tables
Frontend React version · folder structure (show one feature end to end) · state management · the API client file · server-state convention and query keys · routing and guards · the sidebar definition file · design tokens · component library location · the button loading mechanism · shimmer components and when they are used vs spinners · the table component (show one full data table) · form library · modal and confirm patterns · toast helper · the existing number and currency formatters · date helper and timezone convention · whether permission gating hides or disables · i18n
Functions In-process or isolated · trigger types (show one timer function) · DI setup · database access · how functions call module APIs · cron convention · singleton pattern · existing job-run table

11.2 Output format

### Response envelope

**Found at:** src/Cards.Api/Common/ApiResponse.cs
**Used in:**  src/Cards.Api/Controllers/CardController.cs:42

public class ApiResponse<T> { bool Success; T Data; string Message; List<ValidationError> Errors; }

**Treasury will:** return ApiResponse<T> from every endpoint, matching this exactly.
**Divergence:** Payments uses Result<T> — src/Payments.Api/Common/Result.cs.
Treasury follows the Cards pattern (4 of 5 solutions). **Confirm.**

End the file with Open questions blocking the build and a Divergences table.

11.3 Stop and ask when


12. KPI definitions

If a KPI is not in this list, it must not appear on a screen.

ID KPI Formula Uses Colour Seed value
K-1 Total holdings Σ (available + pendingIn + reserved) × fx total Always neutral $86,339,500
K-2 Client coverage ratio (segregated assets ÷ client liabilities) × 100 total; liabilities from our own module ledgers <100 red + PAGE · 100–101.99 amber · ≥102 green 101.40% amber
K-3 Accounts below minimum COUNT(*) WHERE available < minimum AND minimum > 0 available 0 green · ≥1 red. No amber. 2
K-4 Connections not verified COUNT(gate.status IN ('BLOCKED','DEGRADED')) 0 green · ≥1 amber 4 — 3 blocked, 1 degraded
K-5 Movable balance Σ MAX(0, available − minimum) × fx available − minimum Neutral
K-6 Days of coverage available ÷ 90-day average daily net outflow available <3d red · 3–7d amber · >7d neutral
K-7 Data age now − position.asOfUtc within threshold grey · 1–3× amber · >3× red
K-8 Concentration by asset (asset total ÷ grand total) × 100 vs the asset limit total within green · over red

K-1 excludes pendingOut — already committed away. K-2's denominator comes from our own module ledgers; if any module fails to report, show an em dash and never a fallback. K-3 uses available, not total — reserved funds cannot prevent a decline; the related near state is available < minimum × 1.15 and is not counted in K-3.

Meaning Always means
Red Something is wrong now and needs action today
Amber Heading toward wrong, or proceeding on unverified data
Green Verified healthy. Never use for "big number".
Neutral A fact with no judgement attached

Colour is never the only carrier of meaning. Every coloured state also has a text label or pill.

12.1 What verification still does not give you

The variance check is a strong control and a poor substitute for reconciliation. Two things remain genuinely missing:

Transaction-level matching. A variance tells you the totals disagree. It does not tell you which transaction is wrong. When Tazapay is 554,054 SGD out, someone still has to find out why, and without matching that is manual work through two systems.

Attestation. Nothing produces a signed daily statement that customer assets covered customer liabilities. If you are subject to safeguarding rules, an auditor will ask for that, and "we polled the provider and the totals looked close" is not an answer.

See §3.4 for how reconciliation slots in later without disturbing what you build now.


13. Test matrix

13.1 Invariants — one test each, named after the rule

ID Rule Scenario Expected
T-01 BR-01 Client → corporate movement via API 422 SEGREGATION_VIOLATION, no row created
T-02 BR-01 Fee revenue → corporate operating, type FEE_SWEEP 201 — the documented exception
T-03 BR-01 Rule saved with client source, corporate destination 422 at save time, not run time
T-04 BR-05 Initiator approves own movement 403 + AuditLog row APPROVAL_BLOCKED
T-05 BR-05 Initiator is TREASURY_MANAGER and approves own Still 403. Role does not exempt.
T-06 BR-06 Same approver approves twice 409 DUPLICATE_APPROVAL, count stays 1
T-07 BR-07 $150k movement created, FX moves so it is worth $95k, approve once Still requires 2 — quorum frozen
T-08 BR-03 Connection has not synced for longer than its threshold Skipped, RuleExecution GATE_BLOCKED, reason quotes the age
T-09 BR-03 Two consecutive sync failures Automation blocked, last error surfaced on S11
T-09b BR-03 Variance outside tolerance Automation blocked; manual movement allowed with +1 approver
T-09c BR-03 Variance outside tolerance, then checked again Baseline unchanged — the same gap is measured, not reset
T-09d BR-24 Admin accepts a variance with a reason Baseline updated, VARIANCE_ACCEPTED audited, history retained
T-09e BR-24 Admin accepts with an empty reason 422 REASON_REQUIRED
T-09f Module returns IsComplete = false NO_DATA, connection blocked, no variance computed
T-09g Connection in FRESHNESS_ONLY Automation allowed at halved caps; S11 shows Freshness only
T-09h Manual refresh twice inside the cooldown Second returns 429 REFRESH_TOO_SOON
T-09i Manual refresh on a variance-blocked connection Stays blocked. A refresh cannot clear a variance.
T-09j Provider returns 429 Backoff applied, failures incremented, no tight retry loop
T-09k Poll returns an unchanged position No PositionSnapshot row; ConnectionSyncState still updated
T-10 BR-03 Sync recovers and variance is within tolerance Rule fires on the next evaluation
T-11 BR-08 Amount = movable exactly 201 — boundary inclusive
T-12 BR-08 Amount = movable + 0.00000001 422 — test at 8-dp precision
T-13 BR-10 UPDATE PositionSnapshot SET Available = 0 SQL error 50001
T-14 BR-11 DELETE FROM AuditLog as the app login Permission denied
T-15 BR-12 Query total USDC across networks Ethereum and Polygon separate, never summed except on S14
T-16 BR-13 Sum Noah's treasury value Two connections reported separately
T-12 BR-26 One module fails to report client liabilities Coverage shows an em dash and names the module. No ratio from a partial denominator.
T-18 BR-15 Ratio exactly 100.00% Amber, not red
T-19 BR-15 Ratio exactly 102.00% Green
T-20 BR-16 Kill switch engaged, engine runs Returns immediately, zero RuleExecution rows
T-21 BR-17 Dry-run, edit source, then enable 422 DRY_RUN_REQUIRED — config hash changed
T-22 BR-18 Same idempotency key posted twice Second returns 200 with the original. One row.
T-23 BR-19 Reject with empty reason 422 REASON_REQUIRED
T-24 BR-20 Failed movement with 2 approvals retried PENDING_APPROVAL, approval count 0
T-25 BR-04 Approve a settled movement 409 INVALID_STATE_TRANSITION

T-12 is the test that matters most. Automate it and gate every release on it.

13.2 Concurrency — against a real database, not mocks

ID Scenario Expected
C-01 Two approvers approve the final approval simultaneously Exactly one transition to APPROVED; the loser gets 409
C-02 Same user approves twice concurrently Unique constraint rejects the second
C-03 Rule engine runs twice in the same minute bucket One movement — idempotency key collides
C-04 Threshold PATCH with a stale ETag 409 CONCURRENCY_CONFLICT

13.3 End-to-end journeys

ID Journey
E-01 Collateral drops below minimum → alert on S1 → rule creates movement → S9 → two approvals → SUBMITTED → settles → balances update → five audit entries
E-02 Break a credential → sync fails twice → rule shows BLOCKED not OFF → banner everywhere → no movement → fix and refresh → rule fires
E-03 Client → corporate in S6 → blocker, Submit disabled → forge the API call → 422 → audit records the attempt
E-04 Log in as Anita → S9 → MV-4473 shows "You initiated this" → Approve disabled → direct API 403 → audit row
E-05 Reduce segregated assets → hourly job → red ratio → PAGE alert → banner with exact shortfall → top-up restores buffer
E-06 Stop a module reporting liabilities → S15 shows em dashes and names it → assert no ratio computed
E-07 Kill switch → banner everywhere → all rules HALTED → manual movement still works → release → rules re-arm
E-08 Add a provider → appears in module totals → starts gate-blocked → first successful sync and check clears it
E-09 Failed movement → Retry → PENDING_APPROVAL with zero approvals → approve → settles
E-10 Dry run → "Create this movement" → S6 preloaded → submit

13.4 Boundary values

Boundary Test Expected
Coverage ratio 99.99 / 100.00 / 100.01 red / amber / amber
Coverage ratio 101.99 / 102.00 / 102.01 amber / green / green
Below minimum min−ε / min / min+ε below / ok / ok
Near minimum min×1.149 / min×1.15 / min×1.151 near / ok / ok
Approval quorum $99,999.99 / $100,000 / $100,000.01 1 / 1 / 2 approvals
Break tolerance $49,999 / $50,000 / $50,001 clean / clean / blocked
Asset decimals USDC 6dp, ETH 18dp, USD 2dp Extra dp rejected per asset

14. Delivery plan

Each slice is: migration → domain → application → API → tests → merged. Do not build all entities, then all services, then all controllers.

# Slice Delivers Screens
0 Discovery + conventions TREASURY-CONVENTIONS.md, reviewed
1 Module treasury contracts Five endpoints per module over existing provider clients
2 Reference data + accounts Treasury knows what accounts exist
3 Position aggregation Real balances flowing S1, S2, S3, S4, S5
4 Sync state + gate Freshness and sync health, blocked connections visible S11, S12
4a Balance verification The variance check and its evidence S18
5 Thresholds Health status, alerts S5 edit, S16
6 Movement preview + creation Full policy enforcement S6, S6b
7 Approvals Maker–checker enforced S8, S9
8 Execution Dispatch, polling, settlement S7, S8
9 Funding rules Rule CRUD, dry run, engine S10, S10b
10 Coverage Ratio from our own module ledgers S15, S15b
11 Forecast, FX, audit Remaining screens S13, S14, S17

Do not invert domain and UI. Building the UI first produces a screen that looks finished with validation logic living in React. That logic then gets duplicated, imperfectly, in the API. The server is the only place a control actually exists.

Definition of done, per slice: follows TREASURY-CONVENTIONS.md with no new patterns · every touched invariant has a passing negative test · server-side enforcement verified independently of the UI · audit entries asserted in test · loading, empty, error and no-permission states implemented · existing components used, no new primitives · migration has a tested rollback · no existing table, endpoint or shared component modified.


15. Failure modes — read before writing code

# Failure Why it happens Guard
1 Position sync sets its own timestamp AsOfUtc = DateTime.UtcNow is the obvious line to write Assert in the sync test that AsOfUtc equals the module-reported value
2 A screen shows an unlabelled "balance" Available and total both look like "the balance" PositionCell everywhere; grep for any single-figure balance render
3 Quorum recomputed on read It looks like a derived value Frozen columns RequiredApprovals and AmountBase; T-07
4 A ratio is shown from a partial denominator One module is silent, the others have numbers, and a ratio looks better than an em dash T-12, gated on every release
5 Rules act on stale balances The gate is skipped for "just this one case" No override exists; guard 2 checks both sides
6 A provider's two connections get summed They share a provider name UQ_Connection_ProviderModule; T-16
7 USDC on two networks treated as one pool Same ticker Position key is asset × network; T-15
8 Double funding on job retry Timer fires twice, or two instances run Singleton + minute-bucket idempotency key; C-03
9 Self-approval refused silently The 403 is returned but nothing is written APPROVAL_BLOCKED audit row; T-04
10 PositionSnapshot grows unbounded One row per account per asset per minute Skip unchanged positions; agree retention with the DBA before go-live
11 Crypto precision truncated Existing money columns are decimal(18,2) Raise in discovery — §11.3
12 Retry keeps old approvals Retry looks like a resume Approvals cleared; the confirm dialog says so; T-24

16. Decisions needed

# Question Blocks Owner
1 Which providers can supply account-activity? Any that cannot go to FRESHNESS_ONLY with halved caps. The variance check, §6.3 Discovery
2 What is the existing money column precision, and can it hold 18-decimal crypto? Entire data model DBA
3 Do existing account, balance, FX rate, settings or audit tables overlap with §5.5? Data model, avoiding two sources of truth DBA
3b Is $50,000 the right variance tolerance per connection? Too tight blocks constantly; too loose hides real gaps. Gate configuration Treasury
3c Provider rate limits per API — needed before polling continuously §6.4.2 Discovery
4 Which module solution is the structural reference to copy? All backend structure Eng lead
5 Is there an existing inter-service call pattern between solutions? The module contract transport Eng lead
6 Which providers lack a programmatic transfer method? Which accounts can be automated Discovery
7 Which providers cannot separate reserved from available? Collateral accuracy on card programs Discovery
8 Confirm Wasabi's exact role — card issuing was assumed throughout Slice 2 Product
9 Are exchange balances eligible as safeguarded client assets in our jurisdictions? Coverage calculation Compliance
10 Does Mesta need per-corridor prefund accounts or one pooled balance? Account granularity Product
11 Approval value threshold and step-up MFA threshold Slices 6 and 7 Treasury + Security
12 Is $50,000 the right automation tolerance for our volumes? Gate configuration Treasury

Items 1, 2 and 3 block the most. Item 1 decides how much of the estate can be automated at all: a connection with no activity feed runs on freshness only, at halved caps.


17. Generation guide for VS Code

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

Step 0 — Discovery

Read §11 of the attached spec, then analyse every repository provided.
Produce TREASURY-CONVENTIONS.md following §11.1 exactly.

Every convention must cite a real file path and include a real code excerpt
from these repos. Where solutions disagree, document both and recommend one.
List every provider integration and flag any missing balance or transfer
capability, and any provider that cannot separate reserved from available.

Write NO feature code. Stop when the document is complete.

Step 1 — Module treasury contracts

Using §9.2, implement the treasury contract in the <module> solution.

- Additive only: new controller, new DTOs, wrapping the EXISTING provider clients.
- Match that solution's controller style, response envelope, auth and logging
  exactly as recorded in TREASURY-CONVENTIONS.md.
- AsOfUtc MUST come from the provider response. If the provider gives no
  timestamp, return the fetch time and set SourceType accordingly. Never
  DateTime.UtcNow presented as provider truth.
- ExposesReservedSeparately must be honest. If the module cannot distinguish
  authorisation holds, return false and put the best estimate in Reserved.
- If a provider has no transfer method, return SupportsProgrammaticTransfer=false.
  Do not throw and do not fake it.
- Do not modify any existing file except the DI registration.

Step 2 — Database

Using §18, create the treasury migration for the EXISTING database.

- ADDITIVE ONLY. No existing table is altered. No existing data is modified.
- Translate every type and name to the conventions in TREASURY-CONVENTIONS.md:
  money type, datetime type, PK style, table naming, schema placement.
- If the existing money column type cannot hold 18-decimal crypto amounts,
  STOP and report it rather than truncating.
- Before creating any table, check §5.4 and the conventions doc for an existing
  table that already covers it. Reuse rather than duplicate.
- Include a tested rollback.

Step 3 — Domain

Using §5, §6 and §12, create the Treasury.Domain project per §9.3.

- GateService, PolicyEngine and RuleEvaluator are PURE: no I/O, no DbContext,
  no async. Given state in, decision out.
- Every invariant in §13.1 lives in the domain, never in a controller.
- Quorum and AmountBase are computed once at creation and stored. Never
  recomputed on read.
- The movement state machine rejects every skipped transition.
- Use TreasuryAccount, Connection and Movement as the type names. Never
  "Account", "Integration" or "Transaction".
Include xunit tests for every rule in §13.1, named after the rule id.

Step 4 — Application and API

Using §8, create the Application and Api layers.

- POST /movements/preview is the authority on validity. It returns blockers and
  warnings; blockers make the movement uncreatable, warnings may raise quorum.
- POST /movements re-runs the same policy evaluation server-side. Never trust
  a preview result passed back by the client.
- Idempotency middleware on every money-moving POST.
- Self-approval returns 403 AND writes an AuditLog row with action
  APPROVAL_BLOCKED. The audit row is part of the acceptance criteria.
- Error codes exactly as in §8.4, inside the existing error envelope.
Include integration tests against a real database for §13.2.

Step 5 — Jobs

Using §9.6, add the treasury functions to the EXISTING Function App,
following its existing structure, DI, logging and cron conventions.

- RuleEngine implements all six guards from §6.5, each independently testable.
- EVERY rule evaluation writes a RuleExecution row, including skips.
- Idempotency key is rule id + minute bucket, so a double run cannot double-fund.
- PositionSync skips writing when the position is unchanged.
- CoverageCalc: if any module fails to report liabilities, record UNAVAILABLE and raise
  an alert. NEVER substitute a locally computed liability figure.
- Singleton on every timer function.

Step 6 — Frontend foundations

Using §10, create features/treasury in the existing React app.

- Mirror the folder shape of the feature named in TREASURY-CONVENTIONS.md.
- Use the EXISTING api client instance, the existing server-state library,
  the existing formatters, the existing permission hook.
- Build only the composites in §10.3. Create no new visual primitives.
- PositionCell must render available and reserved separately. There is no prop
  that produces a combined balance.
- Add treasury to the existing sidebar file using the existing grouping and
  badge components.

Step 7 — Screens, one at a time

Build screen <Sn> from §7 of the attached spec.

- Use only existing components from <component library path>.
- Match the loading, shimmer, button-loading, table and toast patterns recorded
  in TREASURY-CONVENTIONS.md.
- Implement every acceptance criterion listed for that screen.
- The APIs and tables for the screen are listed in its section and in §19.
- Take all styling from the existing design system. The screenshot shows
  information hierarchy, not visual design.
- Implement loading, empty, error and no-permission states, not just the
  happy path.

18. Table DDL

Neutral dialect. Translate types and names to your conventions before running — see §11.1 and Step 2 above.

-- ═══════════════════════════════════════════════════════════
--  REFERENCE DATA — check first whether a registry exists
-- ═══════════════════════════════════════════════════════════
CREATE TABLE Module (
    ModuleId    INT IDENTITY(1,1) PRIMARY KEY,
    ModuleCode  VARCHAR(24)  NOT NULL UNIQUE,   -- 'cards'
    Name        NVARCHAR(80) NOT NULL,
    SortOrder   TINYINT      NOT NULL DEFAULT 0
);

CREATE TABLE Provider (
    ProviderId   INT IDENTITY(1,1) PRIMARY KEY,
    ProviderCode VARCHAR(32)   NOT NULL UNIQUE,  -- 'rain'
    Name         NVARCHAR(120) NOT NULL,
    Kind         NVARCHAR(200) NOT NULL,
    IsActive     BIT           NOT NULL DEFAULT 1
);

-- One provider serving one module. The unit of verification.
-- Noah / Tazapay / Thunes each appear TWICE.
CREATE TABLE Connection (
    ConnectionId              INT IDENTITY(1,1) PRIMARY KEY,
    ConnectionCode            VARCHAR(48)   NOT NULL UNIQUE,   -- 'rain-cards'
    ProviderId                INT           NOT NULL REFERENCES Provider(ProviderId),
    ModuleId                  INT           NOT NULL REFERENCES Module(ModuleId),
    AdapterVersion            VARCHAR(32)   NOT NULL,
    LiveSince                 DATE          NULL,
    StalenessThresholdSeconds INT           NOT NULL DEFAULT 300,
    AutomationToleranceBase   DECIMAL(28,8) NOT NULL DEFAULT 50000.00,
    IsActive                  BIT           NOT NULL DEFAULT 1,
    RowVer                    ROWVERSION    NOT NULL,
    CONSTRAINT UQ_Connection_ProviderModule UNIQUE (ProviderId, ModuleId)
);

CREATE TABLE Asset (
    AssetCode             VARCHAR(12)  PRIMARY KEY,
    Name                  NVARCHAR(80) NOT NULL,
    AssetType             VARCHAR(16)  NOT NULL,   -- FIAT | CRYPTO | STABLECOIN
    Decimals              TINYINT      NOT NULL,   -- drives rounding everywhere
    ConcentrationLimitPct DECIMAL(5,2) NULL
);

CREATE TABLE Network (
    NetworkCode           VARCHAR(24)  PRIMARY KEY,
    Name                  NVARCHAR(60) NOT NULL,
    NativeAssetCode       VARCHAR(12)  NOT NULL,
    ConfirmationsRequired INT          NOT NULL DEFAULT 12
);

-- ═══════════════════════════════════════════════════════════
--  VERIFICATION STATE — treasury computes all of this itself
-- ═══════════════════════════════════════════════════════════
-- One row per connection, updated in place by the position sync job.
CREATE TABLE ConnectionSyncState (
    ConnectionId        INT           PRIMARY KEY REFERENCES Connection(ConnectionId),
    LastAttemptAtUtc    DATETIME2(3)  NULL,
    LastSuccessAtUtc    DATETIME2(3)  NULL,
    LastStatus          VARCHAR(16)   NOT NULL DEFAULT 'NEVER_RUN',  -- OK|FAILED|RATE_LIMITED|NEVER_RUN
    ConsecutiveFailures INT           NOT NULL DEFAULT 0,
    LastError           NVARCHAR(500) NULL,
    LastDurationMs      INT           NULL,
    AccountsRetrieved   INT           NOT NULL DEFAULT 0,
    CONSTRAINT CK_SyncState_Status CHECK (LastStatus IN ('OK','FAILED','RATE_LIMITED','NEVER_RUN'))
);

-- The variance check. Append-only: the history is the evidence.
CREATE TABLE BalanceCheck (
    BalanceCheckId     BIGINT IDENTITY(1,1) PRIMARY KEY,
    TreasuryAccountId  INT           NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    AssetCode          VARCHAR(12)   NOT NULL,
    ProviderBalance    DECIMAL(28,8) NOT NULL,
    ExpectedBalance    DECIMAL(28,8) NOT NULL,
    Variance           DECIMAL(28,8) NOT NULL,   -- provider − expected
    VarianceBase       DECIMAL(28,8) NOT NULL,
    ToleranceBase      DECIMAL(28,8) NOT NULL,   -- what it was measured against, frozen
    Status             VARCHAR(16)   NOT NULL,   -- WITHIN|OUTSIDE|NO_DATA|STALE
    BaselineBalance    DECIMAL(28,8) NULL,       -- LastVerifiedBalance used
    BaselineAtUtc      DATETIME2(3)  NULL,
    ModuleInflow       DECIMAL(28,8) NOT NULL DEFAULT 0,
    ModuleOutflow      DECIMAL(28,8) NOT NULL DEFAULT 0,
    TreasuryNet        DECIMAL(28,8) NOT NULL DEFAULT 0,
    AcceptedByUserId   INT           NULL,       -- admin rebaseline
    AcceptedReason     NVARCHAR(500) NULL,
    AcceptedAtUtc      DATETIME2(3)  NULL,
    CheckedAtUtc       DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME(),
    CONSTRAINT CK_BalanceCheck_Status CHECK (Status IN ('WITHIN','OUTSIDE','NO_DATA','STALE'))
);
CREATE INDEX IX_BalanceCheck_Latest ON BalanceCheck(TreasuryAccountId, CheckedAtUtc DESC);

-- Coverage denominator, per module so provenance and gaps are visible.
CREATE TABLE ClientLiabilitySnapshot (
    ClientLiabilitySnapshotId BIGINT IDENTITY(1,1) PRIMARY KEY,
    ModuleCode           VARCHAR(24)   NOT NULL,
    AssetCode            VARCHAR(12)   NOT NULL,
    TotalOwedToCustomers DECIMAL(28,8) NOT NULL,
    TotalOwedBase        DECIMAL(28,8) NOT NULL,
    CustomerAccountCount INT           NOT NULL,
    ModuleAsOfUtc        DATETIME2(3)  NOT NULL,   -- when the MODULE computed it
    FetchedAtUtc         DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE INDEX IX_ClientLiability_Latest ON ClientLiabilitySnapshot(ModuleCode, AssetCode, FetchedAtUtc DESC);


-- ═══════════════════════════════════════════════════════════
--  ACCOUNTS AND POSITIONS
-- ═══════════════════════════════════════════════════════════
CREATE TABLE TreasuryAccount (
    TreasuryAccountId  INT IDENTITY(1,1) PRIMARY KEY,
    AccountCode        VARCHAR(24)   NOT NULL UNIQUE,
    ConnectionId       INT           NOT NULL REFERENCES Connection(ConnectionId),
    Name               NVARCHAR(120) NOT NULL,
    ProviderAccountRef NVARCHAR(160) NOT NULL,   -- matches the module contract
    Classification     VARCHAR(24)   NOT NULL,
    AssetCode          VARCHAR(12)   NOT NULL REFERENCES Asset(AssetCode),
    NetworkCode        VARCHAR(24)   NULL REFERENCES Network(NetworkCode),
    LegalEntity        NVARCHAR(120) NULL,
    IsActive           BIT           NOT NULL DEFAULT 1,
    CreatedAtUtc       DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME(),
    RowVer             ROWVERSION    NOT NULL,
    CONSTRAINT CK_TreasuryAccount_Class CHECK (Classification IN
        ('CLIENT_SAFEGUARDED','COLLATERAL','PREFUND','CORPORATE_OPERATING',
         'CORPORATE_RESERVE','GAS_TANK','FEE_REVENUE'))
);
CREATE INDEX IX_TreasuryAccount_Conn  ON TreasuryAccount(ConnectionId);
CREATE INDEX IX_TreasuryAccount_Class ON TreasuryAccount(Classification);

CREATE TABLE AccountThreshold (
    AccountThresholdId INT IDENTITY(1,1) PRIMARY KEY,
    TreasuryAccountId  INT           NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    AssetCode          VARCHAR(12)   NOT NULL,
    CriticalMinimum    DECIMAL(28,8) NULL,
    MinimumAmount      DECIMAL(28,8) NOT NULL DEFAULT 0,
    TargetAmount       DECIMAL(28,8) NOT NULL DEFAULT 0,
    MaximumAmount      DECIMAL(28,8) NOT NULL DEFAULT 0,   -- 0 = unbounded
    UpdatedByUserId    INT           NULL,
    CONSTRAINT UQ_AccountThreshold UNIQUE (TreasuryAccountId, AssetCode),
    CONSTRAINT CK_AccountThreshold_Order CHECK (
        MinimumAmount >= 0 AND TargetAmount >= MinimumAmount
        AND (MaximumAmount = 0 OR MaximumAmount >= TargetAmount))
);
-- Keep history: temporal table if this database uses them, otherwise your
-- existing audit-history approach.

-- APPEND-ONLY. The historical series is a regulatory asset.
CREATE TABLE PositionSnapshot (
    PositionSnapshotId BIGINT IDENTITY(1,1) PRIMARY KEY,
    TreasuryAccountId  INT              NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    AssetCode          VARCHAR(12)      NOT NULL,
    NetworkCode        VARCHAR(24)      NULL,
    Available          DECIMAL(28,8)    NOT NULL,
    PendingIn          DECIMAL(28,8)    NOT NULL DEFAULT 0,
    PendingOut         DECIMAL(28,8)    NOT NULL DEFAULT 0,
    Reserved           DECIMAL(28,8)    NOT NULL DEFAULT 0,
    ReservedIsEstimate BIT              NOT NULL DEFAULT 0,
    SourceType         VARCHAR(16)      NOT NULL,
    AsOfUtc            DATETIME2(3)     NOT NULL,   -- the PROVIDER's timestamp
    IngestedAtUtc      DATETIME2(3)     NOT NULL DEFAULT SYSUTCDATETIME(),
    CorrelationId      UNIQUEIDENTIFIER NULL,
    CONSTRAINT CK_PositionSnapshot_NonNeg CHECK (
        Available >= 0 AND PendingIn >= 0 AND PendingOut >= 0 AND Reserved >= 0)
);
CREATE INDEX IX_PositionSnapshot_Latest
    ON PositionSnapshot (TreasuryAccountId, AssetCode, NetworkCode, AsOfUtc DESC)
    INCLUDE (Available, PendingIn, PendingOut, Reserved, ReservedIsEstimate, SourceType);

GO
CREATE TRIGGER TR_PositionSnapshot_NoMutate ON PositionSnapshot
INSTEAD OF UPDATE, DELETE AS
BEGIN THROW 50001, 'PositionSnapshot is append-only.', 1; END;
GO

-- ═══════════════════════════════════════════════════════════
--  MOVEMENTS AND APPROVALS
-- ═══════════════════════════════════════════════════════════
CREATE TABLE Destination (
    DestinationId     INT IDENTITY(1,1) PRIMARY KEY,
    Label             NVARCHAR(120) NOT NULL,
    TreasuryAccountId INT           NULL,
    AddressOrRef      NVARCHAR(200) NOT NULL,
    NetworkCode       VARCHAR(24)   NULL,
    IsApproved        BIT           NOT NULL DEFAULT 0,
    ApprovedByUserId1 INT           NULL,
    ApprovedByUserId2 INT           NULL,
    ApprovedAtUtc     DATETIME2(3)  NULL,
    CONSTRAINT UQ_Destination UNIQUE (AddressOrRef, NetworkCode),
    -- adding an allowlist entry is itself a dual-approval act
    CONSTRAINT CK_Destination_DualApproval CHECK (
        IsApproved = 0 OR (ApprovedByUserId1 IS NOT NULL
                       AND ApprovedByUserId2 IS NOT NULL
                       AND ApprovedByUserId1 <> ApprovedByUserId2))
);

CREATE TABLE LiquidityRule (
    LiquidityRuleId       INT IDENTITY(1,1) PRIMARY KEY,
    RuleCode              VARCHAR(48)   NOT NULL UNIQUE,
    Name                  NVARCHAR(160) NOT NULL,
    SourceAccountId       INT           NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    DestinationAccountId  INT           NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    AssetCode             VARCHAR(12)   NOT NULL,
    NetworkCode           VARCHAR(24)   NULL,
    Strategy              VARCHAR(24)   NOT NULL,   -- TO_TARGET|FULL|EXCESS_OVER_MAX|FIXED
    FixedAmount           DECIMAL(28,8) NULL,
    MaxAmountPerRun       DECIMAL(28,8) NOT NULL,
    MaxDailyValue         DECIMAL(28,8) NOT NULL,
    MaxExecutionsPerDay   TINYINT       NOT NULL DEFAULT 4,
    ApprovalThresholdBase DECIMAL(28,8) NOT NULL DEFAULT 0,
    SourceMinRemaining    DECIMAL(28,8) NOT NULL DEFAULT 0,
    MaxDataAgeSeconds     INT           NOT NULL DEFAULT 900,
    IsEnabled             BIT           NOT NULL DEFAULT 0,
    LastDryRunAtUtc       DATETIME2(3)  NULL,
    LastDryRunConfigHash  VARCHAR(64)   NULL,
    CreatedByUserId       INT           NULL,
    RowVer                ROWVERSION    NOT NULL,
    CONSTRAINT CK_LiquidityRule_NotSelf CHECK (SourceAccountId <> DestinationAccountId),
    -- a rule cannot be armed without a dry run
    CONSTRAINT CK_LiquidityRule_DryRun CHECK (IsEnabled = 0 OR LastDryRunAtUtc IS NOT NULL)
);

CREATE TABLE Movement (
    MovementId           BIGINT IDENTITY(1,1) PRIMARY KEY,
    MovementCode         VARCHAR(20)    NOT NULL UNIQUE,   -- 'MV-4472'
    MovementType         VARCHAR(20)    NOT NULL,
    Status               VARCHAR(24)    NOT NULL,
    SourceAccountId      INT            NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    DestinationAccountId INT            NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    DestinationId        INT            NULL REFERENCES Destination(DestinationId),
    AssetCode            VARCHAR(12)    NOT NULL,
    NetworkCode          VARCHAR(24)    NULL,
    Amount               DECIMAL(28,8)  NOT NULL,
    FeeAmount            DECIMAL(28,8)  NULL,
    FeeAssetCode         VARCHAR(12)    NULL,
    FxRate               DECIMAL(28,10) NULL,
    AmountBase           DECIMAL(28,8)  NOT NULL,   -- FROZEN at creation
    RequiredApprovals    TINYINT        NOT NULL,   -- FROZEN at creation
    GateStatusAtCreation VARCHAR(12)    NOT NULL,
    IdempotencyKey       VARCHAR(64)    NOT NULL UNIQUE,
    InitiatedByUserId    INT            NULL,       -- NULL when a rule created it
    LiquidityRuleId      INT            NULL REFERENCES LiquidityRule(LiquidityRuleId),
    Reference            NVARCHAR(200)  NULL,
    ExternalRef          NVARCHAR(128)  NULL,
    FailureReason        NVARCHAR(500)  NULL,
    RejectReason         NVARCHAR(500)  NULL,
    CreatedAtUtc         DATETIME2(3)   NOT NULL DEFAULT SYSUTCDATETIME(),
    SubmittedAtUtc       DATETIME2(3)   NULL,
    SettledAtUtc         DATETIME2(3)   NULL,
    RowVer               ROWVERSION     NOT NULL,
    CONSTRAINT CK_Movement_Positive CHECK (Amount > 0),
    CONSTRAINT CK_Movement_NotSelf  CHECK (SourceAccountId <> DestinationAccountId),
    CONSTRAINT CK_Movement_Actor    CHECK (InitiatedByUserId IS NOT NULL OR LiquidityRuleId IS NOT NULL),
    CONSTRAINT CK_Movement_Type   CHECK (MovementType IN
        ('INTERNAL','TOP_UP','SWEEP','FX','FEE_SWEEP','MINT','BURN')),
    CONSTRAINT CK_Movement_Status CHECK (Status IN
        ('DRAFT','PENDING_APPROVAL','APPROVED','SUBMITTED','CONFIRMING',
         'SETTLED','FAILED','REJECTED','CANCELLED','MANUALLY_RESOLVED'))
);
CREATE INDEX IX_Movement_Status ON Movement(Status, CreatedAtUtc DESC);
CREATE INDEX IX_Movement_Pending ON Movement(CreatedAtUtc DESC)
    WHERE Status = 'PENDING_APPROVAL';   -- the approvals queue

-- The UNIQUE constraint is what enforces one approval per person.
CREATE TABLE MovementApproval (
    MovementApprovalId BIGINT IDENTITY(1,1) PRIMARY KEY,
    MovementId         BIGINT        NOT NULL REFERENCES Movement(MovementId),
    ApproverUserId     INT           NOT NULL,
    Decision           VARCHAR(12)   NOT NULL,   -- APPROVED | REJECTED
    Reason             NVARCHAR(500) NULL,
    StepUpVerified     BIT           NOT NULL DEFAULT 0,
    DecidedAtUtc       DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME(),
    CONSTRAINT UQ_MovementApproval UNIQUE (MovementId, ApproverUserId)
);

CREATE TABLE MovementEvent (
    MovementEventId BIGINT IDENTITY(1,1) PRIMARY KEY,
    MovementId      BIGINT        NOT NULL REFERENCES Movement(MovementId),
    FromStatus      VARCHAR(24)   NULL,
    ToStatus        VARCHAR(24)   NOT NULL,
    ActorUserId     INT           NULL,
    ActorType       VARCHAR(12)   NOT NULL,   -- USER | RULE | SYSTEM | PROVIDER
    Reason          NVARCHAR(500) NULL,
    OccurredAtUtc   DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME()
);

-- Every evaluation, INCLUDING skips. Without skip rows, "why didn't the rule
-- fire last night?" is unanswerable — and that is asked after every incident.
CREATE TABLE RuleExecution (
    RuleExecutionId BIGINT IDENTITY(1,1) PRIMARY KEY,
    LiquidityRuleId INT           NOT NULL REFERENCES LiquidityRule(LiquidityRuleId),
    TriggeredAtUtc  DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME(),
    Outcome         VARCHAR(24)   NOT NULL,   -- CREATED|NOT_TRIGGERED|GATE_BLOCKED|
                                              -- STALE_DATA|SOURCE_FLOOR|DAILY_CAP|
                                              -- KILL_SWITCH|ERROR
    Reason          NVARCHAR(500) NULL,
    ComputedAmount  DECIMAL(28,8) NULL,
    MovementId      BIGINT        NULL REFERENCES Movement(MovementId),
    DurationMs      INT           NULL
);

-- ═══════════════════════════════════════════════════════════
--  DERIVED AND SUPPORTING — check first for existing equivalents
-- ═══════════════════════════════════════════════════════════
CREATE TABLE FxRate (
    FxRateId   BIGINT IDENTITY(1,1) PRIMARY KEY,
    BaseAsset  VARCHAR(12)    NOT NULL,
    QuoteAsset VARCHAR(12)    NOT NULL,
    Rate       DECIMAL(28,10) NOT NULL CHECK (Rate > 0),
    Source     VARCHAR(32)    NOT NULL,
    ValidAtUtc DATETIME2(3)   NOT NULL
);
CREATE INDEX IX_FxRate_Lookup ON FxRate(BaseAsset, QuoteAsset, ValidAtUtc DESC) INCLUDE (Rate);

CREATE TABLE CoverageSnapshot (
    CoverageSnapshotId BIGINT IDENTITY(1,1) PRIMARY KEY,
    LiabilitiesBase    DECIMAL(28,8) NULL,   -- NULL when a module is not reporting
    LiabilitySourceAt  DATETIME2(3)  NULL,   -- provenance
    SegregatedBase     DECIMAL(28,8) NOT NULL,
    RatioPct           DECIMAL(9,4)  NULL,
    Status             VARCHAR(20)   NOT NULL,
    ShortfallBase      DECIMAL(28,8) NULL,
    ComputedAtUtc      DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME(),
    CONSTRAINT CK_CoverageSnapshot_Status CHECK (Status IN
        ('HEALTHY','INSIDE_BUFFER','SHORTFALL','UNAVAILABLE'))
);

CREATE TABLE ForecastPoint (
    ForecastPointId   BIGINT IDENTITY(1,1) PRIMARY KEY,
    ForecastRunId     UNIQUEIDENTIFIER NOT NULL,
    TreasuryAccountId INT           NOT NULL REFERENCES TreasuryAccount(TreasuryAccountId),
    AssetCode         VARCHAR(12)   NOT NULL,
    ForecastDate      DATE          NOT NULL,
    ProjectedBalance  DECIMAL(28,8) NOT NULL,
    ProjectedInflow   DECIMAL(28,8) NOT NULL DEFAULT 0,
    ProjectedOutflow  DECIMAL(28,8) NOT NULL DEFAULT 0,
    BreachesMinimum   BIT           NOT NULL DEFAULT 0,
    GeneratedAtUtc    DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME()
);

CREATE TABLE AlertInstance (
    AlertInstanceId      BIGINT IDENTITY(1,1) PRIMARY KEY,
    AlertType            VARCHAR(48)   NOT NULL,
    Severity             VARCHAR(12)   NOT NULL,   -- INFO|WARNING|CRITICAL|PAGE
    DedupKey             VARCHAR(160)  NOT NULL,
    Message              NVARCHAR(500) NOT NULL,   -- server-composed
    TargetRoute          NVARCHAR(200) NULL,       -- server tells the UI where to go
    SourceType           VARCHAR(24)   NOT NULL,
    RelatedEntityId      BIGINT        NULL,
    Status               VARCHAR(16)   NOT NULL DEFAULT 'OPEN',
    RaisedAtUtc          DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME(),
    AcknowledgedByUserId INT           NULL,
    AcknowledgedAtUtc    DATETIME2(3)  NULL,
    ResolvedAtUtc        DATETIME2(3)  NULL
);
-- one open alert per condition
CREATE UNIQUE INDEX UX_AlertInstance_OpenDedup ON AlertInstance(DedupKey)
    WHERE Status <> 'RESOLVED';

-- USE THE EXISTING AUDIT LOG IF ONE EXISTS.
CREATE TABLE AuditLog (
    AuditLogId    BIGINT IDENTITY(1,1) PRIMARY KEY,
    ActorUserId   INT              NULL,
    ActorType     VARCHAR(12)      NOT NULL,   -- USER | SYSTEM | RULE
    ActorName     NVARCHAR(120)    NOT NULL,
    Action        VARCHAR(64)      NOT NULL,
    ResourceType  VARCHAR(48)      NOT NULL,
    ResourceId    NVARCHAR(64)     NULL,
    Description   NVARCHAR(600)    NOT NULL,
    BeforeJson    NVARCHAR(MAX)    NULL,
    AfterJson     NVARCHAR(MAX)    NULL,
    CorrelationId UNIQUEIDENTIFIER NULL,
    OccurredAtUtc DATETIME2(3)     NOT NULL DEFAULT SYSUTCDATETIME()
);
GO
CREATE TRIGGER TR_AuditLog_NoMutate ON AuditLog
INSTEAD OF UPDATE, DELETE AS
BEGIN THROW 50002, 'AuditLog is immutable.', 1; END;
GO

CREATE TABLE IdempotencyRecord (
    IdempotencyKey VARCHAR(64)   PRIMARY KEY,
    Endpoint       VARCHAR(120)  NOT NULL,
    RequestHash    VARCHAR(64)   NOT NULL,
    ResponseStatus INT           NOT NULL,
    ResponseJson   NVARCHAR(MAX) NULL,
    CreatedAtUtc   DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME()
);

CREATE TABLE SystemSetting (
    SettingKey      VARCHAR(64)   PRIMARY KEY,
    SettingValue    NVARCHAR(400) NOT NULL,
    UpdatedByUserId INT           NULL,
    UpdatedAtUtc    DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME()
);

CREATE TABLE JobRun (
    JobRunId       BIGINT IDENTITY(1,1) PRIMARY KEY,
    JobName        VARCHAR(64)   NOT NULL,
    StartedAtUtc   DATETIME2(3)  NOT NULL,
    FinishedAtUtc  DATETIME2(3)  NULL,
    ItemsProcessed INT           NOT NULL DEFAULT 0,
    ItemsFailed    INT           NOT NULL DEFAULT 0,
    Status         VARCHAR(16)   NOT NULL,
    ErrorSummary   NVARCHAR(MAX) NULL
);

18.1 The three views that back most of the API

-- The single most-used object in the system.
CREATE VIEW vw_CurrentPosition AS
SELECT p.TreasuryAccountId, p.AssetCode, p.NetworkCode,
       p.Available, p.PendingIn, p.PendingOut, p.Reserved, p.ReservedIsEstimate,
       p.Available + p.PendingIn + p.Reserved AS Total,
       CASE WHEN t.MinimumAmount IS NULL THEN p.Available
            WHEN p.Available - t.MinimumAmount < 0 THEN 0
            ELSE p.Available - t.MinimumAmount END AS Movable,
       t.MinimumAmount, t.TargetAmount, t.MaximumAmount,
       CASE WHEN t.MinimumAmount IS NULL OR t.MinimumAmount = 0 THEN 'NONE'
            WHEN p.Available <  t.MinimumAmount        THEN 'BELOW'
            WHEN p.Available <  t.MinimumAmount * 1.15 THEN 'NEAR'
            WHEN t.MaximumAmount > 0
             AND p.Available >  t.MaximumAmount        THEN 'ABOVE'
            ELSE 'OK' END AS HealthStatus,
       p.AsOfUtc, p.SourceType,
       DATEDIFF(SECOND, p.AsOfUtc, SYSUTCDATETIME()) AS DataAgeSeconds
FROM (SELECT *, ROW_NUMBER() OVER (
         PARTITION BY TreasuryAccountId, AssetCode, NetworkCode
         ORDER BY AsOfUtc DESC, PositionSnapshotId DESC) AS rn
      FROM PositionSnapshot) p
LEFT JOIN AccountThreshold t
  ON t.TreasuryAccountId = p.TreasuryAccountId AND t.AssetCode = p.AssetCode
WHERE p.rn = 1;

-- Position in base currency with account, provider and module context.
CREATE VIEW vw_PositionBase AS
SELECT a.TreasuryAccountId, a.AccountCode, a.Name AS AccountName, a.Classification,
       a.ProviderAccountRef, c.ConnectionId, c.ConnectionCode,
       pr.Name AS ProviderName, m.ModuleCode, m.Name AS ModuleName,
       cp.AssetCode, cp.NetworkCode, cp.Available, cp.Reserved, cp.PendingIn,
       cp.PendingOut, cp.Total, cp.Movable, cp.MinimumAmount, cp.TargetAmount,
       cp.MaximumAmount, cp.HealthStatus, cp.DataAgeSeconds,
       cp.ReservedIsEstimate, cp.AsOfUtc,
       cp.Total     * ISNULL(fx.Rate,1) AS TotalBase,
       cp.Movable   * ISNULL(fx.Rate,1) AS MovableBase,
       cp.Available * ISNULL(fx.Rate,1) AS AvailableBase
FROM TreasuryAccount a
JOIN Connection c ON c.ConnectionId = a.ConnectionId
JOIN Provider  pr ON pr.ProviderId  = c.ProviderId
JOIN Module    m  ON m.ModuleId     = c.ModuleId
JOIN vw_CurrentPosition cp ON cp.TreasuryAccountId = a.TreasuryAccountId
LEFT JOIN (SELECT BaseAsset, QuoteAsset, Rate FROM
             (SELECT *, ROW_NUMBER() OVER (PARTITION BY BaseAsset, QuoteAsset
                        ORDER BY ValidAtUtc DESC) rn FROM FxRate) x
           WHERE rn = 1) fx
  ON fx.BaseAsset = cp.AssetCode AND fx.QuoteAsset = 'USD'
WHERE a.IsActive = 1;

-- THE GATE, in SQL. Mirrors GateService exactly.

19. API → table reference

R = reads · W = writes. Use this to scope a ticket, trace a bug from screen to table, and know what a schema change will break.

19.1 Positions and accounts

Endpoint Screen Tables
GET /positions/summary S1 R vw_PositionBase · vw_ConnectionGate · AlertInstance · CoverageSnapshot · Movement
GET /positions/by-module S2, S3 R vw_PositionBase · vw_ConnectionGate
GET /positions/exposure S14 R vw_PositionBase · Asset · FxRate
GET /accounts S2, S3, S4 R vw_PositionBase · vw_ConnectionGate
GET /accounts/{id} S5 R TreasuryAccount · Connection · Provider · Module · AccountThreshold · vw_ConnectionGate
GET /accounts/{id}/positions S5 R vw_CurrentPosition · FxRate
PATCH /accounts/{id}/thresholds S5 R TreasuryAccount · W AccountThreshold (+history) · AuditLog

19.2 Movements

Endpoint Screen Tables
POST /movements/preview S6, S6b R vw_CurrentPosition · AccountThreshold · TreasuryAccount · vw_ConnectionGate · Destination · FxRate · SystemSetting · Asset
POST /movements S6 R all of the above · W Movement · MovementEvent · IdempotencyRecord · AuditLog
GET /movements S1, S5, S7, S12 R Movement · TreasuryAccount · Connection · Provider · AppUser · LiquidityRule
GET /movements/{code} S8 R Movement · MovementEvent · MovementApproval · AppUser · vw_ConnectionGate

19.3 Decisions

Endpoint Screen Tables
GET /approvals/queue S9 R Movement (filtered index) · MovementApproval · AppUser · TreasuryAccount · vw_ConnectionGate
POST /movements/{code}/approve S8, S9 R Movement · MovementApproval · SystemSetting · W MovementApproval · Movement · MovementEvent · AuditLog
POST /movements/{code}/reject S8, S9 W MovementApproval · Movement · MovementEvent · AuditLog
POST /movements/{code}/cancel S8 W Movement · MovementEvent · AuditLog
POST /movements/{code}/retry S8 W Movement · MovementApproval (cleared) · MovementEvent · AuditLog
POST /movements/{code}/resolve S8 W Movement · MovementEvent · AuditLogand will show as a variance until an admin accepts it

A refused self-approval writes to AuditLog even though nothing else changes. That row is the evidence the control works.

19.4 Rules and automation

Endpoint Screen Tables
GET /rules S10 R LiquidityRule · TreasuryAccount · RuleExecution · vw_ConnectionGate · SystemSetting
POST /rules S10 R TreasuryAccount (classification check) · W LiquidityRule · AuditLog
PATCH /rules/{id} S10 W LiquidityRule · AuditLog
POST /rules/{id}/dry-run S10b R LiquidityRule · vw_CurrentPosition · vw_ConnectionGate · RuleExecution · W LiquidityRule (dry-run stamp)
POST /automation/kill-switch global W SystemSetting · AuditLog

19.5 Connections, coverage, alerts, audit

Endpoint Screen Tables
GET /connections S11 R Connection · Provider · Module · ConnectionSyncState · BalanceCheck · vw_ConnectionGate · vw_PositionBase
POST /connections/{id}/refresh S11, S12 W PositionSnapshot · ConnectionSyncState · BalanceCheck · AuditLog
GET /balance-checks S18 R BalanceCheck · TreasuryAccount · Connection
POST /balance-checks/refresh-all S18 W PositionSnapshot · ConnectionSyncState · BalanceCheck · AuditLog
POST /balance-checks/{id}/accept S18 W BalanceCheck · TreasuryAccount (baseline) · AlertInstance · AuditLog
POST /connections S11 W Provider · Connection · TreasuryAccount · AuditLog
GET /connections/{id} S12 R as above + TreasuryAccount · LiquidityRule · Movement
PATCH /connections/{id} S12 W Connection (tolerance) · AuditLog
GET /coverage S1, S15, S15b R CoverageSnapshot · ClientLiabilitySnapshot · vw_PositionBase (client accounts)
GET /coverage/history S15 R CoverageSnapshot
GET /forecast/{accountId} S13 R ForecastPoint · AccountThreshold · vw_CurrentPosition · Movement
GET /alerts S1, S16 R AlertInstance
POST /alerts/{id}/acknowledge S16 W AlertInstance · AuditLog
GET /audit S17 R AuditLog · AppUser

19.6 Background jobs

Job Tables
TreasuryPositionSync R Connection · TreasuryAccount · vw_CurrentPosition (unchanged check) · W PositionSnapshot · JobRun
TreasuryThresholdEval R vw_CurrentPosition · W AlertInstance · JobRun
TreasuryRuleEngine R LiquidityRule · vw_CurrentPosition · vw_ConnectionGate · SystemSetting · W RuleExecution · Movement · MovementEvent · JobRun
TreasuryMovementStatusPoll R Movement · W Movement · MovementEvent · PositionSnapshot · JobRun
TreasuryCoverageCalc R vw_PositionBase · ClientLiabilitySnapshot · W CoverageSnapshot · AlertInstance · JobRun
TreasuryClientLiabilitySync R module client-liabilities · W ClientLiabilitySnapshot · AlertInstance
TreasuryBalanceVerify R module account-activity · vw_CurrentPosition · W BalanceCheck · TreasuryAccount · AlertInstance
TreasuryForecastRebuild R vw_CurrentPosition · Movement · W ForecastPoint · JobRun
TreasuryFxRateRefresh W FxRate · JobRun

19.7 Table → consumer

What breaks if you change it.

Table / view Written by Changing it affects
vw_PositionBase (view) S1, S2, S3, S4, S14, S15 — the most widely consumed object
vw_ConnectionGate (view) The gate. S3, S4, S5, S6, S8, S9, S10, S11, S12
PositionSnapshot PositionSync, webhooks All position data. Append-only.
ConnectionSyncState PositionSync only The gate. Freshness and sync health.
BalanceCheck BalanceVerify, accept The gate. Append-only. The evidence behind every variance block.
ClientLiabilitySnapshot ClientLiabilitySync K-2 denominator
AccountThreshold S5 PATCH Health status, K-3, alerts, forecast, rule triggers
Movement preview→create, approve, jobs S1, S5, S7, S8, S9, S12
MovementApproval approve, reject The maker–checker control
LiquidityRule rule CRUD S10, all automation
CoverageSnapshot CoverageCalc K-2, S1, S15
AlertInstance four jobs S1, S16, nav badges
SystemSetting admin Quorum, kill switch, tolerances
AuditLog interceptor S17. Immutable.

20. Seed data

Reproduces exactly the state in the §7 screenshots so QA can compare screen against script. Non-production only.

20.1 The deliberate test conditions

These are fixtures, not data errors.

Condition Where Exercises
Feed missing wasabi-cards Gate blocking · rule shows BLOCKED not OFF · Stale pill · S10b
Feed late mesta-payments Gate blocking on lateness · extra approver on manual
Breaks above tolerance tazapay-banks — 410,000 vs 50,000 Gate blocking on break value
Below minimum Rain collateral 185,000 / 200,000 K-3 · red pill · threshold bar · PAGE alert
Below minimum Polygon gas 12,000 / 15,000 Second account for the count
Near minimum Thunes prefund 620,000 / 600,000 The amber NEAR state
Coverage inside buffer 101.40% K-2 amber band · top-up suggestion
Self-approval trap MV-4473 initiated by Anita K BR-05 — log in as Anita and try to approve it
Failed movement MV-4468 Retry and manual-resolve paths
Estimated reserved Wasabi collateral The (est.) label
Stale position Wasabi collateral, 14h old Data-age warning

20.2 Expected values after seeding

Every screen must show these. The script ends with verification queries that print the same figures; a mismatch is an application defect, not a data problem.

Figure Expected
Total holdings $86,339,500
Client liabilities (from our own module ledgers) $47,262,000 — wallets 26,270,000 + banks 20,992,000
Segregated client assets $47,924,000
Coverage ratio 101.4007% → displays 101.40%, amber
Top-up to restore the 102% buffer $283,240
Accounts below minimum 2
Blocked connections 3
Pending approvals 3
Blocked rules 2
Wallets / Cards / Banks / Payments / Exchange $32,543,500 / $6,281,000 / $37,595,000 / $4,670,000 / $5,250,000

20.3 Reference and connections

SET IDENTITY_INSERT Module ON;
INSERT INTO Module (ModuleId, ModuleCode, Name, SortOrder) VALUES
 (1,'wallets','Wallets',1),(2,'cards','Cards',2),(3,'banks','Banks',4),
 (4,'payments','Payments',3),(5,'exchange','Exchange',5);
SET IDENTITY_INSERT Module OFF;

SET IDENTITY_INSERT Provider ON;
INSERT INTO Provider (ProviderId, ProviderCode, Name, Kind) VALUES
 (1,'dfns','DFNS','MPC wallet custody'),
 (2,'rain','Rain','Card issuing and processing'),
 (3,'straitsx','StraitsX','Card issuing, SGD stablecoin rails'),
 (4,'wasabi','Wasabi','Card issuing'),
 (5,'noah','Noah','Banking rails and payment settlement'),
 (6,'tazapay','Tazapay','Cross-border banking and payments'),
 (7,'openpayd','OpenPayd','Banking-as-a-service, virtual IBANs'),
 (8,'thunes','Thunes','Global payout network and banking'),
 (9,'mesta','Mesta','Payment corridors'),
 (10,'kraken','Kraken','Exchange and liquidity'),
 (11,'binance','Binance','Exchange and liquidity');
SET IDENTITY_INSERT Provider OFF;

-- Noah, Tazapay and Thunes each appear TWICE — independent positions.
SET IDENTITY_INSERT Connection ON;
INSERT INTO Connection (ConnectionId, ConnectionCode, ProviderId, ModuleId,
                        AdapterVersion, LiveSince, StalenessThresholdSeconds,
                        AutomationToleranceBase) VALUES
 (1, 'dfns-wallets',     1,1,'dfns-v2.0',    '2025-11-04', 60,50000.00),
 (2, 'rain-cards',       2,2,'rain-v2.8',    '2025-09-18',300,50000.00),
 (3, 'straitsx-cards',   3,2,'straitsx-v3.2','2026-01-22',300,50000.00),
 (4, 'wasabi-cards',     4,2,'wasabi-v1.4',  '2026-03-10',300,50000.00),
 (5, 'noah-banks',       5,3,'noah-v1.6',    '2025-10-02',900,50000.00),
 (6, 'noah-payments',    5,4,'noah-v1.6',    '2025-10-02',900,50000.00),
 (7, 'tazapay-banks',    6,3,'tazapay-v2.1', '2025-08-14',900,50000.00),
 (8, 'tazapay-payments', 6,4,'tazapay-v2.1', '2025-08-14',900,50000.00),
 (9, 'openpayd-banks',   7,3,'openpayd-v1.9','2025-12-01',900,50000.00),
 (10,'thunes-banks',     8,3,'thunes-v2.3',  '2026-02-11',900,50000.00),
 (11,'thunes-payments',  8,4,'thunes-v2.3',  '2026-02-11',900,50000.00),
 (12,'mesta-payments',   9,4,'mesta-v1.2',   '2026-04-08',900,50000.00),
 (13,'kraken-exchange', 10,5,'kraken-v1.1',  '2026-05-19',120,50000.00),
 (14,'binance-exchange',11,5,'binance-v1.0', '2026-06-24',120,50000.00);
SET IDENTITY_INSERT Connection OFF;

INSERT INTO Asset (AssetCode,Name,AssetType,Decimals,ConcentrationLimitPct) VALUES
 ('USD','US Dollar','FIAT',2,60.00),        ('SGD','Singapore Dollar','FIAT',2,25.00),
 ('EUR','Euro','FIAT',2,15.00),             ('GBP','Pound Sterling','FIAT',2,10.00),
 ('MYR','Malaysian Ringgit','FIAT',2,5.00), ('USDC','USD Coin','STABLECOIN',6,40.00),
 ('USDT','Tether','STABLECOIN',6,15.00),    ('XSGD','StraitsX SGD','STABLECOIN',6,10.00),
 ('ETH','Ether','CRYPTO',18,2.00),          ('POL','Polygon','CRYPTO',18,2.00),
 ('TRX','Tron','CRYPTO',6,2.00);

INSERT INTO Network (NetworkCode,Name,NativeAssetCode,ConfirmationsRequired) VALUES
 ('ethereum','Ethereum','ETH',12),('polygon','Polygon','POL',128),('tron','Tron','TRX',19);

-- Fixed rates so every test is deterministic. Do not randomise.
INSERT INTO FxRate (BaseAsset,QuoteAsset,Rate,Source,ValidAtUtc) VALUES
 ('USD','USD',1.0000000000,'SEED','2026-08-01T00:00:00'),
 ('SGD','USD',0.7400000000,'SEED','2026-08-01T00:00:00'),
 ('EUR','USD',1.0800000000,'SEED','2026-08-01T00:00:00'),
 ('GBP','USD',1.2700000000,'SEED','2026-08-01T00:00:00'),
 ('MYR','USD',0.2100000000,'SEED','2026-08-01T00:00:00'),
 ('USDC','USD',1.0000000000,'SEED','2026-08-01T00:00:00'),
 ('USDT','USD',1.0000000000,'SEED','2026-08-01T00:00:00'),
 ('XSGD','USD',0.7400000000,'SEED','2026-08-01T00:00:00'),
 ('ETH','USD',3500.0000000000,'SEED','2026-08-01T00:00:00'),
 ('POL','USD',0.4500000000,'SEED','2026-08-01T00:00:00'),
 ('TRX','USD',0.1300000000,'SEED','2026-08-01T00:00:00');

20.4 Sync state and balance checks — three deliberately blocked

-- 11 connections syncing normally; 3 deliberately broken in different ways.
INSERT INTO ConnectionSyncState
 (ConnectionId,LastAttemptAtUtc,LastSuccessAtUtc,LastStatus,ConsecutiveFailures,LastError) VALUES
 (1, '2026-08-01T09:15:30','2026-08-01T09:15:30','OK',          0,NULL),
 (2, '2026-08-01T09:12:00','2026-08-01T09:12:00','OK',          0,NULL),
 (3, '2026-08-01T09:13:00','2026-08-01T09:13:00','OK',          0,NULL),
 (4, '2026-08-01T09:15:00','2026-07-31T18:40:00','FAILED',      7,'401 Unauthorized — API credential rejected'),  -- BLOCKED: sync
 (5, '2026-08-01T09:08:00','2026-08-01T09:08:00','OK',          0,NULL),
 (6, '2026-08-01T09:08:00','2026-08-01T09:08:00','OK',          0,NULL),
 (7, '2026-08-01T09:06:00','2026-08-01T09:06:00','OK',          0,NULL),  -- BLOCKED below, on variance
 (8, '2026-08-01T09:06:00','2026-08-01T09:06:00','OK',          0,NULL),
 (9, '2026-08-01T09:04:00','2026-08-01T09:04:00','OK',          0,NULL),
 (10,'2026-08-01T09:07:00','2026-08-01T09:07:00','OK',          0,NULL),
 (11,'2026-08-01T09:07:00','2026-08-01T09:07:00','OK',          0,NULL),
 (12,'2026-08-01T09:15:00','2026-08-01T05:10:00','RATE_LIMITED',1,'429 Too Many Requests — provider rate limit'), -- BLOCKED: freshness
 (13,'2026-08-01T09:15:00','2026-08-01T09:15:00','OK',          0,NULL),  -- FRESHNESS_ONLY
 (14,'2026-08-01T09:15:00','2026-08-01T09:15:00','OK',          0,NULL);

-- The variance check. Every account is checked; only the fixtures below differ from zero.
-- Tazapay SGD is the deliberate breach: -554,054.05 SGD = -410,000 base, past the 50,000 tolerance.
INSERT INTO BalanceCheck
 (TreasuryAccountId,AssetCode,ProviderBalance,ExpectedBalance,Variance,VarianceBase,
  ToleranceBase,Status,ModuleInflow,ModuleOutflow,TreasuryNet,CheckedAtUtc) VALUES
 (1, 'USDC',14200000.00,14200000.00,      0.00,       0.00,50000,'WITHIN',      0.00,      0.00,      0.00,'2026-08-01T09:15:30'),
 (4, 'USDC', 5400000.00, 5400000.00,      0.00,       0.00,50000,'WITHIN',      0.00,      0.00,      0.00,'2026-08-01T09:15:30'),
 (8, 'USDC',  185000.00,  186250.00,  -1250.00,   -1250.00,50000,'WITHIN',      0.00,  66250.00, 250000.00,'2026-08-01T09:12:00'),
 (10,'XSGD', 5000000.00, 5000000.00,      0.00,       0.00,50000,'WITHIN',      0.00,      0.00,      0.00,'2026-08-01T09:13:00'),
 (13,'USD',  9800000.00, 9818400.00, -18400.00,  -18400.00,50000,'WITHIN', 412000.00, 430400.00,      0.00,'2026-08-01T09:08:00'),
 (14,'SGD',  8500000.00, 9054054.05,-554054.05, -410000.00,50000,'OUTSIDE',1200000.00,645945.95,     0.00,'2026-08-01T09:06:00'),
 (20,'USD',  1850000.00, 1850000.00,      0.00,       0.00,50000,'WITHIN',      0.00,      0.00,      0.00,'2026-08-01T09:08:00'),
 (22,'USD',   620000.00,  620000.00,      0.00,       0.00,50000,'WITHIN',      0.00, 180000.00,      0.00,'2026-08-01T09:07:00'),
 (23,'USD',  1240000.00, 1233600.00,   6400.00,    6400.00,50000,'STALE',       0.00,   6400.00,      0.00,'2026-08-01T05:10:00');
-- Account 11 (Wasabi collateral) deliberately has NO row: its connection cannot sync.

-- Client liabilities from our own module ledgers. Sum = 47,262,000 = the K-2 denominator.
INSERT INTO ClientLiabilitySnapshot
 (ModuleCode,AssetCode,TotalOwedToCustomers,TotalOwedBase,CustomerAccountCount,ModuleAsOfUtc) VALUES
 ('wallets','USD',26270000.00,26270000.00,18422,'2026-08-01T09:10:00'),
 ('banks',  'USD',20992000.00,20992000.00, 6104,'2026-08-01T09:10:00');

20.5 Accounts, positions and thresholds

SET IDENTITY_INSERT TreasuryAccount ON;
INSERT INTO TreasuryAccount (TreasuryAccountId,AccountCode,ConnectionId,Name,
                             ProviderAccountRef,Classification,AssetCode,NetworkCode,LegalEntity) VALUES
 (1, 'ACC-W01', 1,'Client custody USDC',       'wa-3f81c092','CLIENT_SAFEGUARDED','USDC','ethereum','PlatformCo Pte Ltd'),
 (2, 'ACC-W02', 1,'Client custody USDT',       'wa-77b1d410','CLIENT_SAFEGUARDED','USDT','tron',    'PlatformCo Pte Ltd'),
 (3, 'ACC-W03', 1,'Client custody USDC (Poly)','wa-2c94ff10','CLIENT_SAFEGUARDED','USDC','polygon', 'PlatformCo Pte Ltd'),
 (4, 'ACC-W04', 1,'Treasury hot wallet',       'wa-91ae0071','CORPORATE_OPERATING','USDC','ethereum','PlatformCo Pte Ltd'),
 (5, 'ACC-W05', 1,'Gas tank Ethereum',         'wa-gas-eth01','GAS_TANK','ETH','ethereum','PlatformCo Pte Ltd'),
 (6, 'ACC-W06', 1,'Gas tank Polygon',          'wa-gas-pol01','GAS_TANK','POL','polygon','PlatformCo Pte Ltd'),
 (7, 'ACC-W07', 1,'Fee collection wallet',     'wa-fee-0012','FEE_REVENUE','USDC','ethereum','PlatformCo Pte Ltd'),
 (8, 'ACC-C01', 2,'Rain card collateral',      'rn-col-0087','COLLATERAL','USDC','ethereum','PlatformCo Pte Ltd'),
 (9, 'ACC-C02', 2,'Rain settlement account',   'rn-set-0087','CORPORATE_OPERATING','USD',NULL,'PlatformCo Pte Ltd'),
 (10,'ACC-C03', 3,'StraitsX card float',       'sx-flt-2210','COLLATERAL','XSGD',NULL,'PlatformCo Pte Ltd'),
 (11,'ACC-C04', 4,'Wasabi card collateral',    'wb-col-0441','COLLATERAL','USDC','ethereum','PlatformCo Pte Ltd'),
 (12,'ACC-B01', 5,'Noah USD operating',        'nh-usd-1001','CORPORATE_OPERATING','USD',NULL,'PlatformCo Pte Ltd'),
 (13,'ACC-B02', 5,'Noah client segregated',    'nh-seg-1002','CLIENT_SAFEGUARDED','USD',NULL,'PlatformCo Pte Ltd'),
 (14,'ACC-B03', 7,'Tazapay SGD collections',   'tz-sgd-3301','CLIENT_SAFEGUARDED','SGD',NULL,'PlatformCo Pte Ltd'),
 (15,'ACC-B04', 7,'Tazapay USD operating',     'tz-usd-3302','CORPORATE_OPERATING','USD',NULL,'PlatformCo Pte Ltd'),
 (16,'ACC-B05', 9,'OpenPayd EUR IBAN',         'op-eur-5510','CLIENT_SAFEGUARDED','EUR',NULL,'PlatformCo Europe BV'),
 (17,'ACC-B06', 9,'OpenPayd GBP IBAN',         'op-gbp-5511','CLIENT_SAFEGUARDED','GBP',NULL,'PlatformCo UK Ltd'),
 (18,'ACC-B07', 9,'OpenPayd corporate reserve','op-usd-5512','CORPORATE_RESERVE','USD',NULL,'PlatformCo Pte Ltd'),
 (19,'ACC-B08',10,'Thunes banking float',      'th-bnk-7701','CORPORATE_OPERATING','USD',NULL,'PlatformCo Pte Ltd'),
 (20,'ACC-P01', 6,'Noah payout prefund',       'nh-pay-2001','PREFUND','USD',NULL,'PlatformCo Pte Ltd'),
 (21,'ACC-P02', 8,'Tazapay payout prefund',    'tz-pay-4401','PREFUND','USD',NULL,'PlatformCo Pte Ltd'),
 (22,'ACC-P03',11,'Thunes payout prefund',     'th-pay-7801','PREFUND','USD',NULL,'PlatformCo Pte Ltd'),
 (23,'ACC-P04',12,'Mesta corridor prefund',    'ms-cor-9901','PREFUND','USD',NULL,'PlatformCo Pte Ltd'),
 (24,'ACC-E01',13,'Kraken trading balance',    'kr-trd-0001','CORPORATE_OPERATING','USD',NULL,'PlatformCo Pte Ltd'),
 (25,'ACC-E02',13,'Kraken USDC balance',       'kr-usdc-001','CORPORATE_OPERATING','USDC','ethereum','PlatformCo Pte Ltd'),
 (26,'ACC-E03',14,'Binance trading balance',   'bn-trd-0001','CORPORATE_OPERATING','USDT','tron','PlatformCo Pte Ltd');
SET IDENTITY_INSERT TreasuryAccount OFF;

-- Reserved is populated only for card collateral (authorisation holds).
-- Wasabi reserved is an ESTIMATE and its position is deliberately stale.
INSERT INTO PositionSnapshot (TreasuryAccountId,AssetCode,NetworkCode,Available,
                              PendingIn,PendingOut,Reserved,ReservedIsEstimate,
                              SourceType,AsOfUtc) VALUES
 (1, 'USDC','ethereum',14200000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T09:12:00'),
 (2, 'USDT','tron',     8100000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T09:12:00'),
 (3, 'USDC','polygon',  4300000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T09:12:00'),
 (4, 'USDC','ethereum', 5400000.00000000,120000,0,     0,0,'PROVIDER_API','2026-08-01T09:12:00'),
 (5, 'ETH', 'ethereum',      48.00000000,     0,0,     0,0,'CHAIN_RPC',   '2026-08-01T09:14:00'),
 (6, 'POL', 'polygon',    12000.00000000,     0,0,     0,0,'CHAIN_RPC',   '2026-08-01T09:14:00'), -- BELOW
 (7, 'USDC','ethereum',  250100.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T09:12:00'),
 (8, 'USDC','ethereum',  185000.00000000,     0,0,612000,0,'PROVIDER_API','2026-08-01T09:12:00'), -- BELOW
 (9, 'USD', NULL,       1050800.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:55:00'),
 (10,'XSGD',NULL,       5000000.00000000,     0,0,180000,0,'PROVIDER_API','2026-08-01T09:02:00'),
 (11,'USDC','ethereum',  420000.00000000,     0,0,180000,1,'PROVIDER_API','2026-07-31T18:40:00'), -- stale + est.
 (12,'USD', NULL,      10400000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:30:00'),
 (13,'USD', NULL,       9800000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:30:00'),
 (14,'SGD', NULL,       8500000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:20:00'),
 (15,'USD', NULL,        499000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:20:00'),
 (16,'EUR', NULL,       3200000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T07:50:00'),
 (17,'GBP', NULL,       1400000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T07:50:00'),
 (18,'USD', NULL,       4000000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T07:50:00'),
 (19,'USD', NULL,       1372000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:44:00'),
 (20,'USD', NULL,       1850000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:44:00'),
 (21,'USD', NULL,        960000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:44:00'),
 (22,'USD', NULL,        620000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T08:44:00'), -- NEAR
 (23,'USD', NULL,       1240000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T05:10:00'),
 (24,'USD', NULL,       2100000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T09:15:00'),
 (25,'USDC','ethereum', 1600000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T09:15:00'),
 (26,'USDT','tron',     1550000.00000000,     0,0,     0,0,'PROVIDER_API','2026-08-01T09:15:00');

-- Accounts 1,2,3,7 deliberately have NO threshold -> HealthStatus 'NONE'.
INSERT INTO AccountThreshold (TreasuryAccountId,AssetCode,CriticalMinimum,
                              MinimumAmount,TargetAmount,MaximumAmount) VALUES
 (4,'USDC', 800000,1500000, 4000000, 9000000),   (5,'ETH',      10,     25,      60,     120),
 (6,'POL',    8000,  15000,   40000,   80000),   (8,'USDC', 150000, 200000,  500000,  900000),
 (9,'USD',  200000, 400000,  900000, 2000000),   (10,'XSGD',1000000,2000000,5000000,10000000),
 (11,'USDC',150000, 250000,  600000, 1200000),   (12,'USD',3000000,5000000,12000000,25000000),
 (13,'USD',      0,      0,       0,       0),   (14,'SGD',      0,      0,       0,       0),
 (15,'USD',  200000, 400000,  800000, 2000000),  (16,'EUR',      0,      0,       0,       0),
 (17,'GBP',      0,      0,       0,       0),   (18,'USD',2000000,3000000, 4000000, 8000000),
 (19,'USD',  400000, 800000, 1500000, 3000000),  (20,'USD', 600000,1000000, 2000000, 4000000),
 (21,'USD',  300000, 500000, 1000000, 2000000),  (22,'USD', 400000, 600000, 1200000, 2400000),
 (23,'USD',  500000, 800000, 1500000, 3000000),  (24,'USD', 500000,1000000, 2500000, 5000000),
 (25,'USDC', 400000, 800000, 2000000, 4000000),  (26,'USDT',400000, 800000, 2000000, 4000000);

20.6 Rules — two deliberately gate-blocked

SET IDENTITY_INSERT LiquidityRule ON;
INSERT INTO LiquidityRule (LiquidityRuleId,RuleCode,Name,SourceAccountId,DestinationAccountId,
  AssetCode,NetworkCode,Strategy,MaxAmountPerRun,MaxDailyValue,MaxExecutionsPerDay,
  ApprovalThresholdBase,SourceMinRemaining,MaxDataAgeSeconds,IsEnabled,
  LastDryRunAtUtc,LastDryRunConfigHash,CreatedByUserId) VALUES
 (1,'rain-collateral-topup',  'Rain card collateral top-up',   4, 8,'USDC','ethereum','TO_TARGET',500000,1500000,4,100000,1500000,900,1,'2026-07-28T10:00:00','a1f4c9',4),
 (2,'gas-eth-topup',          'Ethereum gas tank top-up',      4, 5,'ETH', 'ethereum','TO_TARGET',    20,     60,3,     0,1500000,900,1,'2026-07-20T14:20:00','b2e7d1',4),
 (3,'gas-pol-topup',          'Polygon gas tank top-up',       4, 6,'POL', 'polygon', 'TO_TARGET', 30000,  80000,3,     0,1500000,900,1,'2026-07-20T14:25:00','c3a8e2',4),
 (4,'wasabi-collateral-topup','Wasabi card collateral top-up', 4,11,'USDC','ethereum','TO_TARGET',400000,1000000,3,100000,1500000,900,1,'2026-06-30T09:10:00','d4b9f3',4),
 (5,'mesta-prefund-topup',    'Mesta corridor prefund top-up',12,23,'USD', NULL,      'TO_TARGET',500000,1000000,2,100000,5000000,900,1,'2026-07-15T16:40:00','e5c0a4',4),
 (6,'fee-sweep-monthly',      'Sweep fee wallet to operating', 7, 4,'USDC','ethereum','FULL',     500000, 500000,1,     0,      0,900,0,'2026-07-01T08:00:00','f6d1b5',4);
SET IDENTITY_INSERT LiquidityRule OFF;

-- Executions INCLUDING skips — this is what makes incidents explicable.
INSERT INTO RuleExecution (LiquidityRuleId,TriggeredAtUtc,Outcome,Reason,ComputedAmount,MovementId,DurationMs) VALUES
 (1,'2026-08-01T08:02:00','CREATED',      'Destination below minimum',315000.00,5,412),
 (1,'2026-08-01T08:07:00','NOT_TRIGGERED','A movement is already pending for this account',NULL,NULL,88),
 (4,'2026-08-01T08:05:00','GATE_BLOCKED', 'Feed missing for 2026-07-31.',NULL,NULL,41),
 (4,'2026-08-01T08:10:00','GATE_BLOCKED', 'Feed missing for 2026-07-31.',NULL,NULL,39),
 (5,'2026-08-01T08:05:00','GATE_BLOCKED', 'Feed arrived late. Last certified 2026-07-30.',NULL,NULL,44),
 (3,'2026-08-01T08:05:00','SOURCE_FLOOR', 'Would breach the source minimum of 1,500,000 USDC.',NULL,NULL,96),
 (2,'2026-07-31T11:05:00','CREATED',      'Gas below minimum',18.00,2,210);

20.7 Movements — including the self-approval trap

SET IDENTITY_INSERT Movement ON;
INSERT INTO Movement (MovementId,MovementCode,MovementType,Status,SourceAccountId,
  DestinationAccountId,AssetCode,NetworkCode,Amount,FeeAmount,FeeAssetCode,AmountBase,
  RequiredApprovals,GateStatusAtCreation,IdempotencyKey,InitiatedByUserId,LiquidityRuleId,
  Reference,ExternalRef,FailureReason,CreatedAtUtc,SubmittedAtUtc,SettledAtUtc) VALUES
 (1,'MV-4468','TOP_UP','FAILED',           4,23,'USD', NULL,     400000.00,  NULL,NULL,400000.00,2,'BLOCKED','seed-mv-4468',3,   NULL,'Mesta corridor prefund',NULL,
   'Provider rejected: daily funding cap reached','2026-07-31T16:20:00','2026-07-31T16:41:00',NULL),
 (2,'MV-4469','TOP_UP','SETTLED',          4, 5,'ETH','ethereum',    18.00,0.0021,'ETH', 63000.00,1,'CLEAN','seed-mv-4469',NULL,2,'Gas top-up',
   '0x4a11c7d2e0f9138ba7710c4429e1d5a0b8c3f6e2',NULL,'2026-07-31T11:05:00','2026-07-31T11:06:00','2026-07-31T11:09:00'),
 (3,'MV-4470','SWEEP','SETTLED',           7, 4,'USDC','ethereum',288000.00,12.40,'USDC',288000.00,1,'CLEAN','seed-mv-4470',NULL,6,'Monthly fee sweep',
   '0x77b2ea41c093d5f8210ba64f19cc7d3e05a91b44',NULL,'2026-07-31T08:00:00','2026-07-31T08:01:00','2026-07-31T08:04:00'),
 (4,'MV-4471','TOP_UP','SETTLED',          4, 8,'USDC','ethereum',250000.00,11.80,'USDC',250000.00,2,'CLEAN','seed-mv-4471',NULL,1,'Rain collateral top-up',
   '0x9c1a44b8e7203df51cc0a9b6741e3d0925fa8c17',NULL,'2026-07-30T22:14:00','2026-07-30T22:19:00','2026-07-30T22:23:00'),
 (5,'MV-4472','TOP_UP','PENDING_APPROVAL', 4, 8,'USDC','ethereum',315000.00,11.80,'USDC',315000.00,2,'CLEAN','seed-mv-4472',NULL,1,'Rain collateral top-up',
   NULL,NULL,'2026-08-01T08:02:00',NULL,NULL),
 -- initiated by Anita K (UserId 1): the self-approval trap
 (6,'MV-4473','TOP_UP','PENDING_APPROVAL',12,22,'USD',NULL,     780000.00,  NULL,NULL,780000.00,2,'CLEAN','seed-mv-4473',1,  NULL,'Thunes prefund ahead of month-end payouts',
   NULL,NULL,'2026-08-01T09:05:00',NULL,NULL),
 -- 3 approvals because it touches a connection that is not verified
 (7,'MV-4475','TOP_UP','PENDING_APPROVAL',12,23,'USD',NULL,     400000.00,  NULL,NULL,400000.00,3,'BLOCKED','seed-mv-4475',3,NULL,'Mesta corridor prefund, manual after rule block',
   NULL,NULL,'2026-08-01T09:31:00',NULL,NULL);
SET IDENTITY_INSERT Movement OFF;

-- MV-4472 already has ONE of its two approvals -> the queue shows "1 / 2".
INSERT INTO MovementApproval (MovementId,ApproverUserId,Decision,StepUpVerified,DecidedAtUtc) VALUES
 (4,1,'APPROVED',1,'2026-07-30T22:17:00'),
 (4,2,'APPROVED',1,'2026-07-30T22:18:00'),
 (5,2,'APPROVED',1,'2026-08-01T08:40:00'),
 (7,1,'APPROVED',1,'2026-08-01T09:40:00');

INSERT INTO MovementEvent (MovementId,FromStatus,ToStatus,ActorUserId,ActorType,Reason,OccurredAtUtc) VALUES
 (5,NULL,              'PENDING_APPROVAL',NULL,'RULE',    'Created by rule rain-collateral-topup','2026-08-01T08:02:00'),
 (5,'PENDING_APPROVAL','PENDING_APPROVAL',2,   'USER',    'Approved by Dev P (1 of 2)','2026-08-01T08:40:00'),
 (6,NULL,              'PENDING_APPROVAL',1,   'USER',    'Created by Anita K','2026-08-01T09:05:00'),
 (7,NULL,              'PENDING_APPROVAL',3,   'USER',    'Created by Ravi M — connection not verified, 3 approvals required','2026-08-01T09:31:00'),
 (1,NULL,              'PENDING_APPROVAL',3,   'USER',    'Created by Ravi M','2026-07-31T16:20:00'),
 (1,'PENDING_APPROVAL','SUBMITTED',       NULL,'SYSTEM',  'Approvals complete','2026-07-31T16:41:00'),
 (1,'SUBMITTED',       'FAILED',          NULL,'PROVIDER','Daily funding cap reached','2026-07-31T16:44:00');

20.8 Coverage, alerts and settings

-- Segregated = accounts 1,2,3,13,14,16,17:
--   14,200,000 + 8,100,000 + 4,300,000 + 9,800,000
--   + 8,500,000 SGD @0.74 (=6,290,000) + 3,200,000 EUR @1.08 (=3,456,000)
--   + 1,400,000 GBP @1.27 (=1,778,000)                    = 47,924,000
-- Liabilities 47,262,000 (wallets 26,270,000 + banks 20,992,000) -> 101.4007% -> AMBER
-- Top-up to restore the 102% buffer: 283,240
INSERT INTO CoverageSnapshot (LiabilitiesBase,LiabilitySourceAt,SegregatedBase,
                              RatioPct,Status,ShortfallBase,ComputedAtUtc) VALUES
 (47262000.00,'2026-08-01T02:15:00',47924000.00,101.4007,'INSIDE_BUFFER',NULL,'2026-08-01T09:00:00'),
 (47240000.00,'2026-08-01T01:15:00',47924000.00,101.4479,'INSIDE_BUFFER',NULL,'2026-08-01T08:00:00'),
 (47180000.00,'2026-08-01T00:15:00',47924000.00,101.5769,'INSIDE_BUFFER',NULL,'2026-08-01T07:00:00');

INSERT INTO AlertInstance (AlertType,Severity,DedupKey,Message,TargetRoute,SourceType,
                           RelatedEntityId,Status,RaisedAtUtc) VALUES
 ('THRESHOLD_BREACH','PAGE',    'threshold:8','Rain card collateral is below its minimum — 185,000 of 200,000 USDC','/treasury/accounts/8','THRESHOLD',8,'OPEN','2026-08-01T08:02:00'),
 ('THRESHOLD_BREACH','PAGE',    'threshold:6','Polygon gas tank is below its minimum — 12,000 of 15,000 POL','/treasury/accounts/6','THRESHOLD',6,'OPEN','2026-08-01T06:41:00'),
 ('MOVEMENT_FAILED', 'CRITICAL','movement:1', 'MV-4468 failed — provider rejected, daily funding cap reached','/treasury/movements/MV-4468','MOVEMENT',1,'OPEN','2026-07-31T16:44:00'),
 ('RECON_FEED',      'WARNING', 'recon:4',    'Wasabi · Cards — feed missing for 2026-07-31, automation blocked','/treasury/connections/4','RECON',4,'OPEN','2026-08-01T02:15:00'),
 ('RECON_BREAKS',    'WARNING', 'recon:7',    'Tazapay · Banks — unresolved breaks of 410,000 exceed the 50,000 tolerance','/treasury/connections/7','RECON',7,'OPEN','2026-08-01T02:15:00'),
 ('RECON_FEED',      'WARNING', 'recon:12',   'Mesta · Payments — feed late, last certified 2026-07-30','/treasury/connections/12','RECON',12,'OPEN','2026-08-01T03:22:00'),
 ('COVERAGE_BUFFER', 'WARNING', 'coverage',   'Client coverage is 101.40%, inside the 102% operating buffer','/treasury/coverage',NULL,'OPEN','2026-08-01T09:00:00'),
 ('APPROVALS_WAITING','INFO',   'approvals',  '3 movements are awaiting approval','/treasury/approvals',NULL,'OPEN','2026-08-01T09:31:00');

INSERT INTO SystemSetting (SettingKey,SettingValue) VALUES
 ('automation.killSwitch',        'false'),
 ('approval.thresholdBase',       '100000'),
 ('approval.stepUpThresholdBase', '500000'),
 ('coverage.bufferPct',           '102'),
 ('gate.defaultToleranceBase',    '50000'),
 ('position.stalenessWarnSeconds','900'),
 ('baseCurrency',                 'USD');

20.9 Running order

  1. Reference: Module, Provider, Connection, Asset, Network, FxRate
  2. Users — map to your existing user table, or insert five test users, one per role
  3. ConnectionSyncState, then BalanceCheck
  4. TreasuryAccount, then PositionSnapshot, then AccountThreshold
  5. Destination, LiquidityRule
  6. Movement, MovementApproval, MovementEvent, RuleExecution
  7. CoverageSnapshot, AlertInstance, SystemSetting, AuditLog

20.10 Verification queries

Run these after seeding. Values must match §20.2 exactly.

PRINT '--- Expected: 86339500.00 ---';
SELECT CAST(SUM(TotalBase) AS DECIMAL(18,2)) AS TotalHoldingsUsd FROM vw_PositionBase;

PRINT '--- Expected: exactly 2 rows (Polygon gas tank, Rain card collateral) ---';
SELECT AccountName, AssetCode, Available, MinimumAmount, HealthStatus
FROM vw_PositionBase WHERE HealthStatus = 'BELOW';

PRINT '--- Expected: 3 BLOCKED (wasabi-cards, tazapay-banks, mesta-payments) ---';
SELECT ConnectionCode, ProviderName, ModuleName, GateStatus, GateReason
FROM vw_ConnectionGate WHERE GateStatus = 'BLOCKED';

PRINT '--- Expected: 3 pending; MV-4472 shows 1 of 2 ---';
SELECT MovementCode, Amount, AssetCode, RequiredApprovals,
       (SELECT COUNT(*) FROM MovementApproval a
        WHERE a.MovementId = m.MovementId AND a.Decision='APPROVED') AS ApprovalsRecorded
FROM Movement m WHERE Status = 'PENDING_APPROVAL';

PRINT '--- Expected: 32543500 / 6281000 / 37595000 / 4670000 / 5250000 ---';
SELECT ModuleName, CAST(SUM(TotalBase) AS DECIMAL(18,2)) AS TotalUsd, COUNT(*) AS Accounts
FROM vw_PositionBase GROUP BY ModuleName ORDER BY TotalUsd DESC;

PRINT '--- Expected: 47262000.00 / 47924000.00 / 101.4007 / INSIDE_BUFFER ---';
SELECT TOP 1 LiabilitiesBase, SegregatedBase, RatioPct, Status
FROM CoverageSnapshot ORDER BY ComputedAtUtc DESC;

PRINT '--- Expected: 2 rules GATE_BLOCKED (wasabi, mesta) ---';
SELECT r.RuleCode, g.GateStatus, g.GateReason
FROM LiquidityRule r
JOIN TreasuryAccount d ON d.TreasuryAccountId = r.DestinationAccountId
JOIN vw_ConnectionGate g ON g.ConnectionId = d.ConnectionId
WHERE r.IsEnabled = 1 AND g.GateStatus = 'BLOCKED';

Appendix A — Prototype to production map

Prototype behaviour Production requirement
Hard-coded 26 accounts TreasuryAccount seeded per environment; onboarding via S11
Balances in a JS object PositionSnapshot, appended by TreasuryPositionSync from the module contracts
Gate computed in the browser GateService in the domain layer, mirrored by vw_ConnectionGate
Approvals tracked in an array MovementApproval with a unique constraint per approver
Self-approval check in JS ApprovalService returns 403 and writes APPROVAL_BLOCKED to AuditLog
Instant settlement SUBMITTED → CONFIRMING → SETTLED driven by TreasuryMovementStatusPoll
Coverage computed locally Denominator read from the module client-liabilities endpoints; unavailable when any module stops reporting
Kill switch in memory SystemSetting, checked as guard 1 of the rule engine

Appendix B — Reading order for a new engineer

Half a day, and you will understand the system.

# Step Time
1 Read §1, §3.3 and §5 — the four questions, what verification does and does not give you, the four-layer model 25 min
2 Scroll the screenshots in §7 without reading the requirements tables 15 min
3 Read §6 — the gate, segregation, quorum, the six guards 25 min
4 Run §18 then §20 against a local database 20 min
5 Run the §20.10 verification queries and reconcile them to the S1 screenshot by hand 30 min
6 Compute the coverage ratio in §20.2 yourself and confirm which colour band it lands in 15 min
7 Read §13.1 and §15 — the invariants, and how each one gets broken 30 min
8 Read §7 for the screen on your first ticket 20 min

The single most expensive mistake on this module is guessing what a number means. Available and total differ by hundreds of thousands of dollars, and picking the wrong one produces a screen that looks correct and is wrong. When in doubt, check §12 or ask.


End of specification. If the code and this document disagree, that is a defect in one of them — raise a change request rather than letting the two drift.