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. - Concurrently granted adapters — the first concurrent routing.
Modules\Rails\Conduit\ConduitRailsProviderimplements the full umbrella over the first-party Saloon SDK atpackages/stables/conduit-sdk(Stables\Conduit\*;vendor/stables/conduit-sdkis just a Composer path-repo symlink). A second adapter —Modules\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), notManagesDocuments(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 theOnboardinggroup resolves tosumsubfor GB/EU tenants and toconduiteverywhere else (and for every non-Onboarding group) — the substrate's first concurrent multi-provider routing. The provider identity is the typed FoundationFinanceProviderenum (D84 —conduit+sumsub+utila) — the value that keys every mirror row and theconfig('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'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. Each provider ships its own SDK→
RailsExceptiontranslator (ConduitExceptionTranslator,SumsubExceptionTranslator, and since 2.2aUtilaExceptionTranslator) 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 sharedContracts\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::Utilaenum case, aconfig('rails.providers')connector entry (so its calls are vendor-logged by class), a wiredUtilaExceptionTranslator, and theproviderCHECK widened to admitutilaon the accounts (×4) + onboarding (×3) mirror tables. At 2.2a it was NOT adapted, NOT granted, NOT routed (ProviderAdapterRegistry::has('utila')wasfalse). 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
verificationUrla human visits, aRosterCeremonyhandle). 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/QuorumChangeResultnow carry a neutralChangeStatus(applied | pending_approval | rejected) + an optional opaqueapprovalRequestRef;Walletadopts the FoundationChainenum + a neutralWalletStatus, andRegisteredAddressadoptsChain. All chain/status mapping is fail-loud at the adapter (unknown chain →ProviderValidationException/422; exhaustive statusmatch), and both client-input chain WRITE paths (createWallet,registerAddress) validate viaRule::enum(Chain::class)→ 422. This changed the client contract on three JSON:API resources (signer-invitation,roster-ceremony→roster-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\UtilaRailsProvideris now a built, registered PARTIAL custody adapter over the in-repostables/utila-sdk— the sealed-adapter shape (its owncall()funnel,Utila\Mappers\*,UtilaExceptionTranslator, and the fail-loud bidirectionalUtilaChainMapbetween the FoundationChainenum and Utila'snetworks/{slug}-mainnetvocabulary). It serves the Custody group — wallets read + create, the address book (register/read/list), and the net-new custody-transaction seamManagesCustodyTransactions(getTransaction/listTransactions/initiateTransaction- the per-transaction API
voteOnTransaction, an 8-state neutralTransactionStatuswith 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-newProviderOperationNotSupportedException(a neutral 422 — distinct from the 500AdapterMissingCapabilityExceptionwiring 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-closedRequiresProvider→Custodygates 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-dependentCustodygroup toutila.conduitis retained ONLY as an override-only permitted key (an explicit per-tenanttenant_provider_overridesescape hatch for legacy Conduit-modeled signer/quorum/wallet flows) — not an automatic custody-tx fallback (Conduit lacksManagesCustodyTransactions, 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 therailsREADME ("The Utila adapter").
- the per-transaction API
- The Utila custody webhooks — pull-to-hydrate, registered-not-granted (increment 2.2d-2). The inbound
half of custody now exists: the
utilawebhook source (asymmetric RSA-PSS validator) + four custody-transaction handlers (TRANSACTION_CREATED/_STATE_UPDATED/_AML_SCREENING_RESULT_READY/TEST) thataccountsregisters 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 viaManagesCustodyTransactions::getTransaction, and upserts thecustody_transactionsmirror from the typed DTO (guaranteeing exact-stringamount, D27) — the same pull-authoritative shape as the Sumsub UBO mirror below. Tenant routing is a source-scoped, WALLET-keyedUtilaWalletTenantResolver(increment 2.2f-1: business wallets share one vault, so it maps the WALLET resourcevaults/{v}/wallets/{w}→ tenant viaprovider_customers, written by the accountsCreateWalletHandler; abstains unless the event source isutila, and abstains for a transaction resource — deferred to sub-PR d). The AML handler is decoupled from compliance by a domain event —accountsemitsCustodyTransactionAmlScreenedand compliance's listener opens the case (never auto-freezes;accountsnever importscompliance). Ship state: Custody is now granted to Utila (ADR D98) — the pull resolves the routed custody provider (nowutila). 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 theaccountsREADME ("Utila webhook integration — pull-to-hydrate"), thewebhooksREADME, 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 thejurisdictionsmodule) — the allow-list: one row = "this jurisdiction licenses this provider key for this capability group", carryingis_primary,data_region,dpa_ref. A partial-unique index enforces exactly one primary per(jurisdiction, capability_group).tenant_provider_overrides(tenant-RLS, owned by thefeaturesmodule) — 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 reason — never 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
ProviderKeyResolver → ProviderAdapterRegistry 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_grantsflip routes a capability to a different registered adapter.sumsubwas registered in 2.1c-1 and, since 2.1d-2b (ADR D95), is GRANTED the Onboarding primary for GB + EU (conduit a permitted fallback), soOnboardingresolves tosumsubfor GB/EU tenants and toconduiteverywhere 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 becauseconduitremains a permitted key in every jurisdiction. Since increment 2.1d-1 theRequiresProvidermarker is on onboarding'sSubmitOnboardingApplicationCommand+InitiateVerificationCommand(requiredProviderGroup(): Onboarding), so the fail-CLOSED command-pathProviderGuardrefuses 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 frontendembed_modebranch, Sumsub EU/UK residency + DPA binding since the grant shippeddata_regionnull, and the beneficial-owner reconciler + stuck-pendingrunbook). Since increment 2.2e the marker also gates the 10 custody write commands (the 9 accounts wallet/signer/quorum makers + the paymentsRegisterAddressCommand, which routes withCustody). WiringRequiresProvideronto 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-conduitvendor — 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) and10-onboarding-identity.md(the go-live gates), the decision log (D94 substrate, D95 the first grant), and the surface in thefoundation,features,rails, andjurisdictionsREADMEs.
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 — a per-source validator, fail-closed → invalid → 401, never stored. Conduit's
ConduitSignatureValidatordelegates to the SDKWebhookSignatureVerifier(singleSignatureheader, HMAC-SHA256, ~300s replay tolerance, comma-separated secret rotation). Thesumsubsource (2.1c-1) usesSumsubSignatureValidator, a two-header scheme — digest inX-Payload-Digest, algorithm inX-Payload-Digest-Alg(HMAC-256/512/1). Theutilasource (2.2d-2) usesUtilaSignatureValidator, an asymmetric scheme — RSASSA-PSS/SHA-512 over Utila's published public key (no sharedsigning_secret), verified via the utila-SDK verifier. Thefingerprintsource (item 11 phase 5, D102) usesFingerprintSignatureValidator, 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. - 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, or — since increment 2.1d-2c — Sumsub'sexternalUserId, the same stamped uuid echoed back under a different key), 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/
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 externalUserId→applicantId 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 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 |