D118 — Bridge rails adapter: sealed PARTIAL adapter over four groups, registered but NOT granted; supersedes D95/D98 primaries
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. The mechanics live inapp-modules/rails/src/Bridge/*,app-modules/rails/config/rails.php, and thebridgesource inapp-modules/webhooks/config/webhook-client.php.
Context
Conduit is no longer the target vendor (decided 2026-09-02 — see the
provider-landscape wiki and packages/stables/bridge-sdk/README.md);
Bridge (+ Sovera) are the forward vendors. bridge-sdk (ADR D114's first adopter of the
OpenAPI-snapshot drift-gate pattern) has shipped as a pure-transport Saloon SDK since before this
increment, but with zero app-modules/* consumers — no RailsProvider adapter existed for
Bridge at all. D117 (onboarding activity declaration) had to work around that gap with a
config-listed, stopgap provider-support matrix precisely because "no RailsProvider adapter
exists for Bridge yet" (D117 Deferred). This increment closes that gap on the adapter side: it
makes Bridge a first-class, registered rails provider — sealed adapter + webhook ingress — so
the platform can run onboarding, fiat rails, and conversion through Bridge. It does not turn
any traffic on: committing Bridge to a jurisdiction is a separate, human-approved one-line grant
flip (the D95/D98 precedent), because that step touches money paths and compliance routing.
Conduit is not ripped out (it remains the baseline grant everywhere), so behaviour is
byte-for-byte unchanged on deploy.
Mid-flight scope change (2026-09-03, same day, before merge): Bridge becomes the intended single-vendor shop, including custody. The initial design of record scoped Bridge to Onboarding
- FiatRails + Conversion and explicitly left Custody to Utila (D98) and left Sumsub (D95) as the intended Onboarding primary. The owner decision below revises the target primary state to Bridge across all four groups Bridge can serve — this ADR records both what ships in this increment (registered, ungranted, exactly as originally scoped) and the revised intended go-live target (Decision items 6–7, Go-live).
Decision
A sealed, PARTIAL BridgeRailsProvider adapter is built and registered over four capability
groups — Onboarding, FiatRails, Conversion, Custody — following the
Sumsub\SumsubRailsProvider / Utila\UtilaRailsProvider precedent. It is registered in
Support\ProviderAdapterRegistry and config('rails.providers') but NOT granted in any
jurisdiction_provider_grants row — conduit alone continues to serve every jurisdiction on
deploy; Utila and Sumsub remain registered and granted exactly as D98/D95 left them (this
increment changes no grant).
- ISP scope — what Bridge implements, per the design's capability table.
BridgeRailsProviderimplementsProviderAdapter+OnboardsCustomers,ManagesCustomers,VerifiesIdentity,FetchesVerification,VerifiesBusinesses(Onboarding, 5);ManagesVirtualAccounts,ManagesPayouts,ManagesWhitelistRecipients,ManagesTransactions(FiatRails, 4);ManagesOrders(Conversion, 1);ManagesWallets,ManagesCustodyTransactions,ManagesWalletSigners,ManagesSigningQuorum,ManagesRegisteredAddresses(Custody, 5 — the last three advertised but stub-only end to end, see item 1a) — fifteen capability interfaces across four routing groups (sixteen entries in the class'simplementsclause counting theProviderAdaptermarker; see the rails README's implemented-vs-stubbed table for the per-method split), the first multi-group PARTIAL adapter in the seam (Sumsub and Utila each serve exactly one group). Not implemented (routing there fails loud withAdapterMissingCapabilityException, 500):ManagesDocuments(Bridge's hosted KYC/KYB flow collects documents itself — the Sumsub precedent),AssessesFraudRisk(Bridge has no fraud/device-risk capability — Fraud stays Fingerprint-primary/SEON-permitted, D102, untouched by this ADR). Within the fifteen implemented interfaces, methods Bridge structurally cannot serve are stubbed withProviderOperationNotSupportedException(422, terminal — the Utila precedent), not omitted from the interface:submitApplicationfor anyApplicationTypeother thanCustomerOnboarding/VirtualAccount;initiateVerificationfor anyVerificationTypeother thanIndividualKyc/BusinessKyb;submitSenderInformation(Bridge has no inbound-deposit RFI);orderRequirements(no requirements-read surface); every method ofManagesWalletSigners,ManagesSigningQuorum, andManagesRegisteredAddresses(Bridge has no signer/quorum/address-book model — see item 1a);rotateWalletandvoteOnTransaction(see item 1a).
1a. Custody scope — advertised-but-stubbed, mirroring the Utila 2.2c precedent, but for a
different reason. BridgeRailsProvider advertises ManagesWallets and
ManagesCustodyTransactions (so routing binds cleanly if Custody is ever granted to it) but
serves a narrower surface than Utila's Console-managed-policy-engine gap: getWallet/
listWallets/createWallet map onto Bridge's custodial wallets (POST /v0/customers/{id}/wallets {chain}); rotateWallet is stubbed (no rotate/replace-key operation exists). initiateTransaction
maps onto the same POST /v0/transfers object every other money-movement capability uses, with
source.bridge_wallet_id naming the custodial wallet; getCustodyTransaction/
listCustodyTransactions (renamed — see item 1b) read the
same transfer resource, scoped to a customer. voteOnTransaction is stubbed
(ProviderOperationNotSupportedException) — Bridge has no API-vote / approver model at all
(unlike Utila's native vote surface), so the in-app custody-controls approval-quorum engine
(the accounts module's N-of-M consensus, D97) is the only N-eyes gate a Bridge-custodied
transaction clears; there is no vendor-side co-sign to layer under or over it. ManagesWalletSigners,
ManagesSigningQuorum, and ManagesRegisteredAddresses are stubbed in full — Bridge has no
signer/quorum concept (custodial wallets are single-key, platform-controlled) and no
address-book/allow-list concept (crypto destinations are supplied inline as to_address on each
transfer, not pre-registered). Bridge custodial wallets require Bridge Legal & Compliance
approval and carry geo exclusions per vendor docs
(https://apidocs.bridge.xyz/platform/wallets/overview.md, docs as of 2026-09-03) — an
operational precondition distinct from the routing grant itself (see Go-live).
1b. A latent ISP defect, found by building the first multi-group adapter — ManagesCustodyTransactions
is renamed getCustodyTransaction/listCustodyTransactions. ManagesTransactions (FiatRails)
and ManagesCustodyTransactions (Custody) both declared a getTransaction method (taking
ExternalRef) and a listTransactions method — identical method names, incompatible signatures (different filter/DTO
shapes). No single-group adapter ever collided on this (Conduit implements only
ManagesTransactions; Utila implements only ManagesCustodyTransactions), so the defect stayed
latent until BridgeRailsProvider tried to implement both interfaces on one class — a
PHP fatal (incompatible redeclaration), not a routing or test failure. Decision: rename the
custody pair to getCustodyTransaction(ExternalRef): CustodyTransaction and
listCustodyTransactions(...): CustodyTransactionCollection on ManagesCustodyTransactions
only — ManagesTransactions::getTransaction/listTransactions are unchanged. A mechanical
Serena-driven rename (rename_symbol) sweeps every implementer and caller:
Utila\UtilaRailsProvider, the custody-controls consumer(s), accounts, and their tests. This
keeps the seam's "one provider key → one adapter class" registry model intact — the
alternative of per-group adapter classes (one class per CapabilityGroup, keyed on
(provider, group) instead of provider) would have meant a bigger ProviderAdapterRegistry
redesign for one naming collision — see Alternatives rejected. The general rule this establishes
for the seam, now recorded in the multi-provider-development skill: segregated capability
interfaces must use group-distinct method names, even when two groups happen to model a
structurally similar read (a transaction lookup), so that a future multi-group adapter can
compose both without a rename fire-drill of its own.
- Onboarding rides Bridge's KYC-Links path, not the Customers API.
submitApplicationposts tokyc_links()->create(full_name,email,type,endorsements[],redirect_uri) rather than the full Customers-API KYB payload — the same hosted-flow shape Sumsub uses, and the one D117's pinnedLegalStructure/EmploymentStatus/IndividualAccountPurpose/BusinessActivityenums were deliberately kept ready for.documentson the submit payload are not forwarded — Bridge's hosted flow (Persona-backed) collects them directly, mirroring the Sumsub WebSDK precedent forManagesDocuments. Mapping D117's activity declaration into the fuller Customers-API payload is deferred (see Alternatives rejected + Follow-ups). - A Bridge transfer is the one neutral money-movement object.
createPayout,createOrder, andgetTransaction/listTransactionsall funnel through Bridge's singlePOST /v0/transfersobject — the adapter'sMappers\TransferPayloadMappermaps our three separate neutral payload shapes (payout, order, transaction read) onto it andMappers\TransferMappermaps the one response object back ontoOrder/Payout/Transactiondepending on call site. Every transfer requires an explicitsource(payment_rail,currency, and one offrom_address/external_account_id/bridge_wallet_id) — Bridge has no implicit "your default wallet" behaviour the way Conduit does. A payload missingsourcethrowsProviderValidationException('Bridge requires an explicit transfer source')rather than guessing; today'sPayoutSubmitter/order makers don't yet supply one (a domain follow-up, not part of this increment — see Follow-ups).BridgeStatusMapmaps Bridge'sTransferStateexhaustively onto the same neutral order/payout/transaction status vocabulary the Conduit mappers already emit — a deliberate choice not to invent a third status vocabulary. - Idempotency keys are normalised to Bridge's ≤64-character requirement.
Support\BridgeIdempotencyKey::normalize()returns a caller-supplied key unchanged when it is already ≤64 characters, else SHA-256-hashes it to a fixed 64-hex-character string — theIdempotencyKeyvalue object elsewhere in the seam carries no length ceiling, so Bridge is the first provider requiring this normalisation step. Aderive()helper builds a deterministic key (sha256('bridge:'.$method.':'.$parts)) for adapter-internal POSTs the caller supplies no key for (e.g.revokeWhitelistRecipient's underlyingdeactivatecall). - Webhook ingress is asymmetric-signed and registered, with no domain handlers yet.
BridgeSignatureValidatorverifies Bridge'sX-Webhook-Signature: t=<ms>,v0=<base64>header — RSA PKCS#1 v1.5 + SHA-256 over the raw string"<t>.<rawBody>", against one or more comma-separated PEM public keys (rotation, the Sumsub precedent), fail-closed on any malformed input. Deviation from Bridge's own suggested behaviour: a stale timestamp (|now_ms - t| > tolerance_ms, default 600000/10min) is rejected as an invalid signature (rendered 401), not Bridge's suggested 400 — the app's existing invalid-signature render callback inbootstrap/app.phponly knows 401, and Bridge's own docs state it retries any non-2xx with exponential backoff regardless of status code, so the deviation has no observable effect on delivery.BridgeTenantResolverresolves the tenant fromevent_object.client_reference_idfirst, falling back to aprovider_customerslookup on(provider='bridge', external_customer_id)derived fromevent_object.customer_id ?? event_object.on_behalf_of. Dedup key is Bridge'sevent_id. NoModules\Rails\Bridge\*import exists inside the webhooks module (D52) — the webhook source is registered (route, validator, resolver, config) with zero domain handlers wired to it; a Bridge webhook is signature-verified, deduped, stored, and acked, but produces no mirror write yet (see Follow-ups). - Registered but NOT granted — the go-live grant is a separate, human-approved step.
FinanceProvider::Bridge, thebridgeconfig('rails.providers')entry, andProviderAdapterRegistry::register('bridge', BridgeRailsProvider::class)all ship in this increment — Bridge becomes vendor-logged, translatable, and routable in principle — but nojurisdiction_provider_grantsrow names it. Every jurisdiction keeps routing every capability group exactly as D94/D95/D98 left it (conduitbaseline;sumsubprimary for GB/EU Onboarding;utilaprimary for Custody), so behaviour is unchanged on deploy. The exact grant lines that turn Bridge on are recorded below under Go-live — the sameassignIn(...)one-liner-per-group pattern D95 (Sumsub, Onboarding) and D98 (Utila, Custody) used, extended to four groups at once since Bridge serves four. - D118 supersedes the D95 and D98 PRIMARY choices as the intended go-live target — adapters
retained, parked. The owner decision behind this ADR is a single-vendor Bridge operating
model: once granted, Bridge becomes primary for Onboarding, FiatRails, Conversion, and
Custody, superseding Sumsub's D95 Onboarding-primary status and Utila's D98 Custody-primary
status. This is a supersession of intent, not of code — neither D95 nor D98 is reversed or
rewritten: their grants stay live exactly as shipped (
sumsubprimary GB/EU Onboarding,utilaprimary Custody all six) until the Bridge go-live grant below actually lands, and both adapters remain registered in the seam (parked, not removed) as permitted fallback keys after the flip — see Go-live. Fraud is untouched (D102's Fingerprint/SEON grant stands; Bridge has noAssessesFraudRiskcapability to supersede it with). The decision log index rows for D95 and D98 are annotated accordingly. RequiresProvidernow covers the remaining fiat-rails/conversion/funding makers. Themulti-provider-developmentskill anddocs/tracking/multi-provider/00-provider-router.md§4.1 have named "wiringRequiresProvideronto the remaining provider-sensitive commands" as a hard prerequisite before the next committed vendor since increment 2.1d-2b. This increment satisfies it for the groups Bridge would serve:Payments\Commands\CreateOrderCommand+CancelOrderCommandnow carryRequiresProvider→CapabilityGroup::Conversion; the payout maker command(s) andAddWhitelistRecipientCommand→FiatRails;Funding\Commands\ SubmitSenderInformationCommand→FiatRails. Withconduitgranted for all six baseline jurisdictions this is a no-op today (the bind-time resolver already resolvesconduiteverywhere); it makes the bus fail closed the day any of these groups routes to a second provider, rather than fail-SAFE→default. The onboarding + custody markers already existed (2.1d, 2.2e); Fraud is off-umbrella and out of scope here.- Policy (user, 2026-09-03): Conduit is legacy code only. It must never again be a primary,
a permitted fallback key, or the code-level default. The grant-flip PR (Go-live, below) removes
Conduit from every grant and flips
RailsServiceProvider::DEFAULT_PROVIDERtobridge; removing the Conduit adapter/SDK code itself is a later programme item, out of scope for both this increment and the grant-flip PR (see Follow-ups). This supersedes the D94/D95/D98 pattern of listingconduitas a universal override-permitted fallback key — that pattern was correct while Conduit was the incumbent baseline; it is not the target state. - Groups no vendor serves get no grant row, not a legacy placeholder. Where the go-live grant
flip leaves a
(jurisdiction × CapabilityGroup)tuple with no vendor that serves it, the flip writes no row for that tuple rather than defaulting it toconduit(the currentregisterConduitBaselineplaceholder behaviour). TheRequiresProviderbus gate then fails the request closed (ProviderNotRoutableException) instead of silently landing it on a retired vendor — fail-closed, compliance-first, consistent with golden rule 6 in themulti-provider-developmentskill. - Sovera is the designated fallback where Bridge does not support the activity type or
jurisdiction and Sovera does. Two prerequisites gate this, both sequenced right after the
grant-flip PR and each carrying its own ADR: S1 — a sealed PARTIAL Sovera rails adapter over
sovera-sdk, registered and then added as a permitted key per jurisdiction where Sovera is licensed; S2 — activity-aware fallback routing that reuses the existing per-tenanttenant_provider_overridesmechanism, driven by the D117 activity-declaration evaluator: when a tenant's declared activities areprohibited/unknownon Bridge butsupportedon Sovera, an override pinssoverafor the affected capability groups (only among that jurisdiction's permitted keys — compliance beats commerce). This is not a new routing axis — the router still resolves on(CapabilityGroup × jurisdiction)plus the existing override mechanism; D117's matrix already carries the per-provider activity-support data S2 reads.
Go-live (NOT shipped in this increment — human-approved; ships as its own, later PR)
The grant flip is a separate PR, and it lands only after a domain-alignment PR, not alongside
it. Two increments still stand between this adapter and the grant above: (1) a domain
alignment PR — PayoutSubmitter and the order makers supplying the explicit source this
adapter requires, Bridge webhook domain handlers actually writing the onboarding/payments/funding
mirrors, and provider_customers rows being populated for Bridge — then (2) the grant-flip PR
itself, which is nothing but the four assignIn lines below + features:sync + its own tests
(one denied-routing test per group, mirroring 2.2e). This is a decision, not a menu of
preconditions to satisfy in any order: the grant does not ship until domain alignment has shipped
and merged first, mirroring how Utila's webhooks (2.2d) landed as their own increment ahead of,
and separate from, the 2.2f go-live grant.
Once product/compliance sign off and domain alignment has shipped, committing Bridge as the
sole primary is these four lines (the D95/D98 assignIn pattern, one per capability group
Bridge serves) in
Modules\Features\Providers\FeaturesServiceProvider::registerFeatureDeclarations(), replacing
registerConduitBaseline(...) and the existing D95/D98 declarations rather than layering on top of
them:
// replaces registerConduitBaseline(...) — Conduit appears in NO grant; parked vendors (utila,
// sumsub) are not permitted keys either (parked = registered, ungranted). Groups no vendor serves
// get NO row, so the RequiresProvider bus gate fails closed instead of landing on a legacy vendor.
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Onboarding, ['bridge'], 'bridge');
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::FiatRails, ['bridge'], 'bridge');
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Conversion, ['bridge'], 'bridge');
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Custody, ['bridge'], 'bridge');
// Fraud line (fingerprint primary, seon permitted) unchanged.
features:sync is no-prune — the four lines above are not sufficient by themselves (capstone
finding, 2026-09-03). Modules\Features\Console\Commands\SyncFeatures::syncJurisdictionProviderGrants()
upserts declared grants and, on a primary flip, demotes the superseded primary to a permitted
key — it never deletes a realized row (see the method's own NO-PRUNE docblock). Run the four
assignIn lines through features:sync alone and every prior conduit/sumsub/utila grant row
survives as a permitted key, and any group Bridge does not end up serving keeps its stale conduit
PRIMARY — both violate the Conduit-legacy policy below. The grant PR must therefore also ship one
of: (a) features:sync gaining a declarative reconcile-delete for provider grants (rows no
longer declared in code are removed — preferred, code stays the single source of truth), or (b) an
explicit features:revoke-provider-grants step run before the flip. Which of the two is a decision
for the grant PR's own ADR to make and record — full analysis in
docs/superpowers/specs/2026-09-03-bridge-rails-adapter-design.md §10.
then php artisan features:sync (having applied the prune/revoke step above), plus
RailsServiceProvider::DEFAULT_PROVIDER = 'bridge' (the
bind-time fail-safe for a missing grant) and the test fixtures that relied on the implicit Conduit
default (features:sync in setUp() is the pattern). Policy (user, 2026-09-03): Conduit is
legacy code only — it must never again be a primary, a permitted fallback key, or the
code-level default; this grant flip is where that policy becomes enforced in config, not merely
stated intent. Removing the Conduit adapter/SDK code itself is a later programme item, not part of
this flip (see Follow-ups). sumsub and utila are not listed as permitted keys either —
parked means registered-and-ungranted, not fallback-eligible; a per-tenant
tenant_provider_overrides row can still pin either adapter explicitly (the override mechanism
itself is unchanged), but neither rides along as a jurisdiction-level fallback the way the D95/D98
precedent listed conduit. Groups no vendor serves at all get no grant row (no legacy
placeholder), so RequiresProvider fails the bus request closed rather than routing it to a
retired vendor. Sovera enters the permitted list per jurisdiction only once its own adapter
exists (S1) and only where Bridge does not support the activity type or jurisdiction and Sovera
does (S2) — see Follow-ups; neither is part of this grant-flip PR. Preconditions, per group:
Onboarding — the D117 Customers-API payload mapping (or product acceptance of the KYC-links
flow as final) and provider_customers rows written by Bridge customer.* webhook handlers (not
built in this increment); FiatRails / Conversion — PayoutSubmitter and the order makers
supply the explicit source this adapter now requires, and Bridge transfer.* /
virtual_account.activity webhook handlers exist in payments/funding; Custody — Bridge
custodial wallets require Bridge Legal & Compliance approval and carry geo exclusions
(https://apidocs.bridge.xyz/platform/wallets/overview.md, docs as of 2026-09-03) that must be
cleared before any wallet-creation traffic; the custody-controls approval-quorum engine (D97) is
confirmed as the sole N-eyes gate (no vendor co-sign to layer under, unlike Utila's native vote
surface); Bridge custody webhook/mirror handlers exist (none built in this increment — Custody
inherits the same "registered ingress, no domain handlers" posture as the other three groups). All
four: the features:sync prune/revoke decision above has been made and implemented — the flip
does not ship on the four assignIn lines + a bare features:sync alone; data_region shipped
null (the D95/D98 precedent — the EU bloc jurisdiction has
region = null, so a non-null data_region would residency_conflict and refuse EU routing
outright); a production BRIDGE_API_KEY and a webhook endpoint registered with Bridge, its PEM
public key held in SSM.
Alternatives rejected
- A full-umbrella adapter with always-throwing stubs for the unserved interfaces. Rejected —
violates the ISP convention every other adapter in the seam follows (Sumsub, Utila): a provider
advertises exactly the interfaces it serves, so an unrouted capability fails loud at the bind
(
AdapterMissingCapabilityException, a wiring fault) rather than at call time against a stub that always throws (indistinguishable from a real outage in logs/metrics). - Keeping Utila as the intended Custody primary (rejected for now, not permanently closed). Utila's write path shipped in full (vault provisioning, per-vault SA secrets, Stage-0 approval quorum, the real forward) and its D98 grant remains live and unchanged by this ADR. The owner decision is nonetheless to move the intended Custody primary to Bridge once granted, in favour of a single-vendor operating model (one relationship, one set of KYC/compliance surfaces, one webhook/mirror shape to maintain long-term) over running two custody vendors concurrently for no product-visible benefit. Utila's adapter is retained, not removed — registered and parked (not a permitted fallback key in the Go-live grant; see item 9/10 and Go-live) — and this decision can be revisited if Bridge custody proves short of Utila's write-path maturity (Bridge has no native API-vote surface — see item 1a) before the grant actually flips.
- Building the payments/funding/onboarding domain handlers in the same PR. The adapter and webhook ingress are additive and registered-not-granted; wiring real mirror-write handlers for Bridge events before any grant exists would be dead code exercised by nothing. Deferred to the go-live increment, mirroring how Utila's webhooks (2.2d) landed ahead of but close to its go-live grant (2.2f).
- The Customers-API KYB path now. The fuller
POST /v0/customerspayload (full KYC/KYB fields,signed_agreement_id) is the eventual target for D117's pinned enums, but building that mapping without a live grant to exercise it against would be speculative; the KYC-Links path keeps parity with the Sumsub hosted-flow pattern and unblocks registration now. - (B) Per-group adapter classes, to fix the
getTransaction/listTransactionsISP collision (item 1b) without a rename. One class per(provider, CapabilityGroup)instead of one class perproviderwould sidestep any cross-group method-name collision entirely — a Bridge-Onboarding class, a Bridge-FiatRails class, etc., each satisfying only its own group's interfaces. Rejected: it breaks the seam'sProviderAdapterRegistry"one provider key → one adapter class" registry model for every existing provider (Conduit, Sumsub, Utila, Fingerprint, SEON), a load-bearing redesign to fix one naming collision that a rename solves in a mechanical, narrowly-scoped sweep. - (C) Dropping the fiat transaction reads from Bridge's scope, i.e. not implementing
ManagesTransactionsat all and leaving fiat-rails transaction lookups to Conduit even under a Bridge grant. Rejected: it would leave a Bridge-primary FiatRails jurisdiction unable to read its own transaction history through the routed provider — a capability gap with no technical justification (Bridge's transfer object serves the read fine) purely to dodge a naming collision that the rename in item 1b fixes properly instead. - Conduit as a permitted fallback key in the go-live grant (the D95/D98 pattern, e.g.
['bridge','conduit']). Rejected per the Conduit-legacy policy (item 9): Conduit must never again be a primary, a permitted fallback, or the code-level default, so the go-live grant listsbridgealone in every group it serves rather than carrying Conduit along as a safety net. - Legacy placeholder grants for groups no vendor serves. Retaining
registerConduitBaseline-style rows (defaulting an unserved group toconduit) was rejected in favour of writing no grant row at all for such groups (item 10): a missing row makesRequiresProviderfail the bus request closed, which is the correct compliance posture for a group with no licensed vendor — routing it to a retired vendor by default would look "working" while being wrong.
Consequences
- Bridge becomes vendor-logged (
vendor_request_logs,provider='bridge') and translatable (BridgeExceptionTranslator) from this increment forward, even though it serves zero live traffic — the same "known before adapted" posture Utila's 2.2a foundation step established. - The provider-landscape wiki's Bridge row moves from DORMANT SDK (zero
app-modules/*imports) to REGISTERED, not granted — a new third state alongside DORMANT and ROUTED that future vendor entries (Sovera, sFOX) will also pass through on their way to a grant. rails.sensitive_fieldsgains Bridge's KYC/bank keys (tax id, SSN, birth date, account/routing numbers, IBAN/BIC, CLABE, Pix key, document number,developer_reason,kyc_link,tos_link,signed_agreement_id) — Bridge error messages can echo submitted KYC values back, so these ride as non-rendered diagnostics only, never in a rendered client error.RequiresProvidernow covers the full set of provider-sensitive commands the multi-provider program's hard-prerequisite list named (onboarding, custody, and now fiat-rails/conversion/ funding) — the fail-SAFE→default bind-time resolver is no longer the only thing standing between a mis-routed grant and a compliance gap on these paths.ManagesCustodyTransactions::getTransaction/listTransactionsare renamed togetCustodyTransaction/listCustodyTransactions(item 1b) — a breaking rename for every existing implementer and caller, swept mechanically acrossUtila\UtilaRailsProvider, the custody-controls consumer(s), andaccounts, plus their tests.ManagesTransactions(FiatRails) is untouched. This is the seam's first instance of a naming collision surfacing only once a multi-group adapter existed to expose it — themulti-provider-developmentskill now states the general rule (segregated capability interfaces must use group-distinct method names) so the next multi-group adapter doesn't rediscover the same class of defect the hard way.- D95 and D98 are not reversed. Both grants remain live and unchanged —
sumsubstays the Onboarding primary for GB/EU,utilastays the Custody primary for all six, until the Go-live grant above actually lands. This ADR records the revised intent only; the decision log index rows for D95 and D98 carry a pointer note to this ADR so a reader lands on the current target state, not just the historical one.
Accepted risks (carried forward, tracked)
- Relationship/evidence documents are not transmitted to Bridge on whitelist recipients.
relationshipandevidenceDocumentIdshave no Bridge external-account field — they are retained locally by the domain (not lost from our side) but never reach Bridge. Not a regression from Conduit parity assessment, but worth stating plainly per the D98 "recorded honestly, not silently omitted" precedent. - Documents are not forwarded on
submitApplication. Bridge's hosted KYC/KYB flow collects them directly (Persona-backed); a caller-supplieddocumentsarray on the submit payload is silently dropped rather than transmitted — this is by design (see Decision item 2), not an oversight, but it means a domain caller that assumes document-forward parity with the Conduit adapter will be surprised. - No inbound-deposit RFI.
submitSenderInformationthrowsProviderOperationNotSupportedException— Bridge has no analogue of Conduit's deposit-held RFI flow; travel-rule originator data on a Bridge-routed transfer goes out via a separate/travel_rulesurface Bridge exposes, not modelled as a capability in this increment (see Follow-ups). - No vendor-side custody co-sign, at all, ever. Unlike the Conduit→Utila transition (D96/D98, where the hosted co-sign ceremony was replaced by Utila's own API-vote model), Bridge has no approval/vote surface of any kind for custodial-wallet transfers. Once Custody is granted to Bridge, the in-app custody-controls approval-quorum engine (D97) becomes the entire N-eyes control for a custody movement — there is no vendor-side second layer to fall back on if that engine has a gap. This raises the bar on the correctness of the in-app engine for Bridge-routed custody in a way it did not for Utila (which still exposes a native vote to reconcile against).
- Bridge custodial wallets require Legal & Compliance approval and carry geo exclusions (https://apidocs.bridge.xyz/platform/wallets/overview.md, docs as of 2026-09-03) — an operational gate this ADR does not resolve; it is a named Go-live precondition, not a code guarantee.
Follow-ups (tracked)
Immediate (domain alignment, precedes the Go-live PR — see Go-live above):
- Bridge webhook domain handlers (onboarding/payments/funding mirror writers) — the webhook ingress is registered but produces no mirror write today.
- Domain payload alignment:
PayoutSubmitterand the order makers supplying the explicitsourceBridge's transfer object requires. provider_customersrows populated for Bridge (written by the domain handlers above).- Customers-API KYB path carrying D117's full activity-declaration payload, once product accepts moving off the KYC-Links hosted flow.
- Decide + implement the
features:syncprovider-grant prune (reconcile-delete) or an explicitfeatures:revoke-provider-grantsstep (see Go-live above) — the four-line grant flip is incomplete without it:features:syncis no-prune, so it alone would leave every priorconduit/sumsub/utilagrant row as a permitted key and any unserved group'sconduitrow PRIMARY. - Boot-time validation of the configured Bridge webhook PEM public key(s):
openssl_verify()emits a PHP E_WARNING per delivery per malformed key inBridgeSignatureValidator(fail-closed, not exploitable — deferred review nit); validatewebhook-client.configs[bridge].signing_secretPEMs once at boot/config-cache time instead of per request.
The Bridge coverage programme — "anything and everything Bridge offers, we offer" (user
decision, 2026-09-03). Every bridge-sdk resource, classified against the rails seam: A
(served by this PR — maps onto an existing capability interface), B (a genuinely new
capability class — new segregated interface + neutral DTOs + a consuming mirror-domain module +
client API, one increment each), C (operator/plumbing, not customer-facing). Full rationale +
sequencing: docs/superpowers/specs/2026-09-03-bridge-rails-adapter-design.md §12; the live
tracker (kept current as increments ship) is
docs/tracking/multi-provider/00-provider-router.md.
| SDK resource | Class | Increment / home |
|---|---|---|
customers, kycLinks, associatedPersons | A | this PR — Onboarding |
virtualAccounts | A | this PR — FiatRails |
externalAccounts | A | this PR — FiatRails (whitelist recipients) |
transfers | A | this PR — FiatRails + Conversion |
wallets | A | this PR — Custody |
liquidationAddresses (+ drains) | B | B1 — new ManagesLiquidationAddresses capability (crypto off-ramp intake) |
travelRule | B | B2 — new SubmitsTravelRule on CapabilityGroup::TravelRule; closes the submitSenderInformation gap |
exchangeRates | B | B3 — Bridge-backed ExchangeRateProvider in treasury (Conversion) |
staticMemos | B | B4 — extends virtual-account intake (FiatRails) |
cards | B | B5 — new IssuesCards capability + a new cards module (largest item, own design) |
plaid | B | B6 — extension of whitelist-recipient registration (FiatRails) |
prefundedAccounts | B/C | B7 — treasury read-only operator view |
fundsRequests | B | B8 — payments (returns/recalls lifecycle) |
cryptoReturnPolicies | B | B9 — payments/compliance config surface |
rewards | B | B10 — treasury/reporting, lowest priority |
batchSettlements | C | operator config (backoffice) |
developers, fiatPayoutConfiguration, apiKeys, webhooks (mgmt), sandbox, lists | C | ops/config — lists already consumed by the D117 catalog snapshots |
Sequencing: B1–B3 complete the money-movement + compliance story a Bridge-exclusive shop needs
first; B4–B6 widen intake; B5 (cards) is its own programme. Each B item ships as its own PR with
its own ADR, following the "adding a capability" checklist in the multi-provider-development
skill. The Go-live grant flip (above) lands after the domain-alignment PR and before B1.
Sovera fallback (item 11) — S1/S2, sequenced right after the grant-flip PR:
| Item | Scope |
|---|---|
| S1 — Sovera rails adapter | Sealed PARTIAL adapter over sovera-sdk (crypto + cards, transport-only; business end-user onboarding still a vendor doc gap). Own ADR. Registered, then added as a permitted key per jurisdiction where Sovera is licensed. |
| S2 — activity-aware fallback routing | Reuses the existing per-tenant tenant_provider_overrides mechanism, driven by the D117 activity-declaration evaluator: when a tenant's declared activities are prohibited/unknown on Bridge but supported on Sovera, write an override pinning sovera for the affected groups (only among the jurisdiction's permitted keys). No new routing axis. Own ADR. |
Other tracked items (unaffected by this ADR):
- Conduit-vocabulary removal (
ConduitRailsProvider::PROVIDERliterals, ~75 refs). - Conduit code removal — deleting the
ConduitRailsProvideradapter andconduit-sdkentirely, once nothing routes to it. A later programme item, out of scope for both this increment and the grant-flip PR (item 9) — the grant flip removes Conduit from grants, not from the codebase. - A Scramble enum
$reffix for D117'sverdict/verdict_sourcefields (a core-web request, unrelated to Bridge but raised alongside this increment).
This decision builds on the router substrate (D94), the first non-Conduit grant precedent (D95), the second worked go-live instance (D98), and the onboarding activity declaration that first flagged the missing Bridge adapter (D117); it does not restate them.