Provider integration — the rail layer end to end
What this covers / who it's for. The system-level map of how the app talks to the external finance rail (Conduit): the outbound capability seam (
rails), the inbound webhook pipeline (webhooks), the mirror backbone that keeps local read models in sync (provider-mirror), the reconciliation backstop, and the tenant↔customer map. Read this before touching any module that moves money or mirrors provider state.
The governing posture (D38–D60, see the decision log): Conduit stays authoritative for financial state. The app never books balances or settles transactions itself — client writes go out through the rails seam, provider truth comes back through webhooks, and the local tenant-scoped mirror tables are read models, healed by a scheduled reconcile sweep.
The end-to-end loop
Client writes never touch a mirror directly (the maker pattern): the handler records local
intent, calls the rail, stores the provider external_id, and lets the webhook (or the
reconciler) move the mirror.
Outbound: the rails capability seam
app-modules/rails/README.md has the current surface; the
multi-provider-development skill
teaches the pattern. The shape:
- One umbrella contract, 13 capabilities.
Modules\Rails\Contracts\RailsProvidercomposes 13 capability interfaces (OnboardsCustomers,ManagesCustomers,ManagesDocuments,VerifiesIdentity,ManagesWallets,ManagesVirtualAccounts,ManagesWalletSigners,ManagesSigningQuorum,ManagesOrders,ManagesPayouts,ManagesTransactions,ManagesRegisteredAddresses,ManagesWhitelistRecipients) pluskey(). Domain handlers type-hint the capability they need, never the vendor class. - One adapter today.
Modules\Rails\Conduit\ConduitRailsProviderimplements the umbrella over the first-party Saloon SDK atpackages/stables/conduit-sdk(Stables\Conduit\*;vendor/stables/conduit-sdkis just a Composer path-repo symlink). The provider identity is the typed FoundationFinanceProviderenum (D84) — the value that keys every mirror row and theconfig('rails.providers')registry. A second provider is additive (D60: replaceable, not concurrent, in V1). - Append-only vendor call log. Every HTTP attempt that produced a response is one row in
central
vendor_request_logs, written via Foundation'sSideEffectLogWriteron the unmanagedpgsql_logsconnection — a separate session, so the row survives a rolled-back command transaction. Bodies are scrubbed (config('rails.sensitive_fields')) then encrypted; transport failures logstatus=null, outcome=transport_error; retention israils:prune-vendor-logs(daily). - Neutral error translation.
ConduitExceptionTranslatormaps SDK exceptions to the neutralRailsExceptiontree:PROVIDER_UNAVAILABLE(503, retryable),PROVIDER_VALIDATION_FAILED(422),PROVIDER_REJECTED(422),PROVIDER_RESOURCE_NOT_FOUND(404). Callers and clients never see raw vendor prose; the provider correlation id rides as non-rendered diagnostics. - Dependency direction is law (D40/D52): rails depends only on Foundation + the SDK. Mirror and domain modules depend down on rails; rails has zero edges back.
Inbound: the webhooks pipeline
app-modules/webhooks/README.md +
the webhook-client-development skill.
POST /webhooks/{source} (no auth — the signature is the auth):
- Signature —
ConduitSignatureValidatordelegating to the SDKWebhookSignatureVerifier(HMAC-SHA256, ~300s replay tolerance, comma-separated secret rotation). Invalid → 401, never stored. - Dedupe —
DedupeByEventIdProfile, exactly-once on the stable(source, event_id)(not the per-attempt delivery id). - Store + ack —
InboundWebhookCallrow (encrypted payload/headers, central table, unique(source, event_id)index), then an immediate 200. Processing is never inline. - Queued job —
ProcessInboundWebhookJobon thewebhooksqueue (tries=5, escalating backoff). - Tenant resolution —
TenantResolverChainin central context, first non-null wins:ClientReferenceIdTenantResolver(tenant uuid in Conduit'sclientReferenceId), then onboarding'sProviderCustomerTenantResolver(provider customer id → the centralprovider_customersmap). Unresolvable events park withUnresolvedTenantException. - Tenancy init + routing —
tenancy()->initialize($tenant), thenWebhookEventRegistryroutes by event type to a handler a domain module registered from its providerboot()(the D52 registry inversion — webhooks never imports domain modules). Unknown types are logged and acked. - Handler → bus — the domain handler translates the event into commands
(
Upsert*MirrorCommand, …) viaCommandBus; it never touches domain Eloquent directly.
Out-of-order and staleness protection is deliberately not the webhook's job — that lives in the mirror upsert below.
The mirror backbone: provider-mirror
app-modules/provider-mirror/README.md +
the provider-mirror-development skill.
Every mirror write in the app flows through
Modules\ProviderMirror\Services\AbstractMirrorUpsert::upsert(MirrorEnvelope):
- Race-safe —
createOrFirston(provider, external_id)against the tenant-scoped partial unique index, thenlockForUpdate+ re-read. - Order-tolerant — the freshness guard skips stale envelopes:
provider_sequencewins when both sides carry it, elseprovider_updated_at; reconcilers bypass withapplyAuthoritative(). - Key-presence-gated column mapping — partial payloads only touch the keys they carry.
- Evented — a real change fires
ProviderMirrorUpdated(or a domain subclass) after commit, exactly once. Drift found by reconcilers is recorded in tenant-scopedmirror_drift(coarse field markers only — never raw PII values).
Concrete mirrors live in the domain modules: onboarding CustomerMirror/ApplicationMirror,
accounts WalletMirror/VirtualAccountMirror/WalletSignerMirror/SigningQuorumMirror,
funding TransactionMirror, payments OrderMirror/PayoutMirror/RegisteredAddressMirror/
WhitelistRecipientMirror. Projections (accounts account_balances, payments
order_transactions) are derived from mirrors, wholesale-overwritten, never incremented (D42).
Reconciliation: the hourly backstop
Webhooks can be missed; mirrors:reconcile-stale (hourly, ProviderMirrorServiceProvider) is
the D59 backstop. Each domain registers a ReconcilableMirror descriptor (entity, model,
reconciler, terminal statuses, staleness window) into the MirrorReconciliationRegistry from its
provider boot() — registered today: funding transactions; accounts wallets,
virtual_accounts, wallet_signers, signing_quorums. Since D93 the command is a thin
dispatcher onto the task-runner: a provider-mirror.reconcile_stale run
with one unit per sweep-eligible tenant on the tasks queue.
A reconciler pulls provider truth, diffs, records deduped mirror_drift, and self-heals
authoritatively — re-diffing after the heal so drift is only marked resolved when it truly is.
The coarse-status caveat. The base
AbstractMirrorReconciler::diff()compares only the status string. A concrete reconciler must overridediff()to compare its own columns, or column-level drift is silently missed — see onboarding'sApplicationReconciler(addsfailure_code+edd_required) andCustomerReconciler(no provider status at all; it comparestype/display_name) for the pattern.
The tenant↔customer map
provider_customers (owned by onboarding) is the central, pre-tenancy map
(provider, external_customer_id) → tenant_id. It exists solely so the webhook tenant resolver
can find the tenant before tenancy is initialized; it carries no client-facing data (that is
the tenant-scoped, RLS-policied customers mirror) and is written once from the KYB submit path.
Where to go deeper
| Topic | Read |
|---|---|
| Rails seam, adding a capability/provider | rails README; multi-provider-development skill |
| Webhook pipeline, adding an event handler | webhooks README; webhook-client-development skill |
| Mirror upsert/reconciler mechanics | provider-mirror README; provider-mirror-development skill |
| The four mirror-domain modules | onboarding, accounts, funding, treasury READMEs; the conduit-mirror-domains skill (treasury is table-less; payments also builds on the mirror framework but has its own payments-development skill) |
| Async execution behind the sweep | Async platforms |