Skip to main content

D122 — Conduit code removal: legacy adapter, SDK, and webhook source deleted; enum case retained

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Design of record: docs/superpowers/plans/ 2026-09-05-conduit-code-removal.md. Follows D118 (Bridge registered), D120 (Bridge the sole granted primary, Conduit in no grant), and D121 (the coverage programme). Stacked on feat/bridge-coverage-programme.

Context

Since D118/D120, Conduit has been legacy code only: no grant, no permitted key on any group, not the code-level default (config('rails.default_provider') = 'bridge'), and — since D120's arch guard — zero domain-module literal references. The app carries no production data at removal time (no live tenants, no in-flight Conduit-routed state to migrate or drain). With Bridge serving every group Conduit used to serve, and Sumsub/Utila/Fingerprint/SEON still registered (parked or granted for Fraud), the Conduit adapter, its SDK, and its webhook source were dead weight: a full sealed adapter (17 files), a 183-file vendor SDK package, 12 webhook handlers, and their pinned tests, none of it reachable by any routed capability. This ADR records what was removed, what was deliberately kept, and which features die with the vendor rather than surviving as a "coverage gap."

Decisions

  1. Deleted the Conduit rails adapter (app-modules/rails/src/Conduit/**, 17 files): the SDK connector wiring, DTO mappers, ConduitExceptionTranslator, providers.conduit config entry, sensitive_fields scrubber rows, .env.example CONDUIT_* vars, and the rails/webhooks composer requires on stables/conduit-sdk (commit 6948ef82, D122 c3).
  2. Deleted the Conduit webhook source (commit e4ad78f4, D122 c2): 15 handlers (the plan's 12 plus WalletSignerUpdatedHandler and the conduit-scoped CustomerCreated/UpdatedHandler arms), the conduit webhook-client config entry + route, ConduitSignatureValidator + SignsConduitWebhooks + the WebhookSignatureVerifier bind, and envelopeFor()/updatedAt() off the three abstract webhook-handler bases. ProcessInboundWebhookJob's DELIVERY_ID_HEADER was generalised from a hardcoded x-conduit-delivery-id constant to a per-source delivery_id_header config value (Bridge ships none — see "features retired" below).
  3. Deleted the RailsProvider umbrella interface (app-modules/rails/src/Contracts/ RailsProvider.php, commit e4ad78f4): Conduit was its only implementer; the 13-capability composition it existed to express has no consumer left. ReturnTypeTest's umbrella allow-list entry and the umbrella container bind (already deleted in D120) are both gone.
  4. Deleted CustodyAccountResolver's Conduit arm, renaming $conduitCustomerExternalId$customerExternalId (commit 6948ef82) — the resolver is now Utila/Bridge-only.
  5. Deleted the legacy infer-the-source payout body in PayoutSubmitter (commit 6948ef82): once no surviving adapter infers a transfer source, the no-nomination code path became dead code reachable only by tests; it now throws ProviderValidationException pre-wire, with a regression test proving the refusal. PinsLegacyConduitRouting (the test helper that pinned Conduit as "the provider that infers a source") is deleted with it.
  6. Deleted webhooks:event-types (D68's diagnostic command, commit 6948ef82): it diffed Conduit's live event vocabulary against the registry; with no Conduit source there is no vocabulary left to diff.
  7. Deleted the two ModuleBoundaryTest Stables\Conduit SDK-confinement rows and the ProviderLiteralTest exemptions 1 (inbound-webhook envelope default) and 2 (the 'conduit' webhook-source-key idiom) — both exemptions existed only to admit a Conduit-specific literal that no longer compiles once the SDK and webhook source are gone. The raw-string tripwire (matching bare 'conduit'/"conduit") stays live for any future reintroduction. Landed as c4, commits b065ba2d (SDK + composer wiring deleted, ModuleBoundaryTest rows removed) and d640de54 ($providerCode rename, fraud fallback, webhook source fixtures) — both before this ADR's own commit.
  8. Skill renamed conduit-mirror-domainsmirror-domains (commits fe8fc310, 9507f99b, D122 c5): source directory, SKILL.md name: + provider-neutral wording, boost.json, the skills README row, the 9 sibling-skill cross-references, two wiki pages, and three tracking docs; stale generated mirror dirs removed with git rm -r (boost never deletes on rename); mirrors regenerated with make boost-update.
  9. Deleted the emitter-less vendor co-sign chain (D122 F-code round, commit 05c9cea6): once c2 (e4ad78f4) deleted the Conduit webhook handler that dispatched it, funding's EVENT_AWAITING_USER_SIGNATURE signal had no emitter left — funding's match arm, emitAwaitingUserSignature(), and the coSignContext payload; the TransactionAwaitingUserSignature event, the RecordPayoutSignatureContext listener, and the RecordPayoutSignatureContextCommand/Handler it dispatched (five files) are all gone. Verified emitter-less with Serena find_referencing_symbols on both the event and the command before deletion. Dual control is the in-app custody-controls approval-quorum engine (D97) — the vendor-hosted co-sign is retired with no replacement needed. Blemish, accepted, not rewritten: these five file deletions actually landed in 0d727f15 (a commit titled as a docs change) rather than in this F-code commit as their own message implies — 0d727f15's diff carries the deletions; 05c9cea6's message documents the reasoning. History is not rewritten for this; noted here for anyone reconciling commit messages against diffs.

Kept — live degradation seams, not dead code

  • FinanceProvider::Conduit (enum case) — kept, docblocked "legacy, no adapter." IsProviderMirror enum-casts every mirror's provider column; deleting the case makes any residual provider='conduit' row (staging fixtures, historical test data) unhydratable at read time. Deleting it is a human-approval surface (a destructive narrowing CHECK migration across every mirror table) for a purely cosmetic benefit — deferred (see Follow-ups).
  • ManagesSignersAndQuorumNatively + its 409 gate — the native-signer-management refusal Conduit's ceremony-less ISP redesign (D96) introduced stays live: it is the gate any FUTURE adapter without native signer/quorum support (Bridge included) hits, not Conduit-specific code.
  • ManagesDocuments + its guards — no surviving adapter implements it (Bridge collects documents via its own hosted flow, not this interface), but the job (ForwardDocumentToProviderJob) and the guard in SubmitOnboardingApplicationHandler stay: the degradation seam (a doc-forwarding capability that legitimately has zero servers today) is the live behaviour under test, not a Conduit-only code path.
  • The three abstract webhook bases (Abstract{Accounts,Funding,Payments}WebhookHandler) — survive; Bridge's own handlers extend them for shared parse helpers. Onboarding's abstract base needed no change.
  • PayoutForwarder and VerifiesIdentity/InitiateVerificationCommand — both provider-neutral; an early explorer pass flagged them as Conduit-only and was refuted on inspection (Bridge and the parked adapters use the same seam).
  • The held-deposit/RFI domain events — kept as live non-handler callers (DepositAwaitingSenderInformation, DepositHeldForSenderInfoTimeout); see "features retired" below for what specifically dies alongside the Conduit handlers that used to fire them. (The awaiting-user-signature co-sign chain is NOT in this list — see Decision 9: it was deleted whole, emitter-less, once c2 removed the Conduit webhook handler that dispatched it.)
  • The payout co-sign OBSERVE columns (verification_url/required_approvals/ signature_expires_at on payouts, plus their PayoutData/PayoutResource/PayoutMirror fields) — survive, now NEVER written by any code path (Decision 9 deleted the only writer). A code sibling (payments) marks the mirror mapping @deprecated. Dropping the columns is a migration, not a code deletion — tracked as a Follow-up.
  • Five now-dispatch-less accounts mirror/freeze commands — the account-freeze/mirror commands that only Conduit's webhook handlers dispatched are kept as-is; retiring them is a mirror-domain retirement exercise of its own, not this removal (see Follow-ups).
  • WebhookEventRegistry::registeredTypes() — kept; it is source-agnostic infrastructure the registry needs regardless of which sources are registered.

Features retired WITH the vendor (not coverage loss)

Some Conduit-era test coverage disappears because the feature itself was Conduit-specific, not because a Bridge-equivalent test was skipped. Recorded honestly here rather than folded into the coverage-equivalence gate as an unexplained gap:

  • Conduit inbound-deposit RFI/held-deposit timeout handling — Bridge has no inbound RFI concept; the domain events/listeners/columns survive (see Kept) but the Conduit-specific triggering handlers are gone.
  • The vendor payout co-sign webhook — Conduit hosted a provider-side co-sign ceremony over a webhook; the app's own in-app approval-quorum engine (D97) is the dual-control mechanism for every current and future adapter, so this retires cleanly with no replacement needed.
  • Native signer/quorum management (the wire-ceremony methods in SignerQuorumManagementTest, 11 methods) — the 409 gate that refuses it for a marker-less provider stays (see Kept above); the ceremony-specific wire tests die because no adapter still speaks that wire.
  • The "infer-the-source" legacy payout/order body — Conduit was the only adapter that inferred a transfer source; every surviving/future adapter requires an explicit nomination (the RequiresExplicitTransferSource marker, D120). PayoutSubmitter's dual-body branch collapses to one shape.
  • The webhooks:event-types command — diagnosed Conduit's own runtime event vocabulary; no analogue needed since every surviving source's vocabulary is config-fixed, not runtime-discovered.
  • Document forwarding (the wire-level forward tests) — no adapter implements ManagesDocuments post-removal (none did post-D120 either); the reachable behaviour is the docless no-op path, which keeps its own test.
  • Registered addresses have no serving adapterManagesRegisteredAddresses is stub-only on Bridge (crypto destinations are inline per-transfer, D118) and unimplemented by every other active adapter; the domain command stays (Sovera may serve it later) and keeps today's post-D120 posture — a routed Bridge call returns PROVIDER_OPERATION_NOT_SUPPORTED (422).

Neutral fixtures introduced

  • ContrastProviderAdapter (tests/Support/Fixtures, rails c3 prerequisite, commit 4e3e5e4e) — a registered-but-not-real ProviderAdapter fixture carrying the classification arms Conduit was the last live source of (ManagesDocuments, ManagesSignersAndQuorumNatively, ManagesPayouts without RequiresExplicitTransferSource); every method throws, since the seams under test inspect the class-string/resolved type, never the wire.
  • Parked adapters standing in for capability-missing fixtures — where a test needed "a provider that does NOT serve capability X," Sumsub or Utila (still registered, parked) replace Conduit as the negative fixture (e.g. BankLinkTest's capability-missing arm → Utila).
  • AllowsStrayVendorRequests — the Integration-suite trait letting class-based Integration tests opt out of the stray-Saloon-request guard (Pest's uses() hooks never reach class-based tests), introduced during this stack to keep the D121-era stray-request posture intact through the removal.

Production defect fixed en route

BridgeRailsProvider::submitApplication() matched the payload's top-level type key against ApplicationType, while every submit/re-submit caller (SubmitOnboardingApplicationHandler, SubmitRfiResponseHandler) threads the declared CustomerType subject into that same key — a real Bridge submission would 422 PROVIDER_OPERATION_NOT_SUPPORTED for any business or individual application. Existing tests stubbed OnboardsCustomers/the concrete adapter and never exercised the real mapping, so nothing caught it before onboarding's c1 re-targeting pass hit the real adapter. Fixed in commit 8d7f3eb5 (submitKycLink($payload, $subjectType), failing-first via revert) — a pre-existing defect since D118, not introduced by this removal, and flagged cherry-pickable to main ahead of the stack. Cross-reference: this is an erratum on D118, not a D122 decision in its own right.

Test rule and coverage-equivalence gate

Tests were triaged by SUBJECT: an assertion about Conduit's own wire shape (SDK request/response, mapper output, idempotency body, exception translation, signature bytes) was deleted; an assertion about domain behaviour that merely used Conduit as its pinned vehicle was re-targeted to Bridge (dropping the pin, MockClient on BridgeConnector); 'conduit' used as an arbitrary provider string was swapped to 'bridge' (or, where a fixture needed a THIRD/capability-missing provider, to a parked adapter). The coverage-equivalence gate (one row per deleted test method — deleted test :: covering Bridge test, no empty cell without a "features retired" justification) lives in the PR body; the full working table is the removal-branch scratchpad (prog-coverage.md), organized by commit (c2/c3) and module — that table is where the per-commit test-METHOD deltas (e.g. c2 −85/+11, c3 −141/+9) live; they are coverage bookkeeping, not additive to the run counts below (a deleted/added method count does not reconcile 1:1 against a passing-test delta once datasets/providers are in play). Measured passing-test totals, each from a real make test-parallel run at that commit: 3288 at the tip of PR #136/feat/bridge-coverage-programme (3ab15783) → 3211 after c2 (e4ad78f4) → 3081 after c3 (6948ef82) → 3086 at c4/HEAD (commits b065ba2d98c600fb), confirmed by two independent full-suite runs — see Verification.

The delivery_id_header per-source generalisation

ProcessInboundWebhookJob's per-attempt delivery-id Context key was a hardcoded x-conduit-delivery-id constant (DELIVERY_ID_HEADER). Removing Conduit forced the generalisation the multi-source design always implied: delivery_id_header is now a per-source webhook-client config value, defaulting to null (no header recorded) for a source that ships none — which is Bridge's actual shape (Bridge's retry delivery carries no separate per-attempt delivery-id header the way Conduit's X-Conduit-Delivery-Id did).

Consequences

  • Deploy-day is a no-op for routing. Nothing currently routes to Conduit (D120), so removing its adapter changes no runtime behaviour for any live or future tenant. The webhook-client-conduit named route is gone; any historical webhook_calls rows with source = 'conduit' remain readable (the model doesn't enum-cast source) but nothing ingests new ones.
  • Staging rows are harmless. A provider = 'conduit' mirror row from prior staging activity stays hydratable (FinanceProvider::Conduit kept) but can never be created again by any code path.
  • The vendor SDK no longer exists on diskpackages/stables/conduit-sdk was deleted in c4 (commit b065ba2d, landed before this ADR's own commit; see Verification).

Follow-ups

  • Delete FinanceProvider::Conduit with the first real data-cleanup pass (a narrowing CHECK migration across every mirror provider column) — deferred until there is production data to justify the migration risk (human-approval surface per repo rule 3).
  • Retire the five now-dispatch-less accounts mirror/freeze commands as part of a dedicated mirror-domain retirement pass, not bundled into this removal.
  • The D94 "literal cleanup" is now fully closed — D120 took the domain-module literal count to zero and added the arch guard; this removal deletes the two exemptions that guard used to admit, closing the loop entirely (no sanctioned Conduit-literal exemption remains).
  • Sovera S1/S2 — the next committed vendor (FX/jurisdiction fallback where Bridge doesn't serve an activity type or jurisdiction) is unaffected by this removal; its own ADRs are sequenced separately.
  • rails.bridge.allowed_endorsements allow-list — SHIPPED, not a gap any more. F-code round R7/R8 (commit 36480279) added config('rails.php') bridge.allowed_endorsements (rails.php: 221) and SubmitOnboardingApplicationHandler::assertEndorsementsAreOffered() enforces it BEFORE any document capture, vault write, or rails call — a client-requested endorsement code outside the list is a 422, fail-closed for a provider with no configured list. The default list is derived from the shop's own offering (one code per fiat currency already configured in fiat_rails/virtual_account_routes — base, sepa, faster_payments, spei, pix + pix_onramp/pix_offramp, cop); cards is deliberately excluded since the shop sells no cards product. A code sibling (G-code) is now aligning the key-resolution side with the tenant-routed adapter (reading config("rails.{$routedProviderKey}.allowed_endorsements") rather than a hardcoded bridge key) so a future non-Bridge Onboarding primary is not silently unguarded.
  • Compliance's ConduitHandoffFiler is the last Conduit-NAMED live class — out of this ADR's removal scope (it never touched Conduit's SDK or adapter; it is a compliance filing-reference generator that outlived the vendor by name only). app-modules/compliance/src/Integration/ ConduitHandoffFiler.php, bound in ComplianceServiceProvider (~L45), persists CONDUIT-HANDOFF--prefixed filing references — regulatory records, so the reference FORMAT cannot simply be rewritten retroactively. Needs (1) a rename of the class/binding to a vendor-neutral name and (2) a decision on the filing-reference format for new filings (keep the CONDUIT-HANDOFF- prefix for continuity with existing regulatory records vs. mint a new prefix going forward) — tracked here, not actioned by this ADR.

Verification

  • git log --oneline main..HEAD at the time this ADR was authored: c1 (tests re-targeted, 8 commits) → the pre-existing-defect fix (8d7f3eb5) → c2 (629cbb04 prerequisites + e4ad78f4 app deletion + 289d3fb3 READMEs) → c5 skill rename (fe8fc310, 9507f99b, landed concurrently with c2/c3 as a docs-only parallel track) → c3 (4e3e5e4e prerequisites + 6948ef82 rails deletion + 619272b0 READMEs).
  • c4 (SDK + root composer + ModuleBoundaryTest rows + RailsException::$conduitCode$providerCode rename) — LANDED, before this ADR's own commit: b065ba2d (deletes packages/stables/conduit-sdk, 183 files; the root composer.json/composer.lock wiring; the two ModuleBoundaryTest Stables\Conduit confinement rows) and d640de54 (the RailsException::$conduitCode$providerCode rename, the fraud fallback, and the webhook source fixtures). Measured test totals across the full stack: 3086 passing at c4/HEAD, confirmed by two independent make test-parallel runs (see the arithmetic in the Test rule and coverage-equivalence gate section above).
  • make wiki-check — passes (see the PR body / commit trailer for the run).
  • D118 — the Bridge adapter this removal leaves as the sealed primary for the four groups Conduit used to serve.
  • D120 — made Conduit legacy-only in the first place (no grant, no permitted key, not the default, zero domain literals); this ADR deletes the code that D120 made unreachable.
  • D121 — the coverage programme this removal is stacked on; its ContrastProviderAdapter-adjacent fixtures and stray-request posture carry through.
  • docs/tracking/multi-provider/00-provider-router.md — the live built-vs-deferred tracker, updated in this same change to reflect five adapters (bridge, sumsub, utila, fingerprint, seon) and the closed D94 literal-cleanup item.
  • docs/archive/conduit/ — the archived Conduit integration plan and multi-provider execution plan (moved, not deleted, in this same change).

← Decision log index