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
Connection= one provider serving one module. It is the unit of verification: freshness, sync health and variance are all evaluated per connection. Never called "integration" in code.TreasuryAccount= a real account at a provider. Never called "account" alone — the platform already has customer accounts.Movement= a transfer between two accounts we own. Never called "payment" or "transaction".- Every new table is prefixed
Treasury*or lives in atreasuryschema, so it is obvious what belongs to this feature. - Bold rules are non-negotiable invariants. They are enforced in the domain layer, not the UI.
- Amounts:
DECIMAL(28,8)in SQL,decimalin .NET, string in JSON. Neverfloat, never JSnumber.
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-25MUST 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_ADMINcannot 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 usestotal. 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
RequiredApprovalsandAmountBaseat 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-26MUST 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
RuleExecutionrow, 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
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 TreasuryOverviewPage → KpiCard ×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
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 ModulesPage → ModuleCard, AccountTable
Tables
vw_PositionBase R · vw_ConnectionGate R
Group client-side by
moduleCode. Do not issue five separate account queries.
S3 · Module detail
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 ModuleDetailPage → KpiCard ×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
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 AccountsPage → FilterBar, AccountTable, PositionCell, ClassificationPill, HealthPill, GatePill
Tables
vw_PositionBase R · vw_ConnectionGate R
PositionCellexists so available-vs-total cannot be got wrong by accident. Use it everywhere a balance appears.
S5 · Account detail
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 AccountDetailPage → KpiCard ×3, GateBanner, DefinitionList, ThresholdBar, ThresholdModal, MovementList
Tables
TreasuryAccount R · AccountThreshold R/W · vw_CurrentPosition R · vw_ConnectionGate R · Movement R · AuditLog W
PATCHrequiresIf-Match. On mismatch return 409 and tell the user someone else changed the account.
S6 · New movement
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 NewMovementModal → AccountSelect ×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
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 PreviewPanel → BlockerList
Tables
R TreasuryAccount (classification) · AccountThreshold · vw_CurrentPosition
Server-side enforcement is the control. The disabled button is a convenience.
S7 · Movements
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 MovementsPage → FilterBar, 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
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 MovementDetailPage → DefinitionList, 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
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 ApprovalsPage → ApprovalRow, 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
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) → ARMED → OFF. |
| 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 RulesPage → RuleRow, RuleStatePill, DryRunModal, KillSwitchToggle, ConfirmDialog
Tables
R LiquidityRule · TreasuryAccount · RuleExecution · vw_ConnectionGate · vw_CurrentPosition · SystemSetting · W LiquidityRule · SystemSetting · AuditLog
CK_LiquidityRule_DryRunenforces AC-10.4 in the database as well as the service.
S10b · Dry run — blocked rule
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 DryRunModal → GateBanner, DefinitionList
Tables
R LiquidityRule · vw_CurrentPosition · vw_ConnectionGate · RuleExecution · W LiquidityRule (dry-run stamp)
Dry run never writes a
Movementand never moves money.
S11 · Providers
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 ConnectionsPage → ConnectionTable, 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
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 ConnectionDetailPage → KpiCard ×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
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 ForecastPage → AccountSelect, KpiCard ×3, ForecastChart, ShortfallBanner, InputsPanel
Tables
ForecastPoint R · AccountThreshold R · vw_CurrentPosition R · Movement R (history)
ForecastPointis deleted and reinserted per run, not merged. The nightly job runs at 17:00 UTC.
S14 · FX exposure
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 FxExposurePage → ExposureTable, 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
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 CoveragePage → KpiCard ×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
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 CoveragePage → KpiCard (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
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 ExceptionsPage → AlertRow, SeverityPill, AckModal
Tables
AlertInstance R/W · AuditLog W
UX_AlertInstance_OpenDedupis a filtered unique index onDedupKeywhere status is not resolved.
S17 · Audit log
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 AuditPage → FilterBar, 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
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 BalanceVerificationPage → KpiCard ×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/treasury — or 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": []
}
blockersdisable Submit.warningsdo not, but may raiserequiredApprovals. 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
AuditLogrow with actionAPPROVAL_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 errorCode — the 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.
UQ_MovementApproval (MovementId, ApproverUserId)stops the same person counting twice.- The transition to
APPROVEDre-reads the approval count inside the transaction and takes an update lock on theMovementrow. RowVeronMovementcatches the lost update; return 409CONCURRENCY_CONFLICTand let the client refetch.
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
- Two solutions show different patterns for the same concern and you cannot tell which is current
- A change would modify an existing table, endpoint contract or shared component
- A module solution has no working provider method for something treasury needs
- Existing money columns cannot hold the precision this feature requires
- An existing table already covers something in §5.5
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 · AuditLog — and will show as a variance until an admin accepts it |
A refused self-approval writes to
AuditLogeven 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
- Reference:
Module,Provider,Connection,Asset,Network,FxRate - Users — map to your existing user table, or insert five test users, one per role
ConnectionSyncState, thenBalanceCheckTreasuryAccount, thenPositionSnapshot, thenAccountThresholdDestination,LiquidityRuleMovement,MovementApproval,MovementEvent,RuleExecutionCoverageSnapshot,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.