Skip to main content

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\RailsProvider composes 13 capability interfaces (OnboardsCustomers, ManagesCustomers, ManagesDocuments, VerifiesIdentity, ManagesWallets, ManagesVirtualAccounts, ManagesWalletSigners, ManagesSigningQuorum, ManagesOrders, ManagesPayouts, ManagesTransactions, ManagesRegisteredAddresses, ManagesWhitelistRecipients) plus key(). Domain handlers type-hint the capability they need, never the vendor class.
  • One adapter today. Modules\Rails\Conduit\ConduitRailsProvider implements the umbrella over the first-party Saloon SDK at packages/stables/conduit-sdk (Stables\Conduit\*; vendor/stables/conduit-sdk is just a Composer path-repo symlink). The provider identity is the typed Foundation FinanceProvider enum (D84) — the value that keys every mirror row and the config('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's SideEffectLogWriter on the unmanaged pgsql_logs connection — a separate session, so the row survives a rolled-back command transaction. Bodies are scrubbed (config('rails.sensitive_fields')) then encrypted; transport failures log status=null, outcome=transport_error; retention is rails:prune-vendor-logs (daily).
  • Neutral error translation. ConduitExceptionTranslator maps SDK exceptions to the neutral RailsException tree: 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):

  1. SignatureConduitSignatureValidator delegating to the SDK WebhookSignatureVerifier (HMAC-SHA256, ~300s replay tolerance, comma-separated secret rotation). Invalid → 401, never stored.
  2. DedupeDedupeByEventIdProfile, exactly-once on the stable (source, event_id) (not the per-attempt delivery id).
  3. Store + ackInboundWebhookCall row (encrypted payload/headers, central table, unique (source, event_id) index), then an immediate 200. Processing is never inline.
  4. Queued jobProcessInboundWebhookJob on the webhooks queue (tries=5, escalating backoff).
  5. Tenant resolutionTenantResolverChain in central context, first non-null wins: ClientReferenceIdTenantResolver (tenant uuid in Conduit's clientReferenceId), then onboarding's ProviderCustomerTenantResolver (provider customer id → the central provider_customers map). Unresolvable events park with UnresolvedTenantException.
  6. Tenancy init + routingtenancy()->initialize($tenant), then WebhookEventRegistry routes by event type to a handler a domain module registered from its provider boot() (the D52 registry inversion — webhooks never imports domain modules). Unknown types are logged and acked.
  7. Handler → bus — the domain handler translates the event into commands (Upsert*MirrorCommand, …) via CommandBus; 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-safecreateOrFirst on (provider, external_id) against the tenant-scoped partial unique index, then lockForUpdate + re-read.
  • Order-tolerant — the freshness guard skips stale envelopes: provider_sequence wins when both sides carry it, else provider_updated_at; reconcilers bypass with applyAuthoritative().
  • 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-scoped mirror_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 override diff() to compare its own columns, or column-level drift is silently missed — see onboarding's ApplicationReconciler (adds failure_code + edd_required) and CustomerReconciler (no provider status at all; it compares type/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

TopicRead
Rails seam, adding a capability/providerrails README; multi-provider-development skill
Webhook pipeline, adding an event handlerwebhooks README; webhook-client-development skill
Mirror upsert/reconciler mechanicsprovider-mirror README; provider-mirror-development skill
The four mirror-domain modulesonboarding, 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 sweepAsync platforms

← Engineering wiki index