Skip to main content

D119 — Bridge domain alignment: neutral settlement mode, fixed-eyes VA re-nomination quorum, PR A shipped

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-domain-alignment-design.md; implementation plan: docs/superpowers/plans/2026-09-03-bridge-domain-alignment-pr-a.md. Follows D118 (the sealed, registered-not-granted Bridge adapter). This record is written with PR A (accounts + funding + onboarding domain alignment) and will be finalised with PR C (the grant flip) — see Status below.

Context

D118 registered a sealed BridgeRailsProvider adapter over four capability groups but shipped it with zero domain wiring: no webhook handlers wrote mirrors, PayoutSubmitter/the order makers supplied no explicit transfer source, and the virtual-account seam still modelled Conduit's balance-bearing shape. Closing that gap surfaced two Bridge realities that the existing ManagesVirtualAccounts contract could not represent:

  1. A Bridge virtual account is transfer-only. Fiat that lands on it is auto-converted to a stablecoin and forwarded to the VA's destination — an external crypto address or a Bridge custodial wallet (bridge_wallet_id). There is no fiat balance, and the destination is mutable after creation (PUT /customers/{c}/virtual_accounts/{va}, "future transactions will use the updated destination"). This is exactly the semantic mismatch the provider-landscape wiki page had already flagged as UNVERIFIED-fit (§7(a), §3 footnote 18).
  2. Transfer-only is a property of Bridge, not of a virtual account. Sovera accounts hold fiat. Modelling "transfer-only" as a hardcoded Bridge assumption would make the seam Bridge-shaped again — the exact mistake D94's neutral-capability seam exists to prevent. The account model needed a per-account settlement mode, not a per-provider branch.

A second, orthogonal gap: the in-app custody-controls approval-quorum engine (D97) is amount-tiered and FX-priced (ApprovalPolicyResolver::resolve() values the movement into USD before banding). A VA destination re-nomination has no natural amount — it is a routing change, not a value transfer — so the engine's existing path would either collapse to a meaningless 1-eye floor or refuse to open at all. Money-sensitivity does not disappear just because there is no amount: mis-routing a VA's destination silently redirects every future deposit.

This is PR A of three (design §7): A ships accounts + funding + onboarding domain alignment with zero grant change. For a baseline-jurisdiction tenant (Conduit granted for FiatRails, Bridge ungranted), the new create/re-nominate endpoints route successfully to Conduit and hit its new stub methods, so the observable failure is Conduit's stub throwing ProviderOperationNotSupportedExceptionHTTP 422 PROVIDER_OPERATION_NOT_SUPPORTED, not a routing refusal. PROVIDER_NOT_ROUTABLE (403) is reserved for a tenant whose jurisdiction has no FiatRails grant at all — a pre-existing case, unrelated to this PR. Either way, behaviour on deploy is unchanged from D118: no tenant reaches real Bridge traffic. B ships payments + custody alignment (the neutral transfer source/destination, the payout source wallet, the custody wallet-ref resolver arm); C is the grant flip itself. This record covers PR A's decisions in full and is finalised with PR C, when the grant flip's own consequences are known.

Decision

