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-filedocs/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, Idempotent⊄WithoutTransaction 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 bespokeauthorize()+ an allow-list entry, not a null — which avoids reintroducing the bare-allow backdoor the framework'sAuthorizationCoverageTest(Phase 1) bans. - D51: the
->requires()macro signature isrequires(PermissionKey|array $permissions, ?string $feature, bool $stepUp, PermissionMode $mode)— PHP requires a variadic to be last, so the plan's literalrequires(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).Concernsstays intentionally shareable (it carries cross-module mixins likeHasTenantRoles/AuthorizesViaDeclaredPermission), so it is not made private. - D28/D56: the dedup store is tenant-scoped (RLS) per D28's
(tenant_id, key, command)key, soIdempotentcommands run in tenant context;provideris included in the unique tuple from V1 (D56/D60). TheCommandDeduplicatoruses the DB facade (sanctioned inFoundation\Bus) rather than a model. - D54: the
SideEffectLogWriterlikewise uses the DB facade and is sanctioned by exact class inCqrsBoundaryTest'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 auuidv7()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 theDUPLICATE_REQUESTbranch. - 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
IdempotencyKeyFactoryreflects 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 thepgsql_logswriter and asserts it survives the handler rollback — proving the realvendor_request_logs/audit_events/data_access_logsshape (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/CommandExecutedaudit semantics (R1 audit),command_dedup_recordspruning (R1), and the codedFORBIDDENDomainException+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 theCryptMAC is the trust boundary (forging the blob requires APP_KEY compromise, a broader breach); only the deduplicator ever writes that column, always viaCrypt, 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
FinanceVendorConnectormarker + aLogsFinanceVendorRequeststrait on the SDK'sConduitConnector, 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 importModules\*; an arch test now assertsStables\Conduitnever usesModules\Rails). Realized instead as config-by-class:config('rails.providers')lists the connector class, andRailsServiceProviderdecorates that already-singleton connector with theRecordVendorRequestonResponsemiddleware 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. TheFinanceVendorConnectormarker 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 inconfig('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 onlyPROVIDER_UNAVAILABLE/_VALIDATION_FAILED/_REJECTED; a 404 was initially folded into the retryableProviderUnavailable. It is now its own terminal code (stop polling / fix the ref), and the server-side409 IDEMPOTENCY_KEY_CONFLICTmaps toProviderRejected(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.outcomeis derived by translating the SDK exception first and mapping itsErrorCode→VendorRequestOutcome, so the logged category always equals the thrown category (no independent HTTP-status branch). - Message discipline (M6): the rendered (client-facing)
RailsExceptionmessage 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 onevendor_request_logsrow per HTTP attempt that produced a response, discriminated by a 1-basedattemptcolumn with per-attemptduration_ms. A TRANSPORT failure (no response) collapses to one row, written after the connector's retries are exhausted, withattempt = 1andstatus = null(theonResponsemiddleware never fires for a connectionless failure; the adapter logs it once). - R2-A —
failureCodeis a rails-owned enum, not a raw string.OnboardingApplication.failureCodecrosses as a rails-ownedEnums\ApplicationFailureCode(mapped from the SDK's closed enum inApplicationMapper, exactly as status/type), so no provider vocabulary leaks into the field the domain branches on.failureMessagestays operator/log-only free-form prose. - D41 deviation — header capture deferred (R2-H). Phase 1 logs request/response BODIES only;
vendor_request_logshas no header column and theVendorRequestScrubberhas 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 ownidempotency_keycolumn. - 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 9457detailprose 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_logspruning againstconfig('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, NOTjsonb) — deviation from the plan's literal §4.4 "raw jsonb". The universalrawcolumn is castencrypted:array(stored astext), so the full provider payload is encrypted AT REST by default. Rationale:rawis an audit/replay payload, never a query surface — typed promoted columns + a per-domainprofilejsonb 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) DLPredactRaw()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) mintsrawas an encryptedtextcolumn 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 providerexternal_idcollide — andcreateOrFirstwould rethrow the unique violation under the second tenant's RLS scope. Addingtenant_idto 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_driftgainssource/event_type/correlation_idandProviderMirrorUpdatedcarries the same, withMirrorEnvelope.correlationIddefaulting from the requestContextflow id — so a webhook→mirror→drift chain is traceable end to end. The previously-deadeventType/sourceenvelope 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 stampsresolved_at+ aself_healresolution in the same pass. - B6 — provider-missing hook.
reconcile()routes a null provider pull through ahandleProviderMissing()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_driftretention/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-secondprovider_updated_ator anisStale()override per such mirror); and the D52Integration\Contractssurface each concrete mirror phase must land before a reconciler can legally call a rails read capability.