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 thecustody-controlsmodule 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-controlsmodule and applied to two money-movement consumers — custody now, the fiat payout maker/checker later. A consumer enters ONLY through the publishedIntegration\Contracts\ApprovalQuorumPlatformseam and registers a per-consumerForwardsApprovedRequestcallback (dispatching its own forward on quorum), so the dependency is one-waypayments → custody-controls— the same inversion the repo uses for the async-request platform and compliance'sCaseIntake. The acyclic module graph is preserved. - Posture/lock storage split (D1 / §4.3). The tenant-tuned
ApprovalPolicystays tenant-scoped RLS; the officer-/system-writable tighten/lock posture lives in a separate central, no-FK, non-RLS sibling (à laaccount_states), read handler-side and tenant-scoped by the resolver — so theweb/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,≥ $1kbands 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-raisedmin_eyes_floorcan 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 (PayoutForwarder → PayoutSubmitter) 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 approved→forwarded 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
paymentsREADME.
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 (CustodyTransactionForwarder → CustodyTransactionSubmitter) 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 approved→forwarded stranding gap).
The go-live routing (Custody → Utila across all six jurisdictions, conduit override-only) is a
separate decision, ADR D98 — live-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.