Skip to main content

D38–D69 — Conduit integration (authoritative register: docs/conduit/README.md §3)

Pointer register. D38–D69 (the Conduit Financial integration) are summarised here by pointer only — the authoritative, per-decision content lives in docs/conduit/README.md §3. This file records, per phase, what was realized in code and the refinements made during the build. Relocated verbatim from the retired single-file docs/tenancy/ decision log; see the decision log index.

The Conduit Financial integration adds decisions D38–D69. To avoid divergence, their authoritative, final-form register lives in docs/conduit/README.md §3 (context + per-pillar designs + phasing in the same doc); they are summarised here only by pointer. This log records, per phase, what is realized in code and any refinement made during the build.

Phase 0 — Foundation kernel (realized; four gates green)

The kernel prerequisites every later phase depends on. Realized: D61 (the Money/Asset/Chain/ Rail value objects + brick/math; DecimalMath moved to Modules\Foundation\Support; the SchemaConventionTest float-ban now records the decimal(38,18)/numeric money carve-out), the new ErrorCode cases (PROVIDER_*, DUPLICATE_REQUEST, RATIONALE_REQUIRED, ACCOUNT_FROZEN), the D28/D56 Idempotent marker + command_dedup_records dedup store + CommandDeduplicator (unique- INSERT-as-first-step, encrypted-result replay, IdempotentWithoutTransaction arch assertion), D46 RequiresRationale marker + bus gate + RationaleCoverageTest, D51/D62 the authorization- declaration seam (PermissionKey relocated to Modules\Foundation\Authorization\Contracts; ChecksPermission/ChecksPermissions markers + AuthorizesViaDeclaredPermission concern + the ->requires() route macro), D52 the Integration\Contracts arch surface (private-by-default Services/Handlers + the interfaces-only/contracts-return-DTOs guard), D53 the central-table inverse-leak guard + RLS spike, and D54 the unmanaged pgsql_logs connection + SideEffectLogWriter.

Phase-0 refinements to the D38–D69 register (recorded for review):

  • D51/D62: ChecksPermission::requiredPermission() is non-nullable (the plan sketched ?PermissionKey). A message that implements the marker declares a permission; "no requirement" is expressed by a bespoke authorize() + an allow-list entry, not a null — which avoids reintroducing the bare-allow backdoor the framework's AuthorizationCoverageTest (Phase 1) bans.
  • D51: the ->requires() macro signature is requires(PermissionKey|array $permissions, ?string $feature, bool $stepUp, PermissionMode $mode) — PHP requires a variadic to be last, so the plan's literal requires(PermissionKey ...$keys, …) shape is realized as an array/single-key first argument.
  • D52: the private-by-default behavioural set is Services + Handlers (the implementation behind a contract). Concerns stays intentionally shareable (it carries cross-module mixins like HasTenantRoles/AuthorizesViaDeclaredPermission), so it is not made private.
  • D28/D56: the dedup store is tenant-scoped (RLS) per D28's (tenant_id, key, command) key, so Idempotent commands run in tenant context; provider is included in the unique tuple from V1 (D56/D60). The CommandDeduplicator uses the DB facade (sanctioned in Foundation\Bus) rather than a model.
  • D54: the SideEffectLogWriter likewise uses the DB facade and is sanctioned by exact class in CqrsBoundaryTest's DB-facade allow-list.
  • Worked example: the three tenant-role commands (Create/Update/Delete TenantRole) were migrated to the declarative seam as the first consumer (behaviour-equivalent; covered by the existing role tests).
  • Local environment note: the schema's DEFAULT uuidv7() is a Postgres-18 function; for native (non- Docker) local runs on Postgres 16 a uuidv7() plpgsql polyfill is installed in the dev + test databases. Production/CI run Postgres 18, where it is native — the polyfill is a local-only convenience, not committed schema.

