Skip to main content

D121 — Bridge coverage programme (B1–B10)

Status: Accepted. Seven capability classes shipped (waves 0–3); B4/B5/B7 excluded with evidence. This record is the as-built account of the whole programme.

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Design of record: docs/superpowers/specs/2026-09-03-bridge-rails-adapter-design.md §12 (classification + sequencing) and docs/superpowers/plans/2026-09-05-bridge-coverage-programme-b1-b10.md (the implementation plan, incl. the scope table this record mirrors, and its security rulings §). Follows D118, D119 and D120.

Context

D118–D120 took Bridge from a sealed, ungranted adapter to the sole granted primary for Onboarding, FiatRails, Conversion and Custody. What Bridge offers is wider than what we consume: the flow-alignment page lists the gaps. This programme closes them deliberately — building what earns its place, and recording, with evidence, what does not.

User directive: "anything and everything Bridge offers, we offer" — the design mandate behind this programme (docs/tracking/multi-provider/00-provider-router.md §7). One-PR ruling: the user directed this programme to ship as ONE PR off feat/bridge-coverage-programme (branched from main at 3eff210d), overriding the design spec's default of one PR per B item — a stacked Conduit code-removal PR (D122) follows separately. This app is not in production — no existing tenant, no in-flight money-movement object, no live customer to protect; every entitlement/marker/grant this programme adds ships default-OFF or ungranted, so nothing here changes behaviour for a real tenant on merge (app-not-in-production-no-inflight-data, the same posture D120's Go-live runbook relied on).

Every Bridge fact in this record is sourced from the SDK spec snapshot (packages/stables/bridge-sdk/spec/openapi.json) or the dated wiki/engineering/bridge/ digest, never from memory. Nothing here has been exercised against a live Bridge sandbox (CLAUDE.md rule 4: a pipeline/runbook counts as working only once actually run — nothing in this programme has been): where a claim is unverifiable outside a real Bridge sandbox/production delivery, this record says so rather than claiming it works.

Decision

Seven capability classes are BUILT as segregated rails capabilities; three are NOT.

B1 — Liquidation addresses + drains — BUILD

ManagesLiquidationAddresses on FiatRails; destination reuses the neutral Data\TransferEndpoint; new Data\LiquidationAddress/Drain + Enums\DrainStatus. accounts owns the RLS liquidation_addresses mirror, funding writes drains. Limitations recorded: no deactivate endpoint exists, the destination is immutable beyond its currency (and the update currency set is narrower than the create's), fiat-destination drains are poll-only.

As built (B1a, wave 1): accounts owns RLS liquidation_addresses + the CENTRAL provider_liquidation_addresses tenant map (the provider_customers shape); creation is maker-checker behind the engine's FOURTH consumer ApprovalConsumer::LiquidationAddress (fixed-eyes, 2 eyes) — the provider is called only by the approved forward — with the destination nominated by wallet uuid only (a bank/registered-address nomination needs a payments Integration seam that does not exist yet), one live address per (chain, currency), and revoked_at as a LOCAL retirement whose residual risk is that the address stays live at Bridge. The client surface rides the default-OFF accounts.crypto-deposit-addresses entitlement. Nothing here has been exercised against a live Bridge sandbox.

(Wave 1 completes: the mirror, the client API, the drain webhook path, the markers and the default-OFF feature.)

As built (drains, B1b). Modules\Funding\Webhooks\Handlers\BridgeLiquidationAddressDrainHandler rides bridge_wallet.activity.{created,updated} (payment_route.type === 'drain'), a SECOND source-scoped registrant alongside accounts' BridgeWalletActivityHandler on the same key; transactions gains nullable drain_id/liquidation_address_uuid (partial-unique (tenant_id, drain_id)). Security rulings enforced: unknown-LA parks + alerts, revoked-LA quarantines + alerts, terminal states (missing_return_policy/error/refund_failed/undeliverable) write + alert, never retry. Modules\Webhooks\TenantResolution\BridgeTenantResolver gains a provider_liquidation_addresses-map arm ahead of the provider_customers fallback. Recorded gap: bridge_wallet.activity carries no drain state field — status is derived from the wallet-ledger type, so error/refund_failed/ missing_return_policy are not reachable from this event alone (see the funding README's drains section).

Pre-flight follow-up (G-code). Because a liquidation address has no deactivate endpoint at Bridge (the limitation recorded above), a live, non-revoked liquidation_addresses row on a provider about to be demoted by a grant flip — or in a jurisdiction the plan touches but declares no FiatRails primary for at all (the sync prunes that jurisdiction's FiatRails routing entirely) — cannot be un-stranded after the fact; an 8th grant pre-flight check, accounts.active_liquidation_addresses, refuses features:sync's provider-grant phase while such a row exists, alongside the seven checks already registered (D120's six + D121 §B9's rails.return_policy_coverage).

B2 — Travel rule — BUILD

SubmitsTravelRule::submitTravelRuleData(ExternalRef, Data\TravelRuleData, string $key) on the existing CapabilityGroup::TravelRule. One flow: the sender-information handler branches on whether the ROW'S OWN provider (never the routed key — the D120 row-provider rule) supports the capability. No new client contract, no new table. The TravelRule grant to bridge landed as its own commit — a human-approval surface — and is inert until features:sync.

As built (B2, wave 2). SubmitSenderInformationHandler resolves ManagesTransactions for the transaction row's own provider via ResolvesProviderAdapter and checks that SAME instance for instanceof SubmitsTravelRule; a hit folds the existing sender-information payload into an originator-only TravelRuleData and files it via submitTravelRuleData against the row's existing ref, a miss keeps the legacy submitSenderInformation call unchanged. FE contract delta: a new optional wallet_attested_ownership_at field, required only when self_custody_attestation: true (the legacy payload carries no attestation timestamp). rails.sensitive_fields gained name / identifying_information / place_of_birth / legal_entity_identifier for the travel-rule wire body.

(Wave 2 completes: the handler branch and its tests.)

B3 — Exchange rates — BUILD

QuotesExchangeRates on Conversion; treasury wraps it behind the existing short-TTL rate cache. Limitation: seven codes, no USDC/EURC — a supplement, never the default rate source; quotes are indicative, not executable.

As built (wave 1): Modules\Treasury\Support\RailsExchangeRateProvider is wired as the ONE CachingExchangeRateProvider's PRIMARY (Providers\TreasuryServiceProvider), with the pre-existing named-registry primary demoted to ITS fallback — never a second, nested decorator, so the <=20s freshness guarantee is unchanged. ProviderOperationNotSupportedException/ProviderValidationException (Bridge's seven-code coverage gap) fall through to the existing provider; a transport failure still surfaces as the translated RailsException it already is. rates_provisional now sees through the supplement to whichever source it falls back to (Contracts\WrapsFallbackSource), so the flag keeps grading the source actually pricing most pairs.

B4 — Static memos — DO NOT BUILD

Evidence. Bridge's own docs digest records static/tag-routed memo deposits (a shared account keyed by a per-customer memo/tag) as a deprecated surface, USD wire/ACH only, with a "memo-or-lose-funds" failure mode on the client side (wiki/engineering/bridge/ virtual-accounts-liquidation-and-wallets.md §7, fetched 2026-09-03) — the digest's own verdict at authoring time was "not a candidate for new build." Ordinary virtual accounts (already SERVED, D119) supersede it functionally for every rail Bridge still actively supports. No code was built for B4 — zero StaticMemo-shaped interface, DTO, or migration exists on this branch.

B5 — Cards — EXCLUDED (user ruling, 2026-09-05)

Evidence. The in-repo CardsResource/packages/stables/bridge-sdk's cards tag surfaces Bridge's legacy card-issuing API. The live path Bridge itself documents is Stripe Issuing behind Bridge (wiki/engineering/bridge/cards.md) — a materially different integration: a second vendor (Stripe), a second webhook ingress (Stripe ↔ Bridge, not just Bridge ↔ us), a sub-2-second real-time authorization SLA the rest of this seam has no precedent for, and an unresolved card-fraud liability question between Stables/Bridge/Stripe. The user ruled this out of the coverage programme's scope on 2026-09-05 (docs/superpowers/plans/2026-09-05-bridge-coverage-programme-b1-b10.md, scope table) — own programme, own ADR, once scoped. Prerequisites recorded for that future programme: (1) a decision on Stripe as a second, directly-integrated vendor (not merely "behind Bridge"); (2) a second inbound-webhook ingress + signature scheme (Stripe's, distinct from BridgeSignatureValidator); (3) an architecture that can meet a ~2-second real-time-authorization response SLA (this app's CQRS bus + RLS + provider-call path has never been load-tested for that latency budget); (4) a card-fraud liability-allocation decision between Stables, Bridge, and Stripe before any cardholder data model is built. No code was built for B5CapabilityGroup::Cards remains interface-less, zero IssuesCards-shaped contract exists.

B6 — Interactive bank linking (Plaid) — BUILD

LinksExternalAccountsInteractively on FiatRails, home payments, no new table; the same permission + markers as registering a whitelist recipient by hand. Limitation: the exchange returns only an acknowledgement and the account materialises asynchronously, so the completion returns no recipient — the caller reconciles from the recipient list.

(Wave 2 completes: the command, the endpoint and its authorization.)

As built (B6). The security ruling superseded the "no new table" scope line above: a new RLS bank_link_sessions table (link_token ENCRYPTED + DLP Secret) backs CreateBankLinkSessionCommand/ CompleteBankLinkCommand, both InitiatePayments + RequiresAccountActive + RequiresApprovedOnboarding + RequiresFreshMfa + RequiresProvider→FiatRails. The recipient is created solely by the EXISTING BridgeExternalAccountUpdatedHandler webhook (non-payable until active); no bank_link_session_uuid correlator was added to whitelist_recipients — Bridge's external_account.* payload carries no Plaid session/link identifier to match against (documented gap, payments README).

B7 — Prefunded accounts — DO NOT BUILD

Bridge deprecated the prefunded-accounts API 2026-03-24: read/list endpoints stay up "indefinitely," no new creation, and the migration path is to read the deprecated resource's bridge_wallet_id and use Bridge Wallet APIs instead — a prefunded account IS a Bridge Wallet under a different name (wiki/engineering/bridge/virtual-accounts-liquidation-and-wallets.md §7, fetched 2026-09-03). §12 of the same page confirms the distinction that matters operationally: an ordinary Bridge Wallet can only source a transfer where on_behalf_of == owner, while a prefunded wallet can source a transfer for ANY customer — but there is no API flag that marks a wallet "prefunded" in Bridge's own create/get schemas, so there is nothing DIFFERENT to build a read against beyond the wallet mirror accounts already maintains. No operator read was added in wave 3 — the existing wallets mirror already covers it.

B8 — Funds requests (recalls) — BUILD

ListsFundsRequests on FiatRails; payments owns the RLS mirror and a scheduled poller. Limitations: list-only (no accept/dispute exists) and poll-only (no webhooks). A fraud=true recall is a fraud/compliance signal, not merely an operational one.

As built (wave 3). funds_requests (RLS mirror, IsProviderMirror) fed EXCLUSIVELY by payments:sync-funds-requests (hourly, TracksAsTaskRun, withoutOverlapping) — polled PER MAPPED CUSTOMER from provider_customers (the W0 seam requires a customer ExternalRef, so there is no account-wide call; a central funds_request_sync_cursors row is the since-watermark, advanced only on a fully clean run). A row whose own customerExternalId mismatches the queried customer is never written to any tenant and is folded into one payments.funds_requests.unmapped operator notification per run. First sight of fraud = true fires Events\FundsRequestFlaggedFraud (local uuid + tenant only) once; Listeners\NotifyOperatorsOfFundsRequestFraud calls the notification dispatcher, falling back to an audit-channel log while notification.responsibilities ships unconfigured for this type. Client read endpoint DEFERRED (not cheap within scope — no existing JSON:API read pattern reused; tracked as a follow-up in the payments README) — the mirror is otherwise queryable via tenants:verify-isolation-gated direct access only. Modules\Payments\Jobs\SyncFundsRequestsJob lists each provider_customers row using THAT CUSTOMER'S OWN recorded provider (Modules\Rails\Integration\Contracts\ ResolvesProviderAdapter, the D120 row-provider rule), never a routed key — the sweep is acting on an existing customer relationship, not creating one.

B9 — Crypto return policies — BUILD

ManagesCryptoReturnPolicies on FiatRails, operator-only, no tenant table (there is no customer axis). Enforcement is a further grant pre-flight check refusing a flip until every accepted (asset, chain) has a policy. Limitations: account-level blast radius; Tron supports no refunds.

As built (wave 2). Operator API GET|POST /api/v1/backoffice/return-policies (web guard, new backoffice.return_policies.view/.manage permissions, the write carrying RequiresFreshMfa + RequiresRationale + an audited before/after trail) refuses refund_to_sender, any destination outside the rails.bridge.return_policy_destinations allow-list, and any change that would un-cover a routed pair — all before the provider is called; Modules\Rails\Support\ReturnPolicyCoveragePreflightCheck (rails.return_policy_coverage) is the seventh grant pre-flight — homed in rails, not features: ModuleBoundaryTest bans Modules\FeaturesModules\Rails, and every input the rule reads (the routed-pair table, the exemption list, the adapter) is rails' own; it registers by class-string from RailsServiceProvider::boot() into the Foundation registry, so Foundation still never imports rails — fail-closed on a provider read error and ARMED only once an environment is given refund destinations (it is the only check that makes an outbound vendor call — an always-on gate would make every local/CI features:sync depend on Bridge being reachable; the residual risk is an environment going live having never been given destinations, so arming belongs on the go-live checklist).

Tron decision. The usd|mxn|brl|gbp|cop → tron/usdt virtual-account routes are KEPT and the pair is exempted via rails.bridge.return_policy_exempt_pairs rather than dropped: Tron is a high-volume USDT deposit rail and the limitation is refund-only ("Tron accepts deposits but does not support refunds", wiki/engineering/bridge/transfers-fees-and-external-accounts.md §9 and virtual-accounts-liquidation-and-wallets.md §10, fetched 2026-09-03). The accepted consequence: a Tron deposit that must be returned lands in missing_return_policy and is an OPS case, never an automated retry. Two follow-ups recorded: the refusals reuse ErrorCode::ProviderValidationFailed rather than a dedicated code (Foundation was outside this wave's ownership), and the required-pair set will need the liquidation-address routes once B1 declares them (accounts declares none today).

F-R fix: the platform-provider indirection. Review round F-R (2026-09-05) found the return-policy surface (CryptoReturnPolicyProvider) and the rewards report (ListProviderRewardsHandler, B10) independently hardcoding the literal 'bridge' for the SAME reason — a tenant-less operator read has no jurisdiction to route on, so golden rule 10's row/named-provider seam is asked for an explicit key instead of a routed one — with no single place recording that choice. The director's ruling: ONE new config key, rails.platform_provider (config/rails.php, default bridge), read by both sites via ResolvesProviderAdapter::forProvider(). It is deliberately a SEPARATE key from rails.default_provider even though both name bridge today: default_provider is a bind-time FAIL-SAFE (what a routed capability falls back to when routing yields nothing for a TENANT), while platform_provider is a DELIBERATE PLATFORM CHOICE for a read that was never tenant-scoped to begin with — the two answer different questions and could diverge (e.g. a future platform-only report naming a provider that is not the routing default). Both call sites lost their private PROVIDER/PROVIDER_KEY class constant in favour of a config read, so a single environment override moves both surfaces together.

B10 — Provider rewards — BUILD

ReportsProviderRewards on FiatRails, treasury operator-only read; no table, no webhook, no entitlement. Limitations: no period axis, and the figures are lagged.

As built (wave 3). GET /api/v1/backoffice/treasury/provider-rewards (web guard, the compliance/ support-module operator stack — no tenancy middleware, no RLS; the existing BackofficePermission:: ViewReports catalog key, no new permission). ListProviderRewardsHandler resolves the adapter for the NAMED provider key 'bridge' via ResolvesProviderAdapter::forProvider() — never a routed key, since a CENTRAL, tenant-less report has no jurisdiction/tenant to route on — and maps RewardSummary to a uuid-free ProviderRewardSummaryData (provider, asset, period, decimal-string balance/rewards, provider updatedAt). A non-null ?period= still surfaces Bridge's own 422 PROVIDER_OPERATION_NOT_SUPPORTED refusal; a provider outage surfaces as the translated 503.

Cross-cutting (wave 0 + go-live)

Seven segregated interfaces, CapabilityGroupMap 17 → 24. app-modules/rails/src/Contracts/ gained ManagesLiquidationAddresses, SubmitsTravelRule, QuotesExchangeRates, LinksExternalAccountsInteractively, ListsFundsRequests, ManagesCryptoReturnPolicies, ReportsProviderRewards — all seven added to Support\CapabilityGroupMap::GROUPS, the single source of truth RailsServiceProvider reads to build its router-driven bind list, taking it from 17 routable capabilities to 24 (five route on CapabilityGroup::FiatRails — the liquidation-address, bank-link, funds-request, return-policy and rewards capabilities all resolve to whichever provider serves that tenant's fiat rail — one on Conversion (QuotesExchangeRates, an indicative rate is only meaningful from the provider that would actually perform the conversion), and one on TravelRule (SubmitsTravelRule) — a pre-existing group since D94 (PR #52) that had never been granted to any provider until this ADR — so a shop could in principle route the compliance filing to a specialist independently of who moves the money). BridgeRailsProvider implements all seven; no other adapter (Conduit, Sumsub, Utila, Fingerprint, SEON) does, so every one of these capabilities fails loud (AdapterMissingCapabilityException, 500) for any tenant routed to a non-Bridge provider for the owning group.

The Bridge adapter trait split. BridgeRailsProvider now composes its per-group method bodies from Bridge\Concerns\{LiquidationAddress,ExchangeRate,TravelRule,BankLink,FundsRequest, ReturnPolicy,Reward}Methods traits — one trait per new capability — each funnelling through the SAME shared call() template (made protected to be trait-reachable) that already guarantees VendorCallContext, vendor-request logging, and SDK→neutral-DTO/exception translation for every other Bridge method. The split exists so wave 0 could pre-declare every trait's implements/use shape and let waves 1–3 fill in method bodies without colliding on one shared adapter file — a mechanical scaling device, not a change to the M1/M2 seam invariants (multi-provider-development golden rules 1–2 still hold: no SDK type escapes any trait method).

The TravelRule grant — first grant on a pre-existing group, its own commit, inert until sync. Unlike the four D120 groups (which replaced three prior declarations), TravelRule had ZERO grants before D121 — registerFeatureDeclarations() gained ONE new line, $providers->assignIn([...six...], CapabilityGroup::TravelRule, ['bridge'], 'bridge'), landing as its own commit (862ab5c4) ahead of every capability wave — a human-approval surface per this repo's CLAUDE.md rule 3 (a grant is a compliance/money-movement-adjacent decision), reviewed and merged on its own. It is CODE-DECLARED only: like every D120 grant, it takes effect on a given environment once an operator runs php artisan features:sync there — a deploy alone never runs it. Arithmetic check against D120's own runbook wording (Features synced: ... 36 provider grant(s) ... — 24 Bridge rows across four groups

  • 12 Fraud rows, six jurisdictions each): the TravelRule line adds 6 more declared tuples (one group × six jurisdictions), so the NEXT features:sync run on an environment carrying this branch reports 42 provider grants upserted, not 36 — the 36 → 42 delta is exactly this one line's six jurisdiction rows, verified by the same counting rule D120 documented (syncJurisdictionProviderGrants() counts every DECLARED tuple it processes), not a re-run this session performed.

New tables (RLS status).

TableOwnerRLSIsolation test
liquidation_addressesaccountsTenant-scoped RLSYes (accounts)
provider_liquidation_addressesaccountsCentral (no RLS — the provider_customers/provider_onboarding_links shape, keyed (provider, external_id) → tenant_id)N/A (central)
bank_link_sessionspaymentsTenant-scoped RLS; link_token encryptedYes (payments)
funds_requestspaymentsTenant-scoped RLS, IsProviderMirror, unique (tenant_id, provider, external_id)Yes (payments)
funds_request_sync_cursorspaymentsCentral (one row per provider, the since-watermark for the poller)N/A (central)

No new table for B2 (folds into the existing sender-information flow), B3 (table-less, wraps the existing rate cache), B9 (operator-only, no customer axis — config + a grant pre-flight, not a table), or B10 (a pure read, no persistence at all).

New permissions, feature keys, error codes. backoffice.return_policies.view / backoffice.return_policies.manage (B9, operator-only, web guard); AccountsFeatureCatalog:: CRYPTO_DEPOSIT_ADDRESSES (accounts.crypto-deposit-addresses, boolean, default-OFF every tier — the B1 client surface ships entitlement-gated, not merely marker-gated); a dedicated return-policy refusal error code, ErrorCode::ReturnPolicyRefused (RETURN_POLICY_REFUSED), ships alongside this programme (G-code) — Foundation's ErrorCode enum was outside this wave's own file ownership, so it lands as a sibling change rather than inside this PR.

FE contract deltas. (1) Travel-rule sender-information payload gains an optional wallet_attested_ownership_at, required only when self_custody_attestation: true (B2). (2) A liquidation address's destination is nominated by wallet uuid only{kind: "wallet", wallet_uuid, currency} — never a raw chain address or bank reference (B1; a registered-address/whitelist-recipient destination needs a payments Integration seam that does not exist yet — see follow-ups). (3) Bank-linking is a two-step exchange — POST /api/v1/payments/bank-links then POST .../bank-links/{bankLinkSession}/complete — and completion returns no recipient synchronously; the client reconciles from the whitelist-recipient list once Bridge's external_account.* webhook lands (B6). (4) Funds requests have no client-facing read yet — the mirror exists only for the poller and operator visibility (B8, deferred, see follow-ups).

Operator surfaces. Crypto return policies (GET|POST /api/v1/backoffice/return-policies, backoffice.return_policies.view/.manage, step-up MFA + mandatory rationale on the write, B9) and provider rewards (GET /api/v1/backoffice/treasury/provider-rewards, the existing BackofficePermission::ViewReports key, no new permission, B10) are both operator-only — neither has a customer/tenant axis in Bridge's own model, so neither got a client-facing surface.

The 7th grant pre-flight check — an accepted risk + a go-live checklist item. Modules\Rails\Support\ReturnPolicyCoveragePreflightCheck (rails.return_policy_coverage) is registered from RailsServiceProvider::boot() into Foundation's ProviderGrantPreflightRegistry — homed in rails, not features (ModuleBoundaryTest forbids Modules\FeaturesModules\Rails, and every input the check reads — the routed-pair table, the exemption list, the adapter — is rails' own). Unlike the six D120 checks, it is the only one that makes an outbound vendor call (reading Bridge's own return-policy list), so it is deliberately config-armed: it refuses a grant flip only once rails.bridge.return_policy_destinations has at least one entry — an environment that has never been given refund destinations sees this check silently pass, not block. Accepted risk: an environment can therefore run features:sync and go live on Bridge's FiatRails grant having never configured a crypto return policy for a routed pair, landing any refund-needing crypto deposit in missing_return_policy (an ops case, not an automated retry) rather than being blocked at sync time. This is why arming the check (configuring destinations) is a go-live checklist item, not something this ADR can certify as done for any given environment.

F-R fix: unarmed visibility. Review round F-R (2026-09-05) found the accepted risk above sharper than intended in practice: an unarmed rails.return_policy_coverage check returned zero blockers with NOTHING in the features:sync output or logs distinguishing "evaluated the rule, found it satisfied" from "never evaluated the rule at all" — an operator reading a clean sync had no way to notice the environment had never been armed. Two additive fixes, no change to the arming predicate itself: (1) the check now writes Log::channel('audit')->warning('rails.return_policy_coverage.unarmed', {jurisdictions, config_key}) once per run whenever a plan would ACTUALLY route FiatRails to bridge while unarmed — a durable trail distinct from the silent pass; (2) Foundation gained an OPTIONAL marker interface, Bus\Contracts\ReportsPreflightArming (isArmed(): bool), which SyncFeatures now checks for every registered pre-flight check to print either <name>: ran — <n> blocker(s) or <name>: UNARMED — skipped (…) — a minimal, backwards-compatible contract extension (a check that doesn't implement it is printed exactly as before) rather than a new required method on ProviderGrantPreflightCheck itself. Neither fix changes WHEN the check blocks a flip — an unconfigured environment can still go live on this grant, per the accepted risk above — they only make that state impossible to miss in the sync output and in the durable log.

Consequences

  • Bridge is now the widest-coverage provider in the seam — 18 of 24 capability interfaces (11 from D118 + all 7 from this programme), spanning five CapabilityGroups (Onboarding, FiatRails, Conversion, Custody, TravelRule). No other adapter implements any of the seven new capabilities, so every one of them is Bridge-only until a second provider is built against these interfaces.
  • Nothing here moves real traffic on merge. The B1/B6 client surfaces are entitlement/marker gated (default-OFF accounts.crypto-deposit-addresses; the existing RequiresProvider/ RequiresApprovedOnboarding/RequiresAccountActive/RequiresFreshMfa stack); B8's poller only finds rows once provider_customers has Bridge-provider rows to poll against (none exist until an operator runs features:sync per D120 and a tenant actually onboards through Bridge); the TravelRule grant is a fifth code-declared-but-unsynced grant exactly like D120's four. A jurisdiction/tenant not yet migrated to Bridge sees zero behavioural change from this ADR. Sequencing the coverage build ahead of any of it running is deliberate — the plan is CLAUDE.md rule 4 compliant every step of the way: nothing here is claimed to work beyond what MockClient tests prove.
  • Three real gaps are recorded as accepted, not hidden: no fiat-destination or registered-address nomination path for a liquidation address (B1, wallet-uuid-only until a payments Integration seam exists); no funds-request client read (B8, deferred); the return-policy coverage gate is config-armed, not always-on (B9, above) — each is a named follow-up, not a silent limitation.
  • A pre-existing gap surfaced, not introduced, by B6: AddWhitelistRecipientCommand (the by-hand whitelist-recipient registration this Plaid flow deliberately mirrors) does not carry RequiresFreshMfa or a quorum/checker step, even though it mints the exact same class of payable recipient the new bank-link flow does carry those markers for. Recorded as a follow-up, not fixed here (out of this programme's file ownership).
  • Documentation debt closed in the same change: the rails README's pre-D121 "13 capability binds" language (stale after CapabilityGroupMap grew to 24) and the wiki's "17 capability contracts" framing are both corrected as part of this ADR's own documentation wave.

Status

Accepted. Wave 0 (the rails seams: seven interfaces, the neutral DTOs/enums, the group-map entries and router binds, the Bridge adapter's per-capability traits and mappers, and the TravelRule grant declaration), waves 1–3 (every BUILD item above), and this documentation wave have all landed on feat/bridge-coverage-programme. B4/B5/B7 are excluded with evidence, not deferred. A stacked Conduit code-removal PR (D122) follows separately and is out of this record's scope.

Follow-ups (tracked)

  • A payments-published recipient seam for liquidation-address fiat destinations. B1 nominates a destination by wallet uuid only; a bank/registered-address destination needs a published payments Integration seam (the ResolvesCustodyWalletRef/ResolvesNominatedWallet precedent) that does not exist yet.
  • AddWhitelistRecipientCommand lacks RequiresFreshMfa/a quorum step — a pre-existing gap this programme's B6 work surfaced by contrast, not introduced by it (see Consequences above).
  • Funds-requests client read. B8's mirror has no JSON:API read endpoint; deferred as not cheap within this programme's scope (no existing read pattern to reuse cleanly).
  • The B5 (cards) programme — its own ADR, gated on the four prerequisites recorded under B5 above (Stripe as a directly-integrated second vendor, a second webhook ingress, a ~2-second real-time-auth SLA, and a card-fraud liability decision).
  • Go-live checklist (operational, not code — none are preconditions for merging this ADR, all are preconditions for a given environment carrying real Bridge-routed traffic through these seven capabilities):
    1. Configure rails.bridge.return_policy_destinations (Stables-controlled treasury addresses) via the published root config — this is what ARMS the 7th grant pre-flight check.
    2. Set a crypto return policy for each of the 9 routed (currency, chain) pairs (the usd|mxn|brl| gbp|cop → tron/usdt pairs are the documented Tron exemption, rails.bridge. return_policy_exempt_pairs, not a policy to set).
    3. Run php artisan features:sync to realize the TravelRule grant (and confirm the reported provider-grant count reflects the +6 delta from 36 to 42 on an environment already carrying D120's grants).
    4. Enable accounts.crypto-deposit-addresses per tenant/tier once liquidation addresses are ready for that tier — it ships default-OFF everywhere.
    5. FE readiness: liquidation-address nomination + lifecycle UI (create/list/revoke, wallet-uuid-only destination), the bank-link two-step flow (initiate + complete, then poll the whitelist-recipient list for the webhook-driven result), and the travel-rule wallet_attested_ownership_at field wired into the sender-information form when self_custody_attestation: true.