Skip to main content

D97 — Custody approval quorum: one general N-of-M engine, two consumers, via a published seam (RESOLVED)

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The full design + the four locked sub-decisions live in docs/tracking/multi-provider/24-custody-approval-quorum.md (§4.1a eyes floor — ≥1 sanity, band-dependent, §4.3 storage split, §4.5 the published seam, §7 D1–D4 + Q1–Q5); the current specifics are in the custody-controls module README. This ADR is the discoverable register entry, not a restatement.

Decision. Custody money-movement is gated, before it reaches any provider, by a general N-of-M approval-quorum engine — an amount-tiered, in-app N-eyes consensus among the tenant's own eligible people — built as a flagship of the platform's security posture. Four load-bearing choices (design doc §7 D1–D4) make it a peer of the platform-engine ADRs (the async-request platform D83, the task-runner D93) rather than a custody-only feature:

  • One general engine, two consumers, no module cycle (D4 / §4.5). The engine is built once in a new custody-controls module and applied to two money-movement consumers — custody now, the fiat payout maker/checker later. A consumer enters ONLY through the published Integration\Contracts\ApprovalQuorumPlatform seam and registers a per-consumer ForwardsApprovedRequest callback (dispatching its own forward on quorum), so the dependency is one-way payments → custody-controls — the same inversion the repo uses for the async-request platform and compliance's CaseIntake. The acyclic module graph is preserved.
  • Posture/lock storage split (D1 / §4.3). The tenant-tuned ApprovalPolicy stays tenant-scoped RLS; the officer-/system-writable tighten/lock posture lives in a separate central, no-FK, non-RLS sibling (à la account_states), read handler-side and tenant-scoped by the resolver — so the web/system path can write while the resolver reads inside a tenant request. (Built in the posture increment.)
  • A band-dependent eyes floor (§4.1a). Effective required eyes = max(band N, min_eyes_floor, MINIMUM_EYES=1). The originally-planned hard 2-eyes platform floor was removed (design decision 2026-07-11): forcing a low-value movement (e.g. a $50 payment) through a multi-approver quorum was the wrong default, so the amount-tiered bands are the sole determinant of N and the hardcoded floor is now a ≥1 sanity floor only (the gate is never a no-op; a 0-eye band clamps up to 1). Dual control is therefore band-dependent — a sub-$1k band needs 1 eye, ≥ $1k bands need ≥2 — and for a ≥2-eye band the initiator can never be the sole eye. The strong promise holds for the ≥2-eye bands; increment B's posture-raised min_eyes_floor can re-tighten a tenant/account.
  • A dedicated module home (D2). The feature spans accounts, compliance, and backoffice; a small dedicated module keeps that seam explicit instead of bloating the accounts wallet/quorum module. It owns one client-guard permission enum (a three-way initiate/approve/manage-policy SoD); the operator override case stays central on BackofficePermission.

Ship-state (honest). Increment A — the engine core, the resolver (FX-at-initiate valuation with staleness fail-closed + hysteresis), the maker/approver flow, the timeout sweep, and the published seam — is built, but the quorum forward is a stub: nothing routes real custody money yet. The adaptive posture + LOCK (B) is built (B1 the central posture table + resolver enrichment, B2 the tighten-only system command + decoupled compliance case-open, B3 the tenant-admin policy-edit endpoint + the weaken-a-locked-policy durable rejection) — but the tighten command is dormant in production: no risk-signal source dispatches it yet (Decision 3 wires the concrete sources in a later increment), so adaptive posture only ever changes via the tenant-admin edit today, it does not auto-tighten. The backoffice officer surface (C) is built. Only the real custody forward (D) is a later increment (since SHIPPED — see Amendment D97.2 below). The policy-in-force (the N) is recorded on the request row + a domain event, not yet on the tamper-evident hash chain (deferred with the real forward).

Amendment D97.1 (§8-E — the payout consumer, BUILT). The fiat payout maker/checker now rides this engine (the second live consumer — "one engine, two consumers" realised). In payments: InitiatePayoutCommand opens a request (ApprovalConsumer::Payout), the checker VotePayoutCommand (approve/reject) replaces the retired single-checker ApprovePayoutCommand, and on quorum a payments forwarder (PayoutForwarderPayoutSubmitter) runs the provider submit as SYSTEM. The payout links to its request via payouts.approval_request_uuid (a pre-product cut-over, not a backfill); it shares the tenant's amount-tiered policy (the resolver ignores consumer for band selection — a deliberate scope call); self-approval stays a 403 DurableRejection. The edge is one-way payments → custody-controls via the published Integration seam + Enums\ApprovalConsumer only (ModuleBoundaryTest).

Because E is the first REAL forward (custody's own is still a stub), the amendment also adds: (FIX 4) provider-failure handling forks on the fault type — a PERMANENT rejection (422) stamps the payout terminal rejected (not a revert to re-approvable), needing a fresh intent + fresh quorum; a TRANSIENT fault (ProviderUnavailableException) is NOT made terminal but left submitting for the reclaim sweep to re-drive (so a money-moved-then-timed-out payout is never stranded terminal); (FIX 1) a reclaim — a new pure-read seam method ApprovalQuorumPlatform::statusFor() drives both a reconcile-on-read (query handlers project a terminal state for a dead-request pending_approval payout — never for a submitting one, which the sweep re-drives) and a scheduled per-tenant payments:reconcile-payouts sweep that idempotently re-drives a stranded submit under the same idempotency key (no double-pay; a FIRST-time reclaim disbursement is freeze-gated) and marks a dead-request payout terminal — closing the approvedforwarded stranding gap for the payout (custody's real forward, increment D, will reuse the pattern); and (FIX 2) approval progress on the payout resource (required_eyes/approve_count + the viewer flags, distinct from the co-sign required_approvals, non-null only while under review). See 24-custody-approval-quorum.md §8-E

  • the payments README.

Amendment D97.2 (increment D — the real custody forward, SHIPPED). The custody consumer's own forward is now BUILT ("one engine, two consumers" both running REAL forwards). In custody-controls, on quorum the custody forwarder (CustodyTransactionForwarderCustodyTransactionSubmitter) runs the provider submit as SYSTEM over the neutral ManagesCustodyTransactions seam (increment 2.2f-2), and a scheduled per-tenant custody-controls:reconcile-custody-transactions sweep idempotently re-drives a request stranded approved after quorum (the FIX-1 reclaim pattern amendment D97.1 introduced for the payout, now reused for custody — closing the approvedforwarded stranding gap). The go-live routing (Custody → Utila across all six jurisdictions, conduit override-only) is a separate decision, ADR D98live-in-code + deploy-gated, pre-product (not serving real production custody money yet). So the "quorum forward is a stub / the real custody forward (D) is a later increment / the real forward will call in increment D" language above is now historical. The policy-in-force on the tamper-evident hash chain remains the one open deferral. See 20-custody.md §5a, ADR D98, and the custody-controls README.

This decision builds on the ceremony-less custody contract (D96 — the neutral ManagesCustodyTransactions seam the real forward calls, since increment D), the router substrate (D94), and the Utila custody go-live grant (D98); it does not restate them.


← Decision log index