Phase-0 two-round review resolutions (clarifications folded in):

  • D56 dedup semantics (realized): because the claim + work + result commit atomically, a concurrent duplicate blocks then replays the stored result (a transparent success); DUPLICATE_REQUEST (409) is reserved for an orphaned in-flight claim (committed claim, no result). The §8.1 error-map is clarified so a completed replay is an ordinary 2xx, not the DUPLICATE_REQUEST branch.
  • D56 tenant context: the deduplicator now fails fast with a clear error when an Idempotent command is dispatched outside tenant context (the dedup store is RLS-scoped), instead of an opaque NOT NULL / RLS error. The IdempotencyKeyFactory reflects over all command properties (not just public) so a private dedup-relevant field can't silently drop out of the key.
  • D54 (realized fully): the rollback-survival test now also writes a tenant_id-bearing row to a central (no-FK) log table via the pgsql_logs writer and asserts it survives the handler rollback — proving the real vendor_request_logs / audit_events / data_access_logs shape (a tenant-of-record on a central log) commits independently of the command transaction, not just a tenant-less row.
  • Deferred (tracked in docs/tracking/conduit-phase-0-prerequisites.md): the §7 Phase-0 external / compliance prerequisites (sandbox creds D68, KMS D43, FX feed D49, sanctions source D65, the @stables/abilities/back-office repos, RTBF/retention #3, the D63 filer model); plus the engineering follow-ups — Idempotent concurrency hardening (lock-timeout budget + a 2-connection test, R3), the replay/CommandExecuted audit semantics (R1 audit), command_dedup_records pruning (R1), and the coded FORBIDDEN DomainException + ACCOUNT_FROZEN-as-DomainException (R1 authz framework, D62).
  • Rejected: restricting unserialize($result, ['allowed_classes' => …]) in the deduplicator — the result is an arbitrary handler DTO (not statically enumerable), and the Crypt MAC is the trust boundary (forging the blob requires APP_KEY compromise, a broader breach); only the deduplicator ever writes that column, always via Crypt, so the invariant holds by construction.

Phase 1 — rails (finance-provider seam + vendor-call logging; realized, four gates green)

Realized D40 (the provider-neutral RailsProvider umbrella composed of segregated capability interfaces — only the onboarding group OnboardsCustomers/ManagesCustomers is implemented this phase; the Conduit adapter ConduitRailsProvider quarantines the SDK; our own thin Data\* DTOs + ExternalRef cross the boundary; the neutral RailsException tree via ConduitExceptionTranslator) and D41/D54 (the central, append-only vendor_request_logs table — plain bigint tenant_id, no FK — written via SideEffectLogWriter on pgsql_logs outside the handler transaction; bodies scrubbed + manually Crypt-encrypted).

Phase-1 refinements / deviations to the D38–D69 register (recorded for review):

  • D40 logging selector — config-by-class, NOT the marker⇔trait pair the plan sketched. The plan described a FinanceVendorConnector marker + a LogsFinanceVendorRequests trait on the SDK's ConduitConnector, enforced by a marker⇔trait arch test. That would require the SDK to implement a rails interface, violating the one-way dependency (the SDK must never import Modules\*; an arch test now asserts Stables\Conduit never uses Modules\Rails). Realized instead as config-by-class: config('rails.providers') lists the connector class, and RailsServiceProvider decorates that already-singleton connector with the RecordVendorRequest onResponse middleware via $app->resolving(...). A non-finance connector (e.g. Twilio) is simply absent from the registry, so it is structurally never logged — the same guarantee, without the inverted edge. The FinanceVendorConnector marker still lives in rails (not the SDK) for a future rails-owned connector to opt in. The lost structural guard is replaced by a feature test asserting every connector in config('rails.providers') resolves with the middleware attached (a future un-logged finance connector reddens the build).
  • M9 taxonomy — added ErrorCode::ProviderResourceNotFound (PROVIDER_RESOURCE_NOT_FOUND, HTTP 404, terminal). The plan listed only PROVIDER_UNAVAILABLE/_VALIDATION_FAILED/_REJECTED; a 404 was initially folded into the retryable ProviderUnavailable. It is now its own terminal code (stop polling / fix the ref), and the server-side 409 IDEMPOTENCY_KEY_CONFLICT maps to ProviderRejected (terminal), so "safe to retry" is asserted only for genuinely transient cases (rate-limit / 5xx / connection / auth).
  • Outcome = exception, single source (M2): the logged vendor_request_logs.outcome is derived by translating the SDK exception first and mapping its ErrorCodeVendorRequestOutcome, so the logged category always equals the thrown category (no independent HTTP-status branch).
  • Message discipline (M6): the rendered (client-facing) RailsException message is a neutral per-subtype default; the raw provider prose + machine code + provider correlation id are carried as non-rendered diagnostics (providerMessage/conduitCode/providerCorrelationId) for the log/operator only. Surfacing the provider correlation id to the client error envelope is deferred to the onboarding phase that introduces the first rails-backed endpoint (tracked).
  • Per-attempt logging (M5): the connector retries transparently (tries=3), so a logical call writes one vendor_request_logs row per HTTP attempt that produced a response, discriminated by a 1-based attempt column with per-attempt duration_ms. A TRANSPORT failure (no response) collapses to one row, written after the connector's retries are exhausted, with attempt = 1 and status = null (the onResponse middleware never fires for a connectionless failure; the adapter logs it once).
  • R2-A — failureCode is a rails-owned enum, not a raw string. OnboardingApplication.failureCode crosses as a rails-owned Enums\ApplicationFailureCode (mapped from the SDK's closed enum in ApplicationMapper, exactly as status/type), so no provider vocabulary leaks into the field the domain branches on. failureMessage stays operator/log-only free-form prose.
  • D41 deviation — header capture deferred (R2-H). Phase 1 logs request/response BODIES only; vendor_request_logs has no header column and the VendorRequestScrubber has no header path. Header capture + redaction (auth headers, cookies) is re-added in the phase that logs headers (tracked). The operationally-useful outbound idempotency key is already captured as its own idempotency_key column.
  • Scrubber scope (R2-I): the scrubber does KEY-BASED redaction of config('rails.sensitive_fields') (now incl. dob/email/phone) — it is the Phase-1 control alongside encryption-at-rest, NOT comprehensive DLP. Value-pattern redaction + scrubbing the RFC 9457 detail prose are the D44 DLP phase's job (tracked). Names are intentionally left un-redacted (needed context; encryption is their control).
  • Deferred (tracked in docs/tracking/conduit-phase-0-prerequisites.md): vendor_request_logs pruning against config('rails.retention_days') — reconciled with the D31 7-year KYC/AML floor (the log is transport telemetry, not the KYB system of record, so a 90-day telemetry prune is acceptable; the prune must still respect the floor for any regulated content); surfacing provider diagnostics + retry-after + auth-fault-vs-outage distinction to the client/operator error surface (R1 onboarding / operator-view); header capture (later phase); comprehensive DLP over the vendor log (R1 dlp V1); unifying the recursive idempotency-key normalization into a shared Foundation primitive (R3).

Phase 2 — provider-mirror (async mirror backbone; realized, four gates green)

Realized D42 (the synced-mirror parallel ledger machinery) and D52/D55/D59 as a FRAMEWORK phase: the abstract AbstractMirrorUpsert / AbstractMirrorReconciler / AbstractMirrorBackfill, the normalized MirrorEnvelope, the shared IsProviderMirror mixin, the generic ProviderMirrorUpdated after-commit event, and the tenant-scoped mirror_drift table. The shared ExternalRef VO was promoted to Modules\Foundation\ValueObjects\ExternalRef and rails refactored onto it (no two divergent copies; provider-mirror depends only DOWN on Foundation, never on rails). No concrete domain mirrors yet — they land in their own later phases, each subclassing the framework.

Phase-2 refinements / deviations to the D38–D69 register (recorded for review):

  • D-provider-mirror (raw is encrypted:array, NOT jsonb) — deviation from the plan's literal §4.4 "raw jsonb". The universal raw column is cast encrypted:array (stored as text), so the full provider payload is encrypted AT REST by default. Rationale: raw is an audit/replay payload, never a query surface — typed promoted columns + a per-domain profile jsonb serve queries — so a query-able plaintext jsonb buys nothing and leaks PII at rest. This makes the convention SAFE BY DEFAULT and removes the dependence on the (still-deferred) DLP redactRaw() redactor for the at-rest control; redactRaw() stays as a defence-in-depth scrubbing hook (D44/D57), no longer the load-bearing safety claim. (Resolves the Phase-2 review Blocker B1.) The §4.4 checklist (plan + the provider-mirror README) mints raw as an encrypted text column accordingly.
  • B2 — the mandatory partial-unique mirror key is TENANT-SCOPED (tenant_id, provider, external_id) WHERE deleted_at IS NULL, not the global (provider, external_id) the first cut shipped. A tenant-scoped table keyed globally would let two tenants holding the same provider external_id collide — and createOrFirst would rethrow the unique violation under the second tenant's RLS scope. Adding tenant_id to the index lets each tenant hold its own row for the same provider ref (RLS keeps them isolated); a covering isolation test proves it.
  • B3 — provenance lives on drift rows + the event, not the mirror row. A mirror ROW stores provider STATE (overwritten wholesale), so it carries no provenance; mirror_drift gains source/event_type/correlation_id and ProviderMirrorUpdated carries the same, with MirrorEnvelope.correlationId defaulting from the request Context flow id — so a webhook→mirror→drift chain is traceable end to end. The previously-dead eventType/source envelope fields are now wired through.
  • B4 — authoritative self-heal. A reconciliation pull IS provider truth, so the reconciler self-heals via AbstractMirrorUpsert::applyAuthoritative() (force-applies, bypassing the out-of-order guard) — otherwise a stale-but-status-different drift would record drift then silently no-op the heal.
  • B5 — drift dedup + resolve-on-heal. recordDrift() dedups on the unresolved (tenant_id, provider, external_id, entity) tuple (touch + occurrences++) instead of flooding the open-drift queue; a successful self-heal stamps resolved_at + a self_heal resolution in the same pass.
  • B6 — provider-missing hook. reconcile() routes a null provider pull through a handleProviderMissing() hook (default: in-sync/no-op) so a domain reconciler can make "the provider dropped a resource we still mirror" observable instead of silently short-circuiting.
  • Deferred (tracked in docs/tracking/conduit-phase-0-prerequisites.md): mirror_drift retention/pruning (7-year floor for compliance-relevant drift); the D59 reconciliation-control BACKEND obligations (staleness SLO, webhook-delivery-gap detector, the scheduled reconcile/backfill commands — R2); the timestamp-only-provider equal-is-stale lost-update risk (a sub-second provider_updated_at or an isStale() override per such mirror); and the D52 Integration\Contracts surface each concrete mirror phase must land before a reconciler can legally call a rails read capability.

← Decision log index