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:
- 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). - 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
ProviderOperationNotSupportedException — HTTP 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 DTOModules\Rails\Data\SettlementDestination { kind: DestinationKind (Wallet|Address), asset, chain, walletRef, address, memo }.Modules\Rails\Data\VirtualAccountgainssettlementMode+destination. The Bridge mapper producesPassThrough+ a populated destination; the Conduit mapper producesHeld+ a null destination (a future Sovera adapter would also produceHeld). 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_accountsgainssettlement_mode(defaultheldfor existing rows, so Conduit-issued accounts are unaffected),destination(client-safe neutral jsonb), anddestination_wallet_id(nullable FK →wallets, populated only when the nomination is one of the tenant's own mirrored wallets).BalanceProjectoris untouched: apass_throughaccount's snapshot carries nobalanceskey, so the existing present-key gate already leaves the projection alone — no new branch was needed in the projector itself.ManagesVirtualAccountsgains two methods, additive on the existing interface:createVirtualAccount(ExternalRef $customer, VirtualAccountSpec $spec, string $idempotencyKey): VirtualAccountandupdateVirtualAccountDestination(ExternalRef $customer, ExternalRef $virtualAccount, SettlementDestination $destination): VirtualAccount(noIdempotency-Keyon the PUT — Bridge's idempotence page does not cover it). Bridge implements both directly againstPOST/PUT /customers/{c}/virtual_accounts; the D118submitApplication(type=VirtualAccount)shim is removed — VA creation no longer rides the onboarding-shaped application flow. Conduit stubs both withProviderOperationNotSupportedException(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_routeslists the allowed(source currency → {payment_rail, currency})pairs from Bridge's payment-routes page; a wallet nomination must additionally be abridgewallet of the same Bridge customer whose chain matches the route.Modules\Rails\Bridge\Mappers\SettlementDestinationMapper(invoked fromBridgeRailsProvider) 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(CreateVirtualAccountCommand→CreateVirtualAccountHandler) andPATCH /api/v1/accounts/virtual-accounts/{virtualAccount}/destination(RequestVirtualAccountDestinationChangeCommand), both under the tenant guard,RequiresProvider(FiatRails), andRequiresApprovedOnboarding.- 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 carriespending_destination(jsonb) +destination_change_request_uuidwhile the request is open. This is the engine's third consumer (payouts, custody transactions, now VA destination) — see theapproval-quorum-developmentskill. - 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 newFIXED_EYES_CONSUMERSlist (today:ApprovalConsumer::VirtualAccountDestination) to a distinctresolveFixedEyesPolicy()path: no FX, no banding,required_eyes = max(configured N, min_eyes_floor, MINIMUM_EYES=1), where the configured N is acustody_approval_policiesrow keyed by a non-nullconsumercolumn (a DISTINCT row family from the amount-tieredconsumer IS NULLrows on the same table) or the code default of 2 (dual control by default).OpenApprovalRequest.amount/.assetbecome 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-policyis raise-only for a fixed-eyes consumer:SetApprovalPolicyHandlerrefuses loweringrequired_eyesbelow 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 offrequired_eyesregardless of how it was resolved. - Ownership is proven under RLS before any provider round-trip — foreign wallet → 404, no IDOR
leak. Both
CreateVirtualAccountHandlerandRequestVirtualAccountDestinationChangeHandler(via the sharedConcerns\ResolvesSettlementDestinationtrait) resolve a nominated wallet uuid through the tenant's own RLS scope; a uuid that resolves to no row throwsWalletNotFoundException(404) without ever confirming to the caller whether a foreign wallet exists on the platform at all. - Accounts' own
ForwardsApprovedRequestimplementation (Support\VirtualAccountDestinationForwarder) is registered inForwardRegistryfromAccountsServiceProvider::boot()as a lazyClosurefactory —fn (): 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 callsupdateVirtualAccountDestinationand applies the pending state; on rejection/expiry it clearspending_destinationexplicitly (the webhook mirror path never touches that column — present-key gate).ModuleBoundaryTestgained an accounts carve-out for the custody-controlsIntegrationseam (previously carved forpaymentsonly), 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/expiredfires no event accounts subscribes to. Mitigated read-time only:Concerns\ProjectsDeadDestinationChange, used byGetVirtualAccountHandler/ListVirtualAccountsHandler, masks a dead pending state out of the response at read time.Commands\ReconcileVirtualAccountDestinationCommand+…Handlerexist 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'svirtual_account.activityaccount_update/activationevents 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) | Module | Handler | Notes |
|---|---|---|---|
customer.created/customer.updated (Bridge-sourced) | onboarding | CustomerCreatedHandler/CustomerUpdatedHandler, source-branched | Same 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.deleted | onboarding | BridgeCustomerUpdatedHandler | Bridge-unique mutations only |
kyc_link.created, kyc_link.updated, kyc_link.updated.status_transitioned | onboarding | BridgeKycLinkUpdatedHandler | Correlates 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/.updated | funding | BridgeVirtualAccountActivityHandler | One 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/.updated | accounts | BridgeWalletActivityHandler | Pulls 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 specified — BridgeWalletActivityHandler 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.
4. provider_onboarding_links — the kyc_link tenant-resolution gap (onboarding + webhooks)
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:
event_object.client_reference_idas a uuid → directTenantlookup.provider_customerson(provider='bridge', external_customer_id)derived fromevent_object.customer_id ?? event_object.on_behalf_of ?? (customer category ? event_object_id : null).- (new)
provider_onboarding_linkson(provider='bridge', external_id=event_object_id)—kyc_linkcategory 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
destinationas 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
ManagesVirtualAccountsis 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 implementcreateVirtualAccountdirectly, 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 specificApprovalConsumer, no valuation).custody_approval_policiesandcustody_approval_requestsboth required migrations to makeamount/assetnullable and add theconsumerdiscriminator column — read/write code for the two families must not be crossed (documented as a golden rule + common mistake inapproval-quorum-development). - Bridge's registered capability surface widens:
ManagesVirtualAccountsis 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.mdregenerated viaphp 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_SUPPORTEDstub on the create/re-nominate endpoints (a routing-unrelated tenant would separately see 403PROVIDER_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/…Rejectedfire (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_changeis 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.CreateVirtualAccountCommandderives itsIdempotency-Keywith a per-attempt nonce; two deliberate creates with identical body provision two separate accounts (preventing accidental deduping of intentional retries).RequestVirtualAccountDestinationChangeCommandopens the approval-quorum request withsourceRef = <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 stalepending_destinationuntil something dispatches the reconcile command. Tracked as an immediate follow-up, not a design decision to omit sweeping forever. destination_tx_hashcross-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 thetransactionstable — 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
providerCHECK constraints are baked at migrate time — 'bridge' is admitted on a fresh database today, but NOT on an already-migrated one.FinanceProvider::Bridgehas existed since D118, but the accounts and onboardingproviderCHECK constraints were built fromFinanceProvider::values()as a literal list at the time their migrations last ran — the exact same mechanism that required a dedicatedextend_provider_check_constraints_for_utilamigration when Utila was added. No equivalent..._for_bridgemigration exists in accounts or onboarding on this branch. A freshmigrateadmits'bridge'rows fine; staging and any other already-migrated environment will reject a'bridge'-provider row (accountsvirtual_accounts/wallets, onboardingcustomers/applications) until such a migration ships. Funding'stransactionstable carries no provider CHECK constraint at all (a plainstringcolumn) 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
ReconcileVirtualAccountDestinationCommandsweep — today it is dead code, invoked by nothing. - Wire a notification listener for
VirtualAccountDestinationChangeRequested/…Applied/…Rejectedso 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
providerCHECK-constraint re-add migrations for'bridge'(the Utilaextend_provider_check_constraints_for_utilaprecedent) — 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 branchdocs/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
bridgearm) — see design §4, plan row B. - PR C (the grant flip itself:
features:syncreconcile-delete, the fourassignInlines,RailsServiceProvider::DEFAULT_PROVIDER = 'bridge', the umbrellaRailsProviderbind deletion) — see D118 Go-live.
Longer-horizon (unaffected by this ADR, restated from D118):
- Per-source webhook registry —
BridgeTenantResolverliving in the webhooks module rather than being folded into onboarding'sProviderCustomerTenantResolveris 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.