Skip to main content

D120 — Bridge grant flip: the four-line grant, prune, pre-flight seam, requiredness, one code-level default

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Design of record: docs/superpowers/plans/ 2026-09-04-bridge-grant-flip-pr-c.md (PR C implementation plan); docs/superpowers/specs/2026-09-03-bridge-domain-alignment-design.md §5/§6/§7 row C; docs/superpowers/specs/2026-09-03-bridge-rails-adapter-design.md §10. Follows D118 (the sealed, registered-not-granted Bridge adapter) and D119 (the domain-alignment PRs A + B this grant depended on). This record FINALISES D119 — see Status below.

Context

D118 registered a sealed, four-group BridgeRailsProvider adapter with zero grant rows. D119 (PR A + PR B) shipped the domain alignment the grant required: the neutral settlement-mode/ transfer-endpoint vocabulary, Bridge webhook domain handlers, the payout/order explicit-source makers, and the custody wallet-ref resolver's bridge arm — all exercised against the still- ungranted adapter, so none of it moved real traffic. Three things stood between that state and a real grant: (1) flipping the grant would silently strand tenant state a routing change could not see coming — an in-flight payout with no explicit source, or a custody approval request whose money already moved on a provider about to lose its grant; (2) SyncFeatures:: syncJurisdictionProviderGrants() was NO-PRUNE — it upserts declared grants and demotes a superseded primary to a permitted key, but never deletes a realized row, so four assignIn lines alone would leave the D94/D95/D98 rows as stale permitted keys or, worse, a stale PRIMARY on any group Bridge does not serve; (3) a payout/order maker had no way to ask "does the routed provider require an explicit source" without hardcoding a provider name, defeating the whole point of the routing seam. This ADR is PR C: the grant flip itself, and the three seams it needed first.

Decision

1. The grant — four lines replace three declarations

Modules\Features\Providers\FeaturesServiceProvider::registerFeatureDeclarations() (app-modules/features/src/Providers/FeaturesServiceProvider.php) REPLACES the call to registerConduitBaseline(...) and the D95 (Sumsub Onboarding-primary) and D98 (Utila Custody-primary) assignIn lines with four lines — one per group Bridge serves, all six conduit-baseline jurisdictions (ZA/NG/KE/GB/EU/US), bridge the SOLE permitted + primary key:

$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');

conduit appears in NO grant — it is legacy code only, never again a primary, permitted fallback, or code-level default. sumsub/utila are NOT permitted keys either — parked means registered and ungranted, not fallback-eligible (a per-tenant tenant_provider_overrides row can still pin either explicitly; the override mechanism itself is unchanged). The Fraud line (fingerprint primary, seon permitted, D102) is UNCHANGED — Bridge does not implement AssessesFraudRisk. The six remaining CapabilityGroup cases with no capability contract yet (transaction monitoring × 2, AML screening, travel rule, cards, regulatory filing) get NO grant row from either the old baseline or this flip — RequiresProvider fails those closed rather than routing to a retired vendor. (Eleven CapabilityGroup cases total — Foundation's ValueObjects\CapabilityGroup enum — of which five are now granted: the four Bridge groups plus Fraud.) data_region stays null, the D95/D98 precedent (the EU bloc jurisdiction has region = null, so a non-null data_region would residency_conflict).

2. Prune — features:sync gains a reconcile-delete

SyncFeatures::pruneStaleProviderGrants() (app-modules/features/src/Console/Commands/ SyncFeatures.php) runs AFTER the upsert loop, in the SAME transaction: it SOFT-DELETES every jurisdiction_provider_grants row whose jurisdiction code is in the DECLARED set but whose (jurisdiction, group, provider_key) tuple is not declared. Scoped to declared jurisdiction codes only — a jurisdiction code the sync never touches (test fixtures use bespoke codes) is left completely alone, never swept by a global prune. Idempotent: a second run with the same declarations prunes 0. This is what makes "code is the source of truth for provider grants" literal: a hand-added grant in a declared jurisdiction is pruned, not preserved. features:sync's own description now states this in plain language, and the pruned count is reported on every run (%d provider grant(s) pruned). The command's exit code failure paths (unknown feature key, unknown jurisdiction) are unchanged; the prune runs unconditionally as part of the (possibly refused, see §3) provider-grant phase.

Implemented post-ADR (commit 04eae6fb): the plan's §2 proposed that a NOT-PERMITTED tenant_provider_overrides row (one naming a key the flip is about to make not-permitted for its tuple) would make features:sync itself refuse, "folded into the pre-flight loop." The check was built in Features as Support\TenantProviderOverridePreflightCheck (name features.tenant-provider-overrides), registered from FeaturesServiceProvider::boot() into ProviderGrantPreflightRegistry, and tested in SyncFeaturesProviderGrantPreflightTest. It reads all tenants' live TenantProviderOverride rows in one pass (via BYPASSRLS, unscoped, in the central console context) and refuses the provider-grant phase (exit 1, unless --force) if any override pins a key the incoming plan does not permit for that tenant's (jurisdiction, group) tuple — the pre-flight analogue of the existing ROUTING-TIME override_not_permitted refusal (ProviderRouter::assertOverride). It is now one of the six registered checks — see §3.

The whole grant phase is ONE transaction (review round F, finding #1). The demote+upsert loop over every declared grant AND the prune (pruneStaleProviderGrants()) run inside a SINGLE outer transaction opened once in syncJurisdictionProviderGrants() (JurisdictionProviderGrant::query()->getConnection()->transaction(...)). Each grant's own demote+upsert transaction() call, and the prune's own, therefore execute on an already-open transaction on the same connection — Postgres nests them as SAVEPOINTs rather than separate top-level transactions — so an exception ANYWHERE in the phase, including inside the prune, rolls back every upsert this run made, never a partial flip. The pre-flight blocker collection (§3) still runs entirely BEFORE syncJurisdictionProviderGrants() is ever called, outside any transaction — a refused flip touches zero rows.

3. The pre-flight seam — Foundation owns the contract, modules own the checks

Foundation (app-modules/foundation/src/Bus/) gained the pre-flight seam, a registry-inversion precedent mirroring TaskTypeRegistry/MirrorReconciliationRegistry:

  • Contracts\ProviderGrantPreflightCheckname(): string; blockers(ProviderGrantPlan): list<PreflightBlocker>, never throws.
  • Data\ProviderGrantPlan — the DECLARED tuples a flip is about to realize (jurisdiction code → group value → {permitted, primary}), built from the SAME code-declared registry the flip is about to realize — never a live DB read that could go stale mid-flip. Exposes primaryFor()/jurisdictions().
  • Data\PreflightBlocker{check, reason, count, tenantUuid?}.
  • Support\ProviderGrantPreflightRegistry — a STATIC registry (not container-bound, the TaskTypeRegistry idiom, immune to per-test container rebinds), register() idempotent per class, all(), reset() (test isolation). Stores CLASS-STRINGS only (review round F, finding #4). The registry previously diverged from the TaskTypeRegistry precedent by caching a built INSTANCE per registered check; it now matches verbatim: register() accepts either an already-built instance (existing callers commonly pass $this->app->make(SomeCheck::class)) or a bare class-string, but either way only the class is retained, and all() resolves a FRESH instance via the container on every call (array_map(fn ($class) => app($class), $checks)) — never a cached instance from registration time. This makes a check immune to per-test container rebinds (the no-RefreshDatabase feature-test pattern this app uses) the way a cached instance would not be.

features:sync walks ProviderGrantPreflightRegistry::all() BEFORE mutating any grant row and refuses (exit 1, every blocker listed) unless --force (which proceeds, prints what it skipped, AND writes a durable audit record — see below). A blocked provider-grant phase never blocks the feature/level/jurisdiction sync riding the same command invocation.

--force is logged on the audit channel (review round F, finding #2). The console warning a --force bypass prints is operator-visible only in that terminal — a bypass that later turns out to have stranded money left no durable trail. SyncFeatures::logForceBypass() now writes a structured Log::channel('audit')->warning('features.sync_provider_grant_force_bypass', [...]) record alongside the console output, carrying every bypassed blocker (check, tenantUuid, count), who and where invoked the bypass (invoked_by.user/.host, via get_current_user()/gethostname()), and the full declared plan (plan.tuples) — the same Log::channel('audit') convention every other operational console command in this app uses (ReconcilePayouts, PruneCommandDedupRecords), never the default channel.

Six checks are registered today, one per module carrying tenant state a flip could strand:

  1. Modules\Features\Support\TenantProviderOverridePreflightCheck (features.tenant-provider-overrides, registered from FeaturesServiceProvider::boot(), §2 above). Predicate: a live (non-expired) TenantProviderOverride row pinning a provider key the incoming plan does not permit for that tenant's (jurisdiction, capability group) tuple — the pre-flight analogue of ProviderRouter's routing-time override_not_permitted refusal, caught before the flip instead of live per tenant. Reads centrally (BYPASSRLS, one pass over every tenant's override rows), not per-tenant.
  2. Modules\Payments\Support\InFlightPayoutsPreflightCheck (payments.in_flight_payouts, registered from PaymentsServiceProvider::boot()). The hazard: a payout with source_wallet_id IS NULL is submittable only by a provider that INFERS the source (Conduit); flipping the FiatRails primary to an explicit-source provider (§4) under an open payout leaves it unsubmittable after N-of-M approval already released it. Predicate: approval_status IN (pending_approval, submitting) AND source_wallet_id IS NULL, for a tenant whose jurisdiction the PLAN gives a FiatRails primary carrying RequiresExplicitTransferSource — asked via the adapter REGISTRY (is_a() on the registered class, never constructed), never a provider-key string comparison. Iterates tenants inside each one's own initialized RLS scope (the ReconcilePayouts fleet-sweep precedent) rather than a central cross-tenant query, because payouts is RLS-policied.
  3. Modules\Payments\Support\InFlightOrdersPreflightCheck (payments.in_flight_orders, registered from PaymentsServiceProvider::boot(), NEW — review round F-A finding 3a, the sibling of #2). The hazard: since the F-A row-provider fix (§3a below), CancelOrderHandler addresses an existing order by its OWN recorded provider — correct, but the ManagesOrders capability BIND is still routed, so a legacy order handed to a newly-primary adapter fails LOUD and can no longer be cancelled (nor healed by the routed D59 reconcile sweep). Predicate: an order whose status is NOT terminal (NOT IN (succeeded, failed, cancelled)) whose provider is not the plan's Conversion primary for the owning tenant's jurisdiction (a jurisdiction left with no Conversion primary blocks too). Same tenant-iteration + fail-CLOSED posture as #2.
  4. Modules\Funding\Support\AwaitingSenderInformationPreflightCheck (funding.awaiting_sender_information, registered from FundingServiceProvider::boot(), NEW — review round F-A finding 3b). The hazard: an open travel-rule RFI has a clock on it (D64); since the row-provider fix, SubmitSenderInformationHandler addresses the deposit by its OWN recorded provider, so a flip leaves the submission failing LOUD at the newly-primary adapter with no way for the tenant to answer, sliding the deposit into a held-deposit compliance case. Predicate: a transaction whose status is awaiting_sender_information (the one deposit sub-state requiring an outbound provider call) whose provider is not the plan's FiatRails primary for the owning tenant's jurisdiction. Deliberately narrower than "every non-terminal transaction" — a plain pending/processing deposit is not THIS handler's concern: no client-driven outbound call is waiting on it. It is NOT true that a plain pending deposit makes no outbound call at all — the hourly D59 stale-mirror reconcile sweep (Modules\ProviderMirror\Jobs\ ReconcileTenantStaleMirrorsJob + the per-module AbstractMirrorReconciler it drives) polls it periodically. What actually keeps it from stranding is the per-row resolution rule (§3a): the reconcile fleet resolves the adapter for EACH row's own recorded provider, never the routed key, so a demoted-provider row keeps reconciling correctly against its own provider after a flip instead of drifting onto the newly-routed one.
  5. Modules\CustodyControls\Support\ForwardedOnOtherProviderPreflightCheck (custody_controls.forwarded_on_other_provider, registered from CustodyControlsServiceProvider:: boot()). The hazard: the reclaim sweep resolves the routed Custody adapter for the CURRENT primary, never the provider a request was actually forwarded on; a Custody primary flip while a request carries a stale forwarded_provider makes the reclaim's arm (b) look the transaction up on the NEW primary, find nothing, and RE-INITIATE — a genuine duplicate on-chain movement. Predicate: an approved request, or a failed one with a stamped forwarded_external_id (the exact reclaim candidate set), whose forwarded_provider is non-null and differs from the plan's Custody primary for the tenant's jurisdiction; OR an approved request with NO forwarded_provider stamped yet, whose wallet's OWN provider (resolved via the published ResolvesCustodyWalletRef seam, a cheap RLS read, no provider call) differs from the plan's primary. READ-FAILURE SAFETY (review round F, finding #3): an unexpected exception reading one tenant's requests (a DB fault, an RLS-scope error — never the narrower, deliberately-swallowed unresolvable-wallet case) is caught and turned into its OWN blocker instead of propagating out of the tenant sweep and aborting it for every OTHER tenant behind the fault — mirrors InFlightPayoutsPreflightCheck::blockerFor() verbatim: fail CLOSED per tenant, never fail open on a read fault.
  6. Modules\Onboarding\Support\OpenReviewsOnOtherProviderPreflightCheck (onboarding.open_reviews_on_other_provider, registered from OnboardingServiceProvider::boot(), NEW — review round F-B finding #2). The hazard: F-B's PULL-side fix (§3a below) means an open KYB/ KYC review keeps reconciling against its own provider even after a flip, but the client-facing NEW-onboarding surfaces (requirements, submit, RFI response, KYC initiate — all RequiresProvider) move to the new primary, so an operator/client cannot start a second onboarding under the SAME provider the open review is still progressing on, and the review has no path forward except waiting it out on a demoted provider. Predicate: an Application whose provider-neutral status is non-terminal (Pending/Processing, the same set ApplicationApprovalEvaluator treats as "still under review with the provider") whose linked customer's provider is not the plan's Onboarding primary for the tenant's jurisdiction. Only jurisdictions the plan actually declares an Onboarding primary for are walked (the InFlightPayoutsPreflightCheck:: explicitSourceJurisdictions() precedent) — an unrouted jurisdiction is a different failure mode, refused elsewhere.

Every check follows the SAME shape: iterate the central Tenant registry, read inside each tenant's own initialized RLS scope (never a central cross-tenant query, which would fail OPEN under a role that does not bypass RLS), never throw (a per-tenant read failure becomes its own blocker, fail CLOSED), and contribute one PreflightBlocker per affected tenant carrying its public uuid and a count.

3a. The row-provider standing rule (review round F-A / F-B)

The director's ruling: a handler acting on an EXISTING provider object uses that object's OWN recorded provider; the routed key is for CREATION only. An in-flight object still open on a non-primary provider blocks the grant flip (§3), it is never silently re-addressed to the new primary. A routed key answers "where would a NEW thing be created" — it says nothing about which provider's namespace an EXISTING external_id lives in. Before this fix, several sites built an outbound ExternalRef (or resolved a rails capability) from the tenant's currently-ROUTED key even when acting on a pre-existing row; after a grant flip that silently aimed the call at the WRONG provider's id space — a legacy Conduit/Sumsub/Utila id handed to Bridge, at best a confusing not-found, at worst (accounts' signing-quorum override resolution) a collision with an unrelated same-id resource at the new provider.

Money-movement sites (review round F-A) read the row's own provider column ($row->providerValue(), from IsProviderMirror) and build the ExternalRef/mirror lookup from it — the adapter BIND stays routed, so a demoted-provider row still fails LOUD at the wrong adapter (never silently succeeds against the wrong resource); the pre-flight checks above are what stop that state from arising in the first place:

  • Modules\Payments\Handlers\Commands\CancelOrderHandler — the order's own provider.
  • Modules\Funding\Handlers\Commands\SubmitSenderInformationHandler — the transaction's own provider.
  • Modules\Accounts\Handlers\Queries\ListSigningQuorumsHandler — the wallet_overrideswallet_uuid resolution groups override wallet ids BY THEIR OWNING QUORUM ROW'S provider, one (provider, external_id) lookup per distinct provider on the page.

Onboarding pull/action sites (review round F-B) resolve the ADAPTER for the addressed object's own provider, via a new rails seam — Modules\Rails\Integration\Contracts\ResolvesProviderAdapter (forProvider(string $providerKey, string $capability): object), implemented by Modules\Rails\Support\NamedProviderAdapterResolver — instead of the router-driven per-capability container bind. forProvider() fails loud exactly like the routed bind does: UnknownProviderAdapterException for an unregistered key, AdapterMissingCapabilityException for a registered adapter that does not implement the requested capability. Landed (commit 88450b31, this review round, finding F-B #1):

  • Modules\Onboarding\Handlers\Commands\SyncVerificationFromProviderHandler
  • Modules\Onboarding\Handlers\Commands\SyncBeneficialOwnersFromProviderHandler
  • Modules\Onboarding\Mirror\CustomerReconciler

Landed separately (commit fcc5b65d, review round finding F-B #16 — a parallel change-set in the same working tree, merged after this ADR's headline commit 4269279f): Modules\Onboarding\Mirror\ApplicationReconciler and Modules\Onboarding\Handlers\Commands\SubmitRfiResponseHandler adopt the identical ResolvesProviderAdapter seam — a stale ApplicationReconciler sweep resolves the adapter for the swept ref's own provider; SubmitRfiResponseHandler resolves it for Application::externalRef()-> provider before re-submitting. All five row-provider onboarding sites are now landed.

Two further capstone sites — TARGET STATE, not yet landed at time of writing (tracked as a sibling capstone-fix, "G1"):

  • Modules\Accounts\Webhooks\Handlers\AbstractUtilaCustodyWebhookHandler (the Utila custody webhook PULL) — today it is incidentally exempted from tests/Architecture/ProviderLiteralTest.php by the guard's broad Webhooks/Handlers/Abstract PATH-SUBSTRING exemption (§7 below) rather than being a genuine inbound-webhook envelope default; G1 fixes it to resolve the webhook's own recorded provider via ResolvesProviderAdapter, the same seam the onboarding pull sites above use.
  • The D59 stale-mirror reconcile fleetModules\ProviderMirror\Jobs\ReconcileTenantStaleMirrorsJob + the per-module AbstractMirrorReconciler implementations it drives — resolves the adapter for EACH swept row's own recorded provider, never the routed key, so the hourly sweep keeps reconciling a demoted-provider row correctly after a flip instead of drifting onto the wrong vendor.

4. Requiredness — a marker, never a provider-key comparison

Modules\Rails\Contracts\RequiresExplicitTransferSource (app-modules/rails/src/Contracts/ RequiresExplicitTransferSource.php) is an EMPTY marker interface: the routed provider refuses a transfer without an explicit source/destination endpoint. BridgeRailsProvider implements it; Conduit does not (it infers a default). Support\CapabilityGroupMap::ROUTING_ONLY_GROUPS maps it to CapabilityGroup::FiatRails ONLY — it is NOT in the router-driven bind list (no methods to serve), so ProviderCapabilityInspector::routedProviderSupports(RequiresExplicitTransferSource::class) resolves the FiatRails-routed provider to inspect. InitiatePayoutHandler refuses a missing sourceWalletUuid and CreateOrderHandler refuses a missing neutral source/destination pair when the routed provider carries the marker — both 422 (PayoutSourceWalletInvalidException for payouts, pointer /data/attributes/source_wallet_uuid; OrderTransferEndpointInvalidException for orders, pointer /data/attributes/source or /destination). The legacy no-nomination body still works for a Conduit-routed (override-pinned) tenant.

Accepted structural gap, not a defect to silently paper over. Bridge's explicit-source requirement spans BOTH FiatRails (payouts) and Conversion (orders), but CapabilityGroupMap maps one routing-classification marker to exactly ONE group. CreateOrderHandler — routed on Conversion — asks the inspector for the marker resolved against FiatRails's routed provider, not Conversion's own. This holds today because every adapter implementing the marker (Bridge) serves both groups as the same vendor, but it is NOT structurally guaranteed by the map — a future provider serving only Conversion with an explicit-source requirement, while FiatRails routes elsewhere, would silently answer the wrong question. Tracked as a follow-up (below), not fixed here — the marker's own docblock and CapabilityGroupMap's carry the same caveat.

5. One default, one place — RailsServiceProvider::DEFAULT_PROVIDER is gone

config('rails.default_provider') (app-modules/rails/config/rails.php) is now the SINGLE bind-time fail-safe default: 'bridge', superseding the D95/D98 PRIMARY choices as the single-vendor Bridge target. The Foundation Bus\Contracts\ProviderKeyResolver::resolveOrDefault( CapabilityGroup $group, ?string $default = null) contract changed to make the $default argument OPTIONAL — omitting it (the mandated call shape for a domain module) falls back to the configured key; the argument survives only for a caller with a genuine own fallback (the fraud device-risk guard). The umbrella RailsProvider container bind is DELETED (RailsServiceProvider::register()) — zero app consumers, and it would fail-loud on a partial default adapter; code that needs the full 13-capability umbrella (rails' own tests) resolves ConduitRailsProvider::class directly, since Conduit remains the only adapter implementing all 13.

CreateOrderCommand::idempotencyKey() (app-modules/payments/src/Commands/CreateOrderCommand.php) is the one call site that resolves the routed key OUTSIDE a handler: the bus dedup identity folds in the provider dimension via app(ProviderKeyResolver::class)->resolveOrDefault($this-> requiredProviderGroup()), read from the container because Foundation's CommandDeduplicator runs BEFORE the handler — the bus order is ProviderGuard (fail-closed RequiresProvider gate) → tenant context → dedup → handler, so the route is already established and compliant by the time the command's own method runs, and resolveOrDefault() never throws on a routing signal.

6. CHECK constraint re-add — 8 tables across 2 migrations

FinanceProvider::values() has included 'bridge' since D118, but a provider CHECK constraint is a literal snapshot baked at whichever migration last rebuilt it — adding an enum case does not retroactively widen an already-migrated database. Two migrations, the Utila precedent ($withinTransaction = false, ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT, down() filters bridge back out):

  • app-modules/accounts/database/migrations/2026_09_04_000000_extend_provider_check_constraints_for_bridge.phpvirtual_accounts, wallets, wallet_signers, signing_quorums, custody_transactions (the last was created AFTER the Utila widening with its own baked pre-Bridge list, so it needs the re-add too even though it never went through that earlier migration).
  • app-modules/onboarding/database/migrations/ 2026_09_04_000000_extend_provider_check_constraints_for_bridge_on_onboarding_tables.phpcustomers, applications, beneficial_owners.

Funding's transactions.provider and payments' orders/payouts/registered_addresses/ whitelist_recipients provider columns carry no CHECK constraint and needed no migration.

7. C8 — the literal cleanup (D94's deferred item, closed)

D94 left ~26 domain-module sites (accounts 13, payments 5, funding 1, rails 3 — 22 confirmed by C2's Serena sweep, plus 4 test-fixture pins not counted in that figure) building ExternalRef( ConduitRailsProvider::PROVIDER, …), querying a mirror by provider = conduit, or passing the Conduit literal as the resolveOrDefault() fallback — every one addressed the WRONG vendor the moment the routed provider became Bridge. tests/Architecture/ProviderLiteralTest.php (new arch guard) fails the build on any of THREE literal forms appearing anywhere in accounts/payments/funding/onboarding/custody-controls/treasury's src/: ConduitRailsProvider::PROVIDER, FinanceProvider::Conduit, and — review round F, finding #5 — the bare quoted string 'conduit'/"conduit", added because the constant-only scan missed a raw string literal doing the exact same thing (e.g. new ExternalRef('conduit', …)).

Two sanctioned exemptions, both scanning for the raw string only where noted:

  1. The inbound-webhook envelope default (all three literal forms) — each module's own webhook envelope handler: Webhooks/Handlers/Abstract{Accounts,Funding,Payments}WebhookHandler (3 files — accounts, funding, payments): an INBOUND Conduit webhook is, by construction, Conduit's own event, so the envelope's provider is a fact about the delivery, not a routing choice. TARGET STATE — not yet landed at time of writing (G1): the guard's CURRENT implementation exempts by a broader Webhooks/Handlers/Abstract PATH-SUBSTRING match rather than this explicit 3-file allow-list, which incidentally also exempts Modules\Accounts\Webhooks\Handlers\AbstractUtilaCustodyWebhookHandler (a webhook PULL handler, not an envelope default, only named Abstract* for an unrelated reason) from the same-string check it should actually be subject to. G1 tightens the guard to the explicit 3-file allow-list above and fixes the Utila custody webhook pull to resolve its own recorded provider instead (a §3a row-provider site, see above).
  2. The inbound-webhook SOURCE key (review round F, finding #5 — 'conduit' ONLY, never the two constant literals). Onboarding's per-source multi-handler webhook registry (Modules\Webhooks\Support\WebhookEventRegistry::registerFor()) registers 'conduit' => CustomerCreatedHandler::class in OnboardingServiceProvider (B0 / bridge-domain- alignment PR B) — 'conduit' here is Conduit's own WEBHOOK SOURCE key, a fact about which vendor's delivery a handler is wired to, not a routing choice, one level up the call chain from exemption 1. A PRECISE, line-content exemption (is_exempt_webhook_source_key_line(), not a blanket directory pass): a line is exempt only when BOTH (a) its file lives under a Providers/*ServiceProvider.php path (the registry-registration call site) OR under Webhooks/Handlers/ (a handler's own docblock describing its registration), AND (b) the line itself is the webhook-registry idiom — a 'conduit' => SomeHandler::class array entry, or a line mentioning registerFor(. Any other 'conduit' in either directory (e.g. inside a handler's own handle() body building an ExternalRef) is NOT covered and still fails the build.

Domain code now resolves ProviderKeyResolver::resolveOrDefault() for its own capability's group, with NO fallback argument, everywhere the guard scans — except the row-provider sites (§3a), which read an existing object's own recorded provider column, never a literal.

Consequences

  • Bridge routes real production traffic once an operator runs features:sync — the code declares the grant on merge, but a deploy runs migrate --force only (see the Go-live runbook below); the routing flip itself is a separate, operator-run, human-approved step.
  • A grant flip can no longer silently strand an in-flight payout or a custody approval request — the pre-flight seam refuses features:sync's provider-grant phase first, with a named, auditable reason per tenant.
  • --force exists and is real — an operator can push a flip through known blockers, but the command prints exactly what it skipped, so the choice is visible in the same log line the flip itself produces.
  • The routing-classification marker/group asymmetry (§4) is now load-bearing, not merely theoretical — RequiresExplicitTransferSource genuinely drives a 422 refusal in production once the grant is realized, so the gap between "resolved for FiatRails" and "asked on behalf of Conversion" is a real, if currently harmless, seam.
  • D95's and D98's PRIMARY choices are superseded, not reversed — the grant ROWS for Sumsub (Onboarding, GB/EU) and Utila (Custody, all six) are pruned by the sync's reconcile-delete; the ADAPTERS stay registered and parked, reachable only via an explicit tenant_provider_overrides pin.
  • Two module READMEs (accounts, custody-controls) had actively wrong "inert until the grant" claims about the native-signer-management 409 gate — discovered while syncing docs for this ADR: ManagesSignersAndQuorumNatively is implemented ONLY by ConduitRailsProvider (not Utila, not Bridge), so once Custody resolves to bridge the 7 signer/quorum management commands refuse 409 for EVERY tenant, not zero — a behavioral consequence of this flip the original increment's own docs did not anticipate. Fixed in this change (app-modules/accounts/README.md, app-modules/custody-controls/README.md).
  • The RegisterAddressCommand travel-rule field-drop accepted risk (D98) is superseded, not extended — Bridge stubs registerAddress entirely (ProviderOperationNotSupportedException, 422), so the exposure documented for Utila-routed registrations does not extend to any Bridge-routed one; it remains a live concern only for addresses registered while Utila was primary (app-modules/payments/README.md).
  • Documents settle in a new TERMINAL not_forwarded status for a Bridge-routed tenant — intended, not a regression, and now durable/observable (review round F-C). Bridge does not implement ManagesDocuments (it collects KYC/KYB documents via its own hosted kyc_links flow instead, per D118). The documents module's pre-existing doc-less-provider guard (ForwardDocumentToProviderJob, ProviderCapabilityInspector::routedProviderSupports( ManagesDocuments::class)) already no-op'd gracefully rather than fail-loud for exactly this case (built for the Sumsub doc-less route, increment 2.1d-1) — but the PRE-review behaviour left the document at status=stored forever, byte-identical to the transient "queued, will forward shortly" state, with no way for a client to tell the two apart. The review fix adds a new terminal, non-error DocumentStatus::NotForwarded (not_forwarded) case: the guard now dispatches MarkDocumentNotForwardedCommand, which flips the document to not_forwarded and stamps a sanitized forward_skipped_reason (Document::$forward_skipped_reason, exposed on DocumentResource, null for every other status). Terminal means terminal: the forward job's idempotency short-circuit also returns early on an already-not_forwarded document, so a LATER provider grant flip (e.g. a future Sovera adapter implementing ManagesDocuments) does not retro-forward bytes captured under the old routing — a client must re-upload to route to a document-capable provider. The encrypted copy stays fully served/verified either way; only the provider forward never happens. app-modules/documents/README.md updated to describe Bridge, not only Sumsub, as a doc-less route, and to document the new status + column.

Accepted risks (carried forward from D118/D119, not newly introduced or newly closed here)

  • provider_customers carries no Bridge rows for any existing tenant — NOT APPLICABLE at D120 merge time. This app is pre-production: there is no existing tenant to re-onboard, so a canary re-onboarding step is NOT a precondition for THIS flip (see the Go-live runbook below). The concern is real for any FUTURE primary flip that happens after production tenants exist (e.g. a later Sovera S2 activity-driven fallback, or any later primary reshuffle) — that flip's own runbook would need a canary re-onboarding step this one does not.
  • Bridge Legal & Compliance approval for custody, and the geo-exclusions on custodial wallets, are still outstanding — this ADR does not represent that clearance; it is a precondition of actually running features:sync in production, not something the code can verify.
  • The Bridge webhook signature scheme (asymmetric RSA PKCS#1 v1.5 + SHA-256) is unverified against a real production delivery — sandbox does not exercise webhooks end-to-end.
  • The marker/group asymmetry (§4) — not a defect introduced by this ADR, but a structural limitation this ADR's own requiredness feature depends on; tracked as a follow-up.
  • No document forwarding for a Bridge-routed tenant — accepted as intended D118 behaviour (Bridge's hosted kyc_links flow collects documents instead), not a remediation item; the document now settles at the durable, client-visible terminal not_forwarded status instead of staying stored forever (review round F-C, see Consequences above) — the underlying no-forward behaviour is unchanged and still accepted, only its observability improved.
  • A lost-response idempotency-key retry can race a grant flip. Every write capability derives its Idempotency-Key deterministically from (provider, method, payload digest) — the retry contract assumes the SAME provider handles a retried attempt. A caller that loses the response to a write, then retries AFTER an intervening features:sync flip has changed the routed provider for that capability's group, derives a DIFFERENT key (a different provider is now folded into the digest) and so is submitted as a genuinely NEW request rather than replayed as the original — no duplicate-submission protection across the flip boundary. The window is narrow (a flip is an infrequent, operator-run event, not a per-request occurrence) and the pre-flight seam (§3) already refuses the flip while money-movement objects are genuinely in flight, but a request that is in-doubt (sent, response lost, not yet reflected in any mirror row the pre-flight checks can see) at the exact moment of a flip is not covered by any check here. Accepted, not remediated by this ADR — tracked as a follow-up.

Go-live runbook

No production data exists at D120 merge time — this app is pre-production. There is no in-flight payout, order, RFI, KYB/KYC review, custody approval request, or not-permitted tenant override for the six pre-flight checks (§3) to find, and no existing tenant's provider_customers row to re-onboard. The drain/re-source step and any canary re-onboarding are therefore NOT preconditions for THIS flip. The checks and the row-provider standing rule (§3a) are not made redundant by that — they stay live as defence for FUTURE grant changes (a later Sovera S2 activity-driven fallback, any later primary reshuffle) and for staging/preview environments that DO carry test data; on staging, a --force past a blocker that is only test fixtures (with its audit-channel log line, §3) is an acceptable, recorded call. This narrows the runbook to:

  1. Merge → deploy. The CHECK-constraint migrations run (migrate --force); routing is UNCHANGED for every existing tenant (grants are DB rows; a deploy never runs features:sync) EXCEPT the code-level default (config('rails.default_provider')), which now applies to any tenant/context that falls through to it (no tenant context, no grant row, an unroutable group) — a routing convenience, not a compliance grant.
  2. An operator runs php artisan features:sync. With no production data, the pre-flight seam (§3) finds nothing to block and the provider-grant phase proceeds straight through. Verify the operator-facing summary line (SyncFeatures's own wording): Features synced: N level entitlement(s), M jurisdiction grant(s), 36 provider grant(s), 74 provider grant(s) pruned. — 66 conduit rows (11 groups × 6 jurisdictions, the D94 baseline), 2 sumsub rows (D95), and 6 utila rows (D98) are soft-deleted (74 total, matching the pruned count); 36, not 24, provider grant(s) are reported upserted — syncJurisdictionProviderGrants() counts every DECLARED tuple it processes, so the 24 bridge rows (4 groups × 6 jurisdictions) are joined in the same count by the 12 fraud rows (D102 — fingerprint primary + seon permitted × 6 jurisdictions), which are re-updateOrCreated unchanged, not skipped.
  3. Remaining open items — operational, not code, and none are data-migration preconditions: production BRIDGE_API_KEY + the webhook PEM public key in SSM; Bridge Legal & Compliance approval for custody; the webhook signature scheme confirmed against a real production delivery; the VA route allow-list (rails.bridge.virtual_account_routes) confirmed against Bridge's live payment-routes page.

Operational detail (config knobs, the --force audit trail, and what each PreflightBlocker.check name means when it appears in a refusal): docs/tracking/multi-provider/00-provider-router.md §7a.

Status

Accepted. Shipped as its own PR (feat/bridge-grant-flip), after PR A + PR B (D119) merged. This record finalises D119 — D119's own Status section said it would be "finalised with PR C"; that amendment is made in D119 directly (its Status line and go-live precondition list), not duplicated in full here. D118's Go-live section is marked applied by this ADR.

Follow-ups (tracked)

  • Close the marker/group structural gap (§4). Either give CapabilityGroupMap a way to map a routing-classification-only interface to more than one group, or add a second, Conversion-specific marker — needed only if a future provider serves one of FiatRails/Conversion with an explicit-source requirement while NOT serving the other with the same vendor.
  • Provider-pinned resolution / customer_provider_bindings — still design-only (per-customer stickiness, ProviderResolution carrying a key + external id, not merely a key string); the row-provider rule (§3a) is a narrower, already-shipped stopgap for EXISTING objects, not a replacement for per-customer binding once a customer can plausibly be created under more than one provider over time.
  • A tenant-capabilities discovery endpoint — a client-facing "what does my tenant's routing currently permit" read, surfaced during this review round as a deferred UX finding (today a client only discovers PROVIDER_NOT_ROUTABLE reactively, per-request, on the write path). Pending user sign-off on scope before it becomes a committed increment — not designed or built here.
  • Conduit adapter/SDK code removal — a later programme item, unaffected by this flip (Conduit stays legacy code, not code-removed).
  • Sovera S1 (adapter) / S2 (activity-aware tenant_provider_overrides fallback routing) — sequenced right after this flip per D118; neither is part of it.
  • The Bridge coverage programme (B1–B10)liquidation_address.drain.* and the rest of the "anything and everything Bridge offers" list, unaffected by this flip; see D118 Follow-ups and docs/tracking/multi-provider/00-provider-router.md §7.
  • A provider-neutral rename for the conduit-mirror-domains skill — proposed, not actioned; the skill's own SKILL.md now carries a one-line note that the name is legacy pending that rename, per this ADR's own docs sweep (do not rename without a dedicated follow-up).
  • The lost-response idempotency-key-vs-flip race (Accepted risks above) — not remediated by this ADR; needs its own design (e.g. pinning an in-doubt request's provider across a flip) if it is ever prioritized.

D94 (the routing substrate), D95 / D98 (the superseded primaries), D102 (the untouched Fraud grant), D118 (the sealed adapter), D119 (the domain-alignment precondition, finalised by this record).

← Back to decisions index