1. Neutral settlement mode + nominated destination (rails seam)

  • New enum Modules\Rails\Enums\SettlementMode { PassThrough = 'pass_through', Held = 'held' } and DTO Modules\Rails\Data\SettlementDestination { kind: DestinationKind (Wallet|Address), asset, chain, walletRef, address, memo }. Modules\Rails\Data\VirtualAccount gains settlementMode + destination. The Bridge mapper produces PassThrough + a populated destination; the Conduit mapper produces Held + a null destination (a future Sovera adapter would also produce Held). This keeps the account model provider-neutral: "does this account hold a balance" is now a per-account fact, not an assumption baked into the contract shape.
  • virtual_accounts gains settlement_mode (default held for existing rows, so Conduit-issued accounts are unaffected), destination (client-safe neutral jsonb), and destination_wallet_id (nullable FK → wallets, populated only when the nomination is one of the tenant's own mirrored wallets). BalanceProjector is untouched: a pass_through account's snapshot carries no balances key, so the existing present-key gate already leaves the projection alone — no new branch was needed in the projector itself.
  • ManagesVirtualAccounts gains two methods, additive on the existing interface: createVirtualAccount(ExternalRef $customer, VirtualAccountSpec $spec, string $idempotencyKey): VirtualAccount and updateVirtualAccountDestination(ExternalRef $customer, ExternalRef $virtualAccount, SettlementDestination $destination): VirtualAccount (no Idempotency-Key on the PUT — Bridge's idempotence page does not cover it). Bridge implements both directly against POST/PUT /customers/{c}/virtual_accounts; the D118 submitApplication(type=VirtualAccount) shim is removed — VA creation no longer rides the onboarding-shaped application flow. Conduit stubs both with ProviderOperationNotSupportedException (Conduit provisioned VAs through applications, a structurally different flow this PR does not retrofit).
  • Route-pair admissibility is validated once, in the adapter, and nowhere else. Config rails.bridge.virtual_account_routes lists the allowed (source currency → {payment_rail, currency}) pairs from Bridge's payment-routes page; a wallet nomination must additionally be a bridge wallet of the same Bridge customer whose chain matches the route. Modules\Rails\Bridge\Mappers\SettlementDestinationMapper (invoked from BridgeRailsProvider) is the single place this is enforced — accounts does not re-validate the pair, it only enforces ownership of the nominated local resource (see item 3 below).

2. Client API — create + step-up + quorum-gated re-nomination (accounts)

  • POST /api/v1/accounts/virtual-accounts (CreateVirtualAccountCommandCreateVirtualAccountHandler) and PATCH /api/v1/accounts/virtual-accounts/{virtualAccount}/destination (RequestVirtualAccountDestinationChangeCommand), both under the tenant guard, RequiresProvider(FiatRails), and RequiresApprovedOnboarding.
  • Re-nomination is money-sensitive by BOTH mechanisms (user decision, 2026-09-03), not either one alone. Submitting the change requires a fresh step-up (MFA) plus the new permission accounts.virtual-accounts.manage; the change is then applied only after the custody-controls approval-quorum engine reaches consensus — the VA carries pending_destination (jsonb) + destination_change_request_uuid while the request is open. This is the engine's third consumer (payouts, custody transactions, now VA destination) — see the approval-quorum-development skill.
  • The engine gains a fixed-eyes policy path (custody-controls). Because the engine's existing path is amount-tiered and FX-priced, an amount-less request would either collapse to the 1-eye floor or fail the resolver's amount-required check. ApprovalPolicyResolver::resolve() now short-circuits, before valuation ever runs, for any consumer in a new FIXED_EYES_CONSUMERS list (today: ApprovalConsumer::VirtualAccountDestination) to a distinct resolveFixedEyesPolicy() path: no FX, no banding, required_eyes = max(configured N, min_eyes_floor, MINIMUM_EYES=1), where the configured N is a custody_approval_policies row keyed by a non-null consumer column (a DISTINCT row family from the amount-tiered consumer IS NULL rows on the same table) or the code default of 2 (dual control by default). OpenApprovalRequest.amount/.asset become nullable — but fail loud (ApprovalAmountRequiredException, 422) for any consumer NOT on the fixed-eyes list, so this is additive, not a general amount-less escape hatch. custody-controls.manage-policy is raise-only for a fixed-eyes consumer: SetApprovalPolicyHandler refuses lowering required_eyes below the code default of 2 — a harder floor than the amount-tiered path's general ≥1 sanity floor, by design (a tenant cannot self-serve down to a single approver for a destination change the way it legitimately can for a sub-$1k payment). The never-sole-eye and self-approval rules (§4.1a/§4.2 of the engine) apply unchanged, keyed off required_eyes regardless of how it was resolved.
  • Ownership is proven under RLS before any provider round-trip — foreign wallet → 404, no IDOR leak. Both CreateVirtualAccountHandler and RequestVirtualAccountDestinationChangeHandler (via the shared Concerns\ResolvesSettlementDestination trait) resolve a nominated wallet uuid through the tenant's own RLS scope; a uuid that resolves to no row throws WalletNotFoundException (404) without ever confirming to the caller whether a foreign wallet exists on the platform at all.
  • Accounts' own ForwardsApprovedRequest implementation (Support\VirtualAccountDestinationForwarder) is registered in ForwardRegistry from AccountsServiceProvider::boot() as a lazy Closure factoryfn (): ForwardsApprovedRequest => $this->app->make(VirtualAccountDestinationForwarder::class) — never an eagerly-built instance, following the engine's boot-order rule (accounts boots before rails attaches its vendor-logging middleware). On approval it calls updateVirtualAccountDestination and applies the pending state; on rejection/expiry it clears pending_destination explicitly (the webhook mirror path never touches that column — present-key gate). ModuleBoundaryTest gained an accounts carve-out for the custody-controls Integration seam (previously carved for payments only), keeping the edge one-way (accounts → custody-controls).
  • No rejection event exists in the engine, so there is no push signal for a dead pending change. A request that goes rejected/expired fires no event accounts subscribes to. Mitigated read-time only: Concerns\ProjectsDeadDestinationChange, used by GetVirtualAccountHandler/ListVirtualAccountsHandler, masks a dead pending state out of the response at read time. Commands\ReconcileVirtualAccountDestinationCommand + …Handler exist for a durable clear, but nothing dispatches it today — no console command, no schedule entry. This is an accepted, tracked gap (see Accepted risks), not a design decision to skip the sweep permanently.
  • Both handlers write the VA mirror row immediately from the adapter's response (applyAuthoritative); Bridge's virtual_account.activity account_update/activation events then keep it in sync going forward.

3. Bridge webhook domain handlers (the mirrors' return path — onboarding, funding, accounts)

Each handler dispatches a bus command; none touch Eloquent directly (D52 unchanged).

Event(s)ModuleHandlerNotes
customer.created/customer.updated (Bridge-sourced)onboardingCustomerCreatedHandler/CustomerUpdatedHandler, source-branchedSame event-type strings Conduit already owns; the registry keys by string, so a Bridge delivery is distinguished by $event->source === 'bridge' inside the existing handlers rather than a second registration
customer.updated.status_transitioned, customer.deletedonboardingBridgeCustomerUpdatedHandlerBridge-unique mutations only
kyc_link.created, kyc_link.updated, kyc_link.updated.status_transitionedonboardingBridgeKycLinkUpdatedHandlerCorrelates on client_reference_id; re-GETs the link every delivery since customer_id population timing is undocumented; refreshes the provider_onboarding_links row on every delivery
virtual_account.activity.created/.updatedfundingBridgeVirtualAccountActivityHandlerOne transactions row per deposit_id; synthetic provider_sequence lifecycle ordinal (funds_scheduled=10 < funds_received=20 < in_review=25 < payment_submitted=30 < payment_processed=40; refund_in_flight=50 < refunded/refund_failed=60) since Bridge VA events carry no native sequence and several share one deposit_id
bridge_wallet.activity.created/.updatedaccountsBridgeWalletActivityHandlerPulls the authoritative wallet and re-projects account_balances; records an audit-only deposit correlation

Design deviation, recorded honestly: the design of record proposed accounts stamping destination_tx_hash on the funding deposit row from the wallet-activity handler (a cross-module write). That was not built as specifiedBridgeWalletActivityHandler publishes no integration seam for a cross-module write; it only logs an audit-only correlation. destination_tx_hash is instead self-sourced entirely within funding, populated by TransactionMirror from the same virtual_account.activity receipt payload BridgeVirtualAccountActivityHandler already reads. The net behaviour (the column gets populated) is unchanged; the cross-module write this ADR's design doc sketched was simplified away during implementation because funding already had the data on its own feed.

Provider tenant resolution for customer.*/virtual_account.* continues to route on client_reference_id = tenant uuid (unchanged from D118); the third resolver path below closes a gap specific to kyc_link events.

A Bridge kyc_link carries no client_reference_id at all — only the customer object does — and its customer_id populates only once the hosted flow progresses past creation. The very first kyc_link.created for a brand-new onboarding could therefore miss both of BridgeTenantResolver's existing paths (the direct client_reference_id uuid match, and the provider_customers customer_id/on_behalf_of fallback). A new CENTRAL table, provider_onboarding_links — same shape as provider_customers (plain bigint tenant_id, no FK, never RLS-policied, unique (provider, external_id), owned by onboarding as Models\ProviderOnboardingLink) — is keyed by the kyc_link id itself rather than the customer ref. It is written by SubmitOnboardingApplicationHandler at submit time (before any webhook can arrive) and kept fresh by BridgeKycLinkUpdatedHandler on every delivery.

BridgeTenantResolver::resolve() gains a third resolution path, tried only when event_category === 'kyc_link' AND the first two paths both missed, keyed by the event's own event_object_id:

  1. event_object.client_reference_id as a uuid → direct Tenant lookup.
  2. provider_customers on (provider='bridge', external_customer_id) derived from event_object.customer_id ?? event_object.on_behalf_of ?? (customer category ? event_object_id : null).
  3. (new) provider_onboarding_links on (provider='bridge', external_id=event_object_id)kyc_link category only.

Both central tables are read by table name on the DB facade (not by importing the model) — the same D52-sanctioned pattern the resolver already used for provider_customers, so no new CqrsBoundaryTest/ModuleBoundaryTest allowance was needed. Two new internal commands, LinkProviderCustomerCommand and LinkProviderOnboardingLinkCommand, are allow-listed as internal/system-only writes to these maps.

Alternatives rejected

  • A Bridge-shaped account model (treat every VA as transfer-only, model destination as a Bridge-only concept). Rejected: this is the D94 mistake repeated one level down — a provider property leaking into the neutral contract, and it would make a future Sovera fiat-holding VA unrepresentable without another shape fork. Settlement mode is a per-account fact; keeping it provider-neutral costs one enum and one nullable DTO field.
  • Notional-amount quorum for VA re-nomination (assign an arbitrary placeholder amount so the existing amount-tiered path could band it). Rejected: an invented amount either over- or under-states the actual risk of a destination change (which is unbounded — every future deposit, not one transfer), and it would corrupt the engine's FX-valuation audit trail with a number that means nothing. A dedicated fixed-eyes path keeps the audit trail honest about what was actually evaluated.
  • Skipping the quorum entirely (step-up alone, no consensus). Rejected per the 2026-09-03 user decision: a destination change is money-sensitive by both step-up AND quorum, not either one alone — a single compromised or coerced session with step-up should not be able to silently re-route every future deposit on a VA.

Consequences

  • ManagesVirtualAccounts is now a 4-method interface (getVirtualAccount, listVirtualAccounts, createVirtualAccount, updateVirtualAccountDestination); Conduit implements all four (the last two as terminal stubs), Bridge implements all four for real. The D118 VA-creation shim (submitApplication(type=VirtualAccount)) is gone — any future adapter must implement createVirtualAccount directly, not smuggle VA creation through the onboarding-application path.
  • The approval-quorum engine now serves two structurally different request shapes on one table family: amount-tiered (consumer IS NULL, FX-priced) and fixed-eyes (consumer = a specific ApprovalConsumer, no valuation). custody_approval_policies and custody_approval_requests both required migrations to make amount/asset nullable and add the consumer discriminator column — read/write code for the two families must not be crossed (documented as a golden rule + common mistake in approval-quorum-development).
  • Bridge's registered capability surface widens: ManagesVirtualAccounts is now genuinely served (create + update-destination), not merely the read half plus an onboarding-application shim — narrowing the semantic-mismatch gap the provider-landscape wiki page's §3 footnote 18 and §7(a) previously flagged as unresolved.
  • Three new domain events ship on accounts: VirtualAccountDestinationChangeRequested, …Applied, …Rejected (docs/EVENTS.md regenerated via php artisan events:catalog — 70 events across 15 modules, up from 67).
  • Still zero live traffic. Bridge remains ungranted; a baseline-jurisdiction tenant hits Conduit's new 422 PROVIDER_OPERATION_NOT_SUPPORTED stub on the create/re-nominate endpoints (a routing-unrelated tenant would separately see 403 PROVIDER_NOT_ROUTABLE, unchanged from D118) — either way this PR is additive scaffolding, not a behaviour change on deploy.
  • The three new accounts events have no subscriber in PR A. VirtualAccountDestinationChange Requested/…Applied/…Rejected fire (and are audited) but nothing listens — the design's "notification to tenant admins" is not delivered. A notification-module listener is deferred to PR B/C (see Follow-ups); accounts imports no consumer today, by design (D52-style module boundary), so wiring the listener is notification's own later PR, not a gap in this one.
  • Wallet nomination now enforces same-Bridge-customer ownership; a destination payload naming a wallet from a different Bridge customer fails with 422 PROVIDER_VALIDATION_FAILED.
  • last_destination_change is now persisted durably on approved/rejected/expired outcomes and synthesized on read for dead-but-not-yet-cleared pending states; the resource exposes it on VA detail reads.
  • CreateVirtualAccountCommand derives its Idempotency-Key with a per-attempt nonce; two deliberate creates with identical body provision two separate accounts (preventing accidental deduping of intentional retries).
  • RequestVirtualAccountDestinationChangeCommand opens the approval-quorum request with sourceRef = <VA uuid>; tenant account-scoped fixed-eyes policy overrides are now honored via this source ref.

Accepted risks (carried forward, tracked)

  • No rejection event in the engine → no push signal for a dead pending destination change. Mitigated by a read-time projection only (ProjectsDeadDestinationChange); the durable-clear command (ReconcileVirtualAccountDestinationCommand) exists but nothing schedules or invokes it today — there is NO scheduled sweep. A tenant that only ever reads via the API is unaffected (the mask is transparent); a direct DB read or a future consumer of the raw row would see a stale pending_destination until something dispatches the reconcile command. Tracked as an immediate follow-up, not a design decision to omit sweeping forever.
  • destination_tx_hash cross-module write was simplified to a same-module write. The design doc's proposed accounts→funding stamp was not built; funding self-sources the field from its own feed instead (see §3 above). Net behaviour is equivalent, but a reader of the original design doc should not expect to find an accounts-side write to the transactions table — there is none.
  • Foreign-wallet nomination is 404, matching the existing IDOR posture — not a new risk, but worth restating: this makes a wallet-nomination timing side-channel (existence-probing via response latency) the same residual risk every other RLS-scoped 404 in this app already carries; no new exposure introduced.
  • Route-pair admissibility is validated only inside the Bridge adapter, not defense-in-depth at the accounts layer. A config error in rails.bridge.virtual_account_routes (an admitted pair that shouldn't be) would not be caught by a second check in accounts. Consistent with the seam's existing single-point-of-validation convention (the adapter owns provider-shape rules), not a new pattern, but recorded here since it's the first time a client-supplied destination reaches provider-specific route rules this directly.
  • Mirror provider CHECK constraints are baked at migrate time — 'bridge' is admitted on a fresh database today, but NOT on an already-migrated one. FinanceProvider::Bridge has existed since D118, but the accounts and onboarding provider CHECK constraints were built from FinanceProvider::values() as a literal list at the time their migrations last ran — the exact same mechanism that required a dedicated extend_provider_check_constraints_for_utila migration when Utila was added. No equivalent ..._for_bridge migration exists in accounts or onboarding on this branch. A fresh migrate admits 'bridge' rows fine; staging and any other already-migrated environment will reject a 'bridge'-provider row (accounts virtual_accounts/ wallets, onboarding customers/applications) until such a migration ships. Funding's transactions table carries no provider CHECK constraint at all (a plain string column) and is not at risk. This is a hard precondition on the grant flip (PR C): PR A/B must not be granted before a re-add migration widens every already-migrated environment's CHECK constraints — tracked as an immediate follow-up below, and PR C's own scope per the D118 Go-live section already names the equivalent CHECK-widening step for its own migrations; this ADR flags accounts + onboarding specifically as needing it too, ahead of any grant.

Status

Accepted for PR A's scope (this record). Finalised with PR C (the grant flip), when this file's Consequences/Follow-ups sections will be extended with PR C's own outcomes — see the D118 precedent of a single ADR spanning an adapter increment and its later go-live grant. PR B (payments

  • custody alignment) ships between this record and its finalisation; it does not require its own ADR (it is domain alignment of the same kind as PR A, under the same decision).

Go-live preconditions, restated from D118 + this PR (do not consider Bridge grantable until all hold): D118's preconditions (production BRIDGE_API_KEY; a webhook endpoint registered with Bridge, its PEM in SSM; Bridge Legal & Compliance approval + geo-exclusion clearance for custodial wallets; a production canary tenant, since Bridge's sandbox does not exercise money movement or webhooks at all; the features:sync provider-grant prune/revoke-delete decision, still unimplemented) — plus, from this PR: the accounts/onboarding CHECK-constraint re-add migrations above; rails.bridge.virtual_account_routes confirmed against the live payment-routes page; the ReconcileVirtualAccountDestinationCommand sweep actually scheduled (or an explicit decision that read-time masking alone is sufficient for go-live, recorded in PR C); and PR B's domain alignment (explicit transfer source, the custody wallet-ref resolver arm) shipped and merged, per D118's "domain alignment precedes the grant-flip PR" sequencing decision.

Follow-ups (tracked)

Immediate (precede or ship alongside PR C):

  • Schedule (or explicitly reject scheduling) the ReconcileVirtualAccountDestinationCommand sweep — today it is dead code, invoked by nothing.
  • Wire a notification listener for VirtualAccountDestinationChangeRequested/…Applied/ …Rejected so a tenant admin actually gets notified of a re-nomination (the design's original intent) — today the events fire and are audited but have zero subscribers; the notification module subscribes to them in a later PR (accounts imports no consumer, by design).
  • Ship the accounts + onboarding provider CHECK-constraint re-add migrations for 'bridge' (the Utila extend_provider_check_constraints_for_utila precedent) — a hard precondition on any grant, not merely a nice-to-have.
  • wiki/engineering/bridge/flow-alignment.md's PLANNED rows for the settlement-mode + VA create/re-nominate flows flip to SERVED once both this PR (A) and the Bridge digest cluster on branch docs/bridge-docs-digest (PR #128) have merged — that page is out of this PR's ownership (it lives on a separate branch); recorded here so whoever merges #128 second knows to make the flip.
  • PR B (payments + custody alignment: neutral transfer source/destination, payout source wallet, order-maker FormRequest, custody wallet-ref resolver bridge arm) — see design §4, plan row B.
  • PR C (the grant flip itself: features:sync reconcile-delete, the four assignIn lines, RailsServiceProvider::DEFAULT_PROVIDER = 'bridge', the umbrella RailsProvider bind deletion) — see D118 Go-live.

Longer-horizon (unaffected by this ADR, restated from D118):

  • Per-source webhook registry — BridgeTenantResolver living in the webhooks module rather than being folded into onboarding's ProviderCustomerTenantResolver is a documented deviation from the onboarding/accounts/fraud domain-owned-resolver pattern (webhooks cannot import a domain module, D52/D45), tracked as an open question in the webhooks README, not resolved by this PR.
  • The Bridge coverage programme (B1–B10, S1/S2) — unaffected by this PR; see D118 Follow-ups and docs/tracking/multi-provider/00-provider-router.md §7.
  • Conduit-vocabulary + code removal — unaffected, later programme items per D118 item 9.

This decision builds on the router substrate (D94), the sealed Bridge adapter (D118), and the approval-quorum engine (D97); it does not restate them.


← Decision log index