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) anddocs/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 B5 — CapabilityGroup::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\Features → Modules\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:syncrun 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).
| Table | Owner | RLS | Isolation test |
|---|---|---|---|
liquidation_addresses | accounts | Tenant-scoped RLS | Yes (accounts) |
provider_liquidation_addresses | accounts | Central (no RLS — the provider_customers/provider_onboarding_links shape, keyed (provider, external_id) → tenant_id) | N/A (central) |
bank_link_sessions | payments | Tenant-scoped RLS; link_token encrypted | Yes (payments) |
funds_requests | payments | Tenant-scoped RLS, IsProviderMirror, unique (tenant_id, provider, external_id) | Yes (payments) |
funds_request_sync_cursors | payments | Central (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\Features → Modules\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 existingRequiresProvider/RequiresApprovedOnboarding/RequiresAccountActive/RequiresFreshMfastack); B8's poller only finds rows onceprovider_customershas Bridge-provider rows to poll against (none exist until an operator runsfeatures:syncper D120 and a tenant actually onboards through Bridge); theTravelRulegrant 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
Integrationseam 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 carryRequiresFreshMfaor 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
Integrationseam (theResolvesCustodyWalletRef/ResolvesNominatedWalletprecedent) that does not exist yet. AddWhitelistRecipientCommandlacksRequiresFreshMfa/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):
- 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. - Set a crypto return policy for each of the 9 routed
(currency, chain)pairs (theusd|mxn|brl| gbp|cop → tron/usdtpairs are the documented Tron exemption,rails.bridge. return_policy_exempt_pairs, not a policy to set). - Run
php artisan features:syncto realize theTravelRulegrant (and confirm the reported provider-grant count reflects the +6 delta from 36 to 42 on an environment already carrying D120's grants). - Enable
accounts.crypto-deposit-addressesper tenant/tier once liquidation addresses are ready for that tier — it ships default-OFF everywhere. - 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_atfield wired into the sender-information form whenself_custody_attestation: true.
- Configure