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.
  • Concurrently granted adapters — the first concurrent routing. Modules\Rails\Conduit\ConduitRailsProvider implements the full 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). A second adapterModules\Rails\Sumsub\SumsubRailsProvider, the first non-Conduit adapter and a PARTIAL one: it serves only the 5 onboarding-group capabilities (KYC/KYB + the authoritative verification pull), not ManagesDocuments (Sumsub collects docs via its own WebSDK) — was registered in 2.1c-1 and, since increment 2.1d-2b (ADR D95), is now GRANTED the Onboarding primary for GB + EU (conduit retained as a permitted fallback). So the Onboarding group resolves to sumsub for GB/EU tenants and to conduit everywhere else (and for every non-Onboarding group) — the substrate's first concurrent multi-provider routing. The provider identity is the typed Foundation FinanceProvider enum (D84 — conduit + sumsub + utila) — the value that keys every mirror row and the config('rails.providers') registry. A second provider is additive — and, since D94 (superseding D60's "replaceable, not concurrent"), providers serve concurrently, routed per capability + jurisdiction (see "Provider routing" below). Ship-state: the GB/EU grant is code-live but pre-product and production-gated — see the ship-state note below.
  • 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. Each provider ships its own SDK→RailsException translator (ConduitExceptionTranslator, SumsubExceptionTranslator, and since 2.2a UtilaExceptionTranslator) mapping to the neutral 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. Since 2.1c-1 the vendor-log outcome is provider-agnostic too — a shared Contracts\ExceptionTranslator (outcomeFor(Throwable)) lets each provider's translator classify its own logged outcome.
  • A registered-not-adapted provider (Utila, 2.2a). A vendor can be made a known provider — vendor-logged and translatable — before any adapter exists. Increment 2.2a registered Utila (custody) as exactly that: the FinanceProvider::Utila enum case, a config('rails.providers') connector entry (so its calls are vendor-logged by class), a wired UtilaExceptionTranslator, and the provider CHECK widened to admit utila on the accounts (×4) + onboarding (×3) mirror tables. At 2.2a it was NOT adapted, NOT granted, NOT routed (ProviderAdapterRegistry::has('utila') was false). The ceremony-less DTOs (2.2b), the adapter (2.2c), webhooks (2.2d), and the go-live grant (2.2f-3, ADR D98 — Utila is now the Custody primary across all six jurisdictions) followed (see the custody tracker §5a and the next two bullets).
  • Ceremony-less custody DTOs (increment 2.2b, ADR D96). The custody signer/quorum DTOs were traced from Conduit's provider-hosted co-sign ceremony (a verificationUrl a human visits, a RosterCeremony handle). The S0.5 spike proved that model dead for the MPC vendor set (Utila/Fireblocks/Fordefi — signing is an API vote, not a hosted URL), so the hosted surface — with no neutral representation — was dropped: SignerAddResult / RosterChangeResult / QuorumChangeResult now carry a neutral ChangeStatus (applied | pending_approval | rejected) + an optional opaque approvalRequestRef; Wallet adopts the Foundation Chain enum + a neutral WalletStatus, and RegisteredAddress adopts Chain. All chain/status mapping is fail-loud at the adapter (unknown chain → ProviderValidationException/422; exhaustive status match), and both client-input chain WRITE paths (createWallet, registerAddress) validate via Rule::enum(Chain::class) → 422. This changed the client contract on three JSON:API resources (signer-invitation, roster-ceremonyroster-change, quorum-change) — code-live but pre-product (no live tenant). Custody is now routed to Utila (ADR D98 — see the custody bullets below); on a Conduit override path the dropped hosted signer/approval URL is not re-surfaced (a documented gap for that escape-hatch path). The Utila adapter (2.2c, next bullet), webhooks, and the go-live grant have since shipped. See the decision log (D96) and the custody tracker §5a/§5b.
  • The Utila custody adapter — registered, not granted (increment 2.2c). Modules\Rails\Utila\UtilaRailsProvider is now a built, registered PARTIAL custody adapter over the in-repo stables/utila-sdk — the sealed-adapter shape (its own call() funnel, Utila\Mappers\*, UtilaExceptionTranslator, and the fail-loud bidirectional UtilaChainMap between the Foundation Chain enum and Utila's networks/{slug}-mainnet vocabulary). It serves the Custody group — wallets read + create, the address book (register/read/list), and the net-new custody-transaction seam ManagesCustodyTransactions (getTransaction/listTransactions/initiateTransaction
    • the per-transaction API voteOnTransaction, an 8-state neutral TransactionStatus with the raw provider state always preserved). Because Utila is a Console-managed policy engine with no API for signer/quorum management or wallet rotation, the adapter advertises those interfaces but stubs 11 methods with the net-new ProviderOperationNotSupportedException (a neutral 422 — distinct from the 500 AdapterMissingCapabilityException wiring fault). Ship-state: Custody now routes to Utila (go-live grant ADR D98, across ZA/NG/KE/GB/EU/US); at 2.2c the adapter was registered-not-granted. The webhooks (2.2d) and the fail-closed RequiresProviderCustody gates on the 10 custody write commands (2.2e) have since shipped. The go-live grant (2.2f-3, ADR D98) has since shipped too: the Utila-native write path landed (the multi-vault service-account registry, provider-aware custody writes, and the real Stage-0 in-app quorum forward + reclaim — increments 2.2f-0…2.2f-2), and the D98 grant flips the co-dependent Custody group to utila. conduit is retained ONLY as an override-only permitted key (an explicit per-tenant tenant_provider_overrides escape hatch for legacy Conduit-modeled signer/quorum/wallet flows) — not an automatic custody-tx fallback (Conduit lacks ManagesCustodyTransactions, so a custody-tx op under a Conduit override fails loud). The grant is live-in-code + deploy-gated (features:sync + the vault/secret write-path steps) and pre-product — not serving real production custody money yet; an accepted travel-rule originator-field drop rides the flip (summarised, not restated, in the custody tracker §5a and ADR D98). See the custody tracker §5a and the rails README ("The Utila adapter").
  • The Utila custody webhooks — pull-to-hydrate, registered-not-granted (increment 2.2d-2). The inbound half of custody now exists: the utila webhook source (asymmetric RSA-PSS validator) + four custody-transaction handlers (TRANSACTION_CREATED / _STATE_UPDATED / _AML_SCREENING_RESULT_READY / TEST) that accounts registers into the webhook registry. They are pull-to-hydrate: a Utila webhook is a PII-light "changed" signal, so the handler reads only the resource id, pulls the authoritative transaction via ManagesCustodyTransactions::getTransaction, and upserts the custody_transactions mirror from the typed DTO (guaranteeing exact-string amount, D27) — the same pull-authoritative shape as the Sumsub UBO mirror below. Tenant routing is a source-scoped, WALLET-keyed UtilaWalletTenantResolver (increment 2.2f-1: business wallets share one vault, so it maps the WALLET resource vaults/{v}/wallets/{w} → tenant via provider_customers, written by the accounts CreateWalletHandler; abstains unless the event source is utila, and abstains for a transaction resource — deferred to sub-PR d). The AML handler is decoupled from compliance by a domain event — accounts emits CustodyTransactionAmlScreened and compliance's listener opens the case (never auto-freezes; accounts never imports compliance). Ship state: Custody is now granted to Utila (ADR D98) — the pull resolves the routed custody provider (now utila). Live-in-code + deploy-gated (provisioning a Utila vault + per-vault SA secrets is a deploy-time write-path step) and pre-product — not serving real production custody traffic yet. Detail in the accounts README ("Utila webhook integration — pull-to-hydrate"), the webhooks README, and the custody tracker §5a.
  • 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.

Provider routing (D94)

The rails seam above answers how the app calls a provider; provider routing answers which provider serves a given capability for a given tenant. D94 (superseding D60's "replaceable, not concurrent") lays the substrate for concurrent providers, resolved at runtime on one axis — (CapabilityGroup × tenant jurisdiction), the routing analogue of the feature engine's "jurisdiction gates, tier entitles". Modules\Foundation\ValueObjects\CapabilityGroup is the routing unit: 11 groups (Onboarding, Fraud, TransactionMonitoringCrypto, TransactionMonitoringFiat, AmlScreening, TravelRule, Custody, Conversion, FiatRails, Cards, RegulatoryFiling) — co-dependent capabilities resolve to one provider together.

Two tables drive the decision (both realized by features:sync from the code-owned JurisdictionProviderRegistry):

  • jurisdiction_provider_grants (central, owned by the jurisdictions module) — the allow-list: one row = "this jurisdiction licenses this provider key for this capability group", carrying is_primary, data_region, dpa_ref. A partial-unique index enforces exactly one primary per (jurisdiction, capability_group).
  • tenant_provider_overrides (tenant-RLS, owned by the features module) — a per-tenant pin that may select only a provider among the jurisdiction's permitted keys (compliance beats commerce), optionally time-boxed.

Modules\Features\Support\ProviderRouter::resolve(CapabilityGroup, Tenant): string returns a provider key string — never a rails type (ModuleBoundaryTest forbids features/jurisdictions → rails in both directions; the key→adapter map is the one rails-owned piece). It is fail-closed / compliance-first: a blank or inactive jurisdiction, no grant, an override outside the permitted set, no primary, or a residency mismatch (data_region + dpa_ref) all raise ProviderNotRoutableException (PROVIDER_NOT_ROUTABLE, 403) carrying a machine-readable, operator-only reasonnever a silent fallback to a default provider.

On the command path, a command implementing Foundation's RequiresProvider marker is gated before its handler by the ProviderGuard (features' BusProviderGuard); the bus audits command.provider_denied (with the reason) when routing refuses — the same shape as the RequiresFeature gate.

The first real grant (D95). Through increment 2.0 only conduit was granted, so every (group × jurisdiction) resolved to conduit. Increment 2.1d-2b adds the first non-Conduit primary: one assignIn(['GB','EU'], CapabilityGroup::Onboarding, ['sumsub','conduit'], 'sumsub') declaration (realized by features:sync) makes Sumsub the Onboarding primary for GB + EU while conduit stays primary elsewhere and for every other group — two providers serving one capability group, routed by jurisdiction. It shipped with data_region = null (no residency enforcement — matching Conduit's incumbent posture, a deliberate pre-product deferral), so assertResidency imposes no in-region constraint on that grant. See D95 and the onboarding go-live gates.

The second real grant — fraud / device intelligence (D102). FeaturesServiceProvider declares assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Fraud, ['fingerprint','seon'], 'fingerprint')Fingerprint primary + SEON permitted in all six jurisdictions. Unlike Onboarding/Custody this routes an off-umbrella capability: device-risk screening (AssessesFraudRisk, FinanceProvider cases Fingerprint + Seon) is segregated from the 13 RailsProvider umbrella capabilities — it never moves money, so it rides its own capability seam and its own CapabilityGroup::Fraud, routed by the same jurisdiction axis but bound outside the umbrella adapter (fraud module owns the enforcing bus gate; see module catalog + the fraud README). The grant is deploy-gated + default-disabled: the fraud.device-risk meter ships entitled at no tier and central-context screening is config-gated off (fraud.central_provider, default null), so the flip changes nothing until ops enables it. See D102 and the fraud/device-login design. Because the tenant SPAs are separate repos (D12), the frontend side of this seam — which vendor agent to load and how the collected device token reaches these endpoints — is its own contract: frontend device-fingerprinting.

The router also drives container binding. RailsServiceProvider binds the 13 rails capability interfaces router-driven (increment 2.0): per container-make each capability resolves the provider key routed for its CapabilityGroup — via rails' Support\CapabilityGroupMap → Foundation's Bus\Contracts\ProviderKeyResolver seam (implemented by features' Support\RoutedProviderKeyResolver over the same ProviderRouter) — then the concrete adapter for that key via rails' Support\ProviderAdapterRegistry, the "register a provider" point (provider key → adapter class, seeded conduit). The registry holds the ProviderAdapter marker, so a partial adapter that serves only some capability groups can register (increment 2.1a; RailsProvider extends ProviderAdapter). Two fail-loud paths guard the bind (both 500, never a silent fallback): a routed key with no registered adapter (PROVIDER_ADAPTER_NOT_REGISTERED), and a routed key whose adapter does not implement the requested capability (ADAPTER_MISSING_CAPABILITY — a grant routed to a vendor that does not serve that capability). Crucially the bind-time resolver is fail-SAFE (falls back to the default provider on any not-routable signal, never throws), deliberately distinct from the fail-CLOSED command-path ProviderGuard: binding must never break, so compliance refusal stays the bus gate's job.

Branching on a partial adapter safely (increment 2.1d-1). A consumer that must branch on whether the routed provider serves a capability — rather than call it — cannot typehint the capability interface, because the router-driven bind fail-LOUD throws ADAPTER_MISSING_CAPABILITY whenever the routed provider is a partial adapter that does not serve it. Rails' Contracts\ProviderCapabilityInspector (routedProviderSupports(capabilityInterface): bool) answers the same question without constructing the adapter — a pure is_a() on the registered adapter class-string, over the same ProviderKeyResolverProviderAdapterRegistry seam — so it never instantiates and never throws. This is what lets a doc-less WebSDK provider (Sumsub, no ManagesDocuments) be routed for onboarding without 500ing the document-forward path: onboarding's KYB submit maker and the documents ForwardDocumentToProviderJob both gate the async provider forward on the inspector, so the encrypted vault store always happens while the forward is skipped for a doc-less route. See the rails README and docs/tracking/multi-provider/10-onboarding-identity.md §4a.

Ship-state — the first non-Conduit primary grant shipped (increment 2.1d-2b); code-live, pre-product, production-gated. The 13 rails capability binds are router-driven — a jurisdiction_provider_grants flip routes a capability to a different registered adapter. sumsub was registered in 2.1c-1 and, since 2.1d-2b (ADR D95), is GRANTED the Onboarding primary for GB + EU (conduit a permitted fallback), so Onboarding resolves to sumsub for GB/EU tenants and to conduit everywhere else / for every other group — the first time behaviour differs from the pre-routing single-provider seam, and only there. The bind-time resolver is fail-SAFE (default = conduit), still safe because conduit remains a permitted key in every jurisdiction. Since increment 2.1d-1 the RequiresProvider marker is on onboarding's SubmitOnboardingApplicationCommand + InitiateVerificationCommand (requiredProviderGroup(): Onboarding), so the fail-CLOSED command-path ProviderGuard refuses an unroutable tenant on those two before their handlers run. The GB/EU grant is NOT serving real production traffic — three go-live gates remain (the frontend embed_mode branch, Sumsub EU/UK residency + DPA binding since the grant shipped data_region null, and the beneficial-owner reconciler + stuck-pending runbook). Since increment 2.2e the marker also gates the 10 custody write commands (the 9 accounts wallet/signer/quorum makers + the payments RegisterAddressCommand, which routes with Custody). Wiring RequiresProvider onto the remaining provider-sensitive commands (the fiat-rails/conversion/funding makers — plus an arch guard and the off-bus resolution paths) stays a hard prerequisite before the next committed non-conduit vendor — see the deep dive: docs/tracking/multi-provider/00-provider-router.md (§4.1 hard prerequisites; §4.2 the one-line dormant-vendor commit path) and 10-onboarding-identity.md (the go-live gates), the decision log (D94 substrate, D95 the first grant), and the surface in the foundation, features, rails, and jurisdictions READMEs.

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. Signature — a per-source validator, fail-closed → invalid → 401, never stored. Conduit's ConduitSignatureValidator delegates to the SDK WebhookSignatureVerifier (single Signature header, HMAC-SHA256, ~300s replay tolerance, comma-separated secret rotation). The sumsub source (2.1c-1) uses SumsubSignatureValidator, a two-header scheme — digest in X-Payload-Digest, algorithm in X-Payload-Digest-Alg (HMAC-256/512/1). The utila source (2.2d-2) uses UtilaSignatureValidator, an asymmetric scheme — RSASSA-PSS/SHA-512 over Utila's published public key (no shared signing_secret), verified via the utila-SDK verifier. The fingerprint source (item 11 phase 5, D102) uses FingerprintSignatureValidator, a single-header HMAC-SHA256 — FPJS-Event-Signature: v1=<hmac-sha256-hex>, secret in the Fingerprint SDK config (SEON ships webhook-less — its scheme is unverified, deferred). Four signature shapes on one seam proves the validator is genuinely per-provider without a pipeline change.
  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, or — since increment 2.1d-2c — Sumsub's externalUserId, the same stamped uuid echoed back under a different key), 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/ BeneficialOwnerMirror, 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).

The onboarding beneficial_owners mirror (Sumsub UBO ownership graph, increment 2.1c-2) is the first pull-authoritative mirror: the Sumsub webhook is a PII-light "changed" signal, so its handlers pull the authoritative state (FetchesVerification/VerifiesBusinesses, depth-bounded) and upsert from the pull rather than from the webhook body — and a companyStructureChanged re-pull soft-deletes departed edges so a former owner can't linger in the ≥25% set. The local ≥25% look-through (a cycle-safe DFS in onboarding's BeneficialOwnershipService) is now consumed by a local application-approval gate (increment 2.1d-2a): Support\ApplicationApprovalEvaluator flips a KYB Application to approved when the company's own review is approved and every ≥25% / control-prong owner is verified and no edd_required/rfi_requirements is open — reusing the same mirror writer as the Conduit application.approved webhook, idempotently and order-independently (lockForUpdate() CAS driven from both pull handlers). This is the approval path for a provider that emits no application.approved webhook (Sumsub) — the counterpart to the Conduit webhook path — and it emits onboarding's first domain event, BeneficialOwnerVerificationCompleted (public uuids + a non-PII UBO summary, never owner names; the Conduit webhook path does not emit it). Ship-state: since increment 2.1d-2b Sumsub is granted the Onboarding primary for GB + EU (see the ship-state note above), so this gate is LIVE for GB/EU tenants (pre-product, production-gated) and stays dormant wherever conduit is primary — a Conduit-routed tenant sees no change. Increment 2.1d-2c then shipped the piece the grant needed to actually receive traffic: webhooks/sumsub is now a registered route (the sumsub webhook-client config had existed since 2.1c-1 but with no route — no Sumsub delivery was ever live before this), and an externalUserIdapplicantId identity reconciliation (additive central-map row + a guarded mirror rekey, run on every pull-handler delivery). Since gap E closed (2026-08-10) the adapter usually captures the real applicantId AT CREATE (read off the raw create response — the published ApplicantPublicDto schema is lossy and omits id), so the mirrors key by it immediately and that reconciliation is a proven no-op; it stays the fallback/backstop for when a response omits the id. The beneficial-owner reconciler — the missed-webhook backstop the gate's local-edge-set trust depended on — is now built: BeneficialOwnerReconciler behind the hourly onboarding:reconcile-beneficial-owners command — since D93 a thin dispatcher onto the task-runner (onboarding.reconcile_beneficial_owners, one unit per sweep-eligible tenant) — re-pulls the ownership graph for every non-terminal KYB application through the SAME pull-authoritative path the webhook uses, closing GATE 3(a) (GATE 3(b), happy-path-only approval, stays open). The onboarding README (Reconciliation section) and docs/tracking/multi-provider/10-onboarding-identity.md §4b/§4c/§4d/§4e/§4f carry the detail.

The accounts custody_transactions mirror (Utila) is the second pull-authoritative mirror: its 2.2d-2 webhook handlers pull the transaction via ManagesCustodyTransactions::getTransaction rather than trust the webhook body (the Utila custody-webhooks bullet under the rails seam above). It routes to Utila as of the custody go-live grant (ADR D98) — live-in-code + deploy-gated, pre-product.

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