Skip to main content

Decision Log (ADRs) — Index

What this covers / who it's for. The D-numbered architecture decision log for stables-core-app — one file per decision going forward — D1 through D113 today, with three historical clusters (D27–D32, D33–D35, the D38–D69 register) grouped as single files. It records what was decided, why, and what is still open, for engineers (and AI agents) who need the rationale behind a pattern before changing it. Data security and tenant data hygiene are the overriding goals — when a choice trades convenience for stronger isolation, we take the isolation.

Context (the log's charter)

  • stables-core-app is the Laravel 13 / PHP 8.5 control plane for a fintech neo-bank. It is the main interface to a banking-rails provider accessed over a REST API.
  • We run single-database multi-tenancy with PostgreSQL Row-Level Security (RLS) via stancl/tenancy v4. No multi-database, no separate-schema, Postgres only.
  • Two faces of the app:
    • Central app — backoffice/admin. Runs under a BYPASSRLS Postgres role. Sees all tenants' data plus central/reference tables.
    • Tenant app — client-facing. Runs under a NOBYPASSRLS RLS role; every query is auto-scoped to the current tenant by a Postgres session variable + RLS policies.
  • The app is a modular monolith using InterNACHI/modular.
  • A tenant == a client/business whose data must be isolated.

The log began as the multi-tenancy setup decision log (branch feat/tenancy-rls-foundation) and grew into the app-wide ADR register. Its companion appendices — open items, the tenancy package inventory, the historical file manifest, team conventions, and the changelog — live in appendices.md.

Recording a new ADR

New ADRs are recorded here (in wiki/engineering/decisions/), not in docs/:

  1. One new file per decision, following the naming scheme: lowercase, zero-padded 3-digit D-number, short kebab slug from the title (≤6 words) — e.g. d094-some-new-decision.md. Use the standard page header (title / audience / related: README.md), make the ADR title the H1, and end with the [← Decision log index](README.md) backlink.
  2. Add a row to the index table below (D-number, title, status, file link).
  3. Parallel branches: take the next free D-number, add an HTML comment <!-- RENUMBER-ON-MERGE: parallel branch --> on the H1 line of the file and mark the index row. At merge time, the later branch renumbers: rename the file, update the D-number in its H1/title, and sweep every reference to the old number (docs, READMEs, skills, tracking docs).
  4. Sub-amendments to an existing decision go in the parent's file as D<n>.<m> (H2 heading — the D74.1 / D76.1 precedent), not as a new file.

The decisions

Rows marked ⚠ carry a RENUMBER-ON-MERGE: parallel branch comment (see above).

DTitleStatusFile
D1Single-DB + Postgres RLSResolvedd001
D2Tenant identification: request data (X-Tenant)Resolvedd002
D3Postgres everywhere, including testsResolvedd003
D4RLS roles (central BYPASSRLS vs stables_rls)Resolvedd004
D5RLS manager + session variableResolvedd005
D6Bootstrappers enabled (single-DB RLS profile)Resolvedd006
D7Table scoping: scopeByDefault = trueResolvedd007
D8Tests on Postgres — approach pendingSuperseded by D13d008
D8aPackage version: track dev-masterResolved (stable-tag pin still open)d008a
D9Tenant model uses HasDatabaseResolvedd009
D10RLS role provisioning & grantsResolvedd010
D11Session variable locked to stables.current_tenantResolvedd011
D12Central Livewire backoffice, tenant headless REST APIOperator-UI half superseded by D112 (now API-only)d012
D13Dedicated Postgres test database (stables_testing)Resolvedd013
D14RLS isolation tests without RefreshDatabaseResolvedd014
D15Client auth: Sanctum SPA + tenant-scoped ClientUser (+ D15.1 central-session store fix, D15.2 password-change session revocation)Resolvedd015
D16Email-first tenant discovery (central)Resolved (hardening tracked)d016
D17API versioning: URL /api/v1/…Resolvedd017
D18Cloudflare Turnstile on unauthenticated endpointsResolvedd018
D19Modular monolith: code in modules, App\ emptyResolvedd019
D20Module-owned Blade views + LivewireSuperseded by D112 (operator UI is now a standalone SPA; no Blade/Livewire)d020
D21Inspiration mined from the legacy stablesxyz/core repoPartly superseded by D22d021
D22CQRS bus + JSON:API envelope + Scramble extensionsResolved (supersedes D21's CQRS-skip)d022
D23Fundamentals-readiness hardeningResolvedd023
D24RBAC in a dedicated Authorization moduleResolved (schema superseded by D25/D26)d024
D25Internal bigint PK + external UUIDv7Resolvedd025
D26Soft deletes + blamable, codifiedResolvedd026
D27Money is integer minor units + explicit currencyResolved (enforced)d027–d032
D28Write idempotency for externally-triggered commandsResolved (realized in Phase 0 / D56)d027–d032
D29Pessimistic row locks for balance mutationsRecorded conventiond027–d032
D30PII encryption-at-rest + data classificationRecorded conventiond027–d032
D31Retention + GDPR erasure vs soft-delete/blamableResolved (floor realized in D70)d027–d032
D32Ledger correctness invariants (double-entry, append-only)Recorded conventiond027–d032
D33Custom Features/Entitlements engine; Pennant dormantResolvedd033–d035
D34Jurisdiction gates, tier entitlesResolvedd033–d035
D35Stepped/banded entitlements + usage meteringResolvedd033–d035
D36Per-module permission registry, both guardsResolvedd036
D37Permission dependencies at role-build time onlyResolvedd037
D38–D69Conduit integration register — authoritative home: docs/conduit/README.md §3; the log file records per-phase realizations + refinementsPointer registerd038–d069
D70Age-based retention prunes + the 7-year compliance floorResolvedd070
D71DefaultRoleRegistry: default roles per moduleResolvedd071
D72Real per-module entitlement featuresPartial (provisioning gap tracked)d072
D73Per-tenant entitlement overridesResolvedd073
D74Observability app-code slices (+ D74.1 hardening)Resolvedd074
D75DomainException carries JSON:API source + metaResolvedd075
D76Expected client 4xx reclassified (+ D76.1 durable carve-out)Resolved (narrow compliance question open)d076
D77Class-aware notification-log retentionResolved (mechanism-only)d077
D78Document-vault retention + RTBF crypto-shredResolved (mechanism-only)d078
D79DLP V2 egress detectionResolved (mechanism-only)d079
D80Jurisdictions: full ISO hierarchy + gate inheritanceResolved (evolved by D81)d080
D81Jurisdiction enrichment: M2M blocs + currency/region dataResolvedd081
D82Local S3-compatible object storage: MinIOResolvedd082
D83Async-request platform (pollable request/status)Resolvedd083
D84Typed finance-provider identity (FinanceProvider enum)Resolvedd084
D85Audit/DLP growth: WORM segment exportResolvedd085
D86Documents direct-to-object-storage transferResolvedd086
D87Direct-path encryption at rest: bucket-default SSE-KMSResolvedd087
D88Documents malware scanning: quarantine lifecycleResolvedd088
D89Notification recipient resolution + CTAs as plain dataResolvedd089
D90Typed supported-asset vocabulary (SupportedAsset enum)Resolvedd090
D91Per-tenant base currencyResolvedd091
D92Defined pair-rate providers registryResolvedd092
D93Durable async-task execution (task-runner)Resolvedd093
D94Concurrent multi-provider routing (ProviderRouter substrate, supersedes D60)Resolvedd094
D95First non-Conduit primary grant — Sumsub Onboarding go-live (GB/EU)Resolvedd095
D96Ceremony-less custody — drop the hosted co-sign/enrolment URL; signer/quorum changes are the MPC API-vote modelResolvedd096
D97Custody approval quorum — one general N-of-M engine, two consumers, via a published seamResolvedd097
D98Utila custody go-live — Custody→utila (all six), no Conduit fallback, accepted travel-rule originator dropResolvedd098
D99CI runs the dev compose stack — gates execute inside the app containerResolvedd099
D100CI test execution moves to Blacksmith ARM runners; static gates stay GitHub-hosted (refines D99)Resolvedd100
D101CI docs-only skip gate: last-green anchor + base-ancestry guardResolvedd101
D102Fraud / device-intelligence go-live — Fingerprint primary + SEON permitted (all six), DeviceRiskGuard postures, deploy-gatedResolvedd102
D103Companion preview labels are a desired-state toggle (unlabel tears down, remove + re-add rebuilds); workflows linted by pinned actionlintResolved (deferred: shellcheck pass, companion build identity)d103
D104Tiered agent roster (stables-agent-kit plugin), Serena/Graphify code-graph MCP, tiered QA review, compressed reportingAccepted (pilot)d104
D105Operator writes to tenant-RLS rows enter tenant context (tenancy()->run) + dispatch the domain's tenant-scoped writer (BYPASSRLS-by-tenant_id prohibited); the KYB unhappy path (RED-FINAL → operator review, re-submission guard, 7-day stuck sweep, decoupled compliance case)Accepted d105
D106Postgres safety valves (idle_in_transaction/lock timeout 120s), pre-flight guard + test-kill, Postgres non-durability flags (fsync/synchronous_commit/full_page_writes off), CLI opcache with file cacheAccepted d106
D107Backend preview deploys gated on preview label; unlabel or PR close tears down (mirrors D103's companion label pattern)Accepted d107
D108Probe-then-truncate-subset "fast reset" toggle for the tenant-DB test reset (TEST_FAST_RESET / TEST_FAST_RESET_PARANOID, default OFF, instrumented)Accepted d108
D109Documents stream route is signature-only (BFF sealed session): permission moves to issue-time-only, freeze/closure gate rides the signed tenant param unchanged, LogDocumentAccessCommand becomes null-actor-tolerantAccepted d109
D110Onboarding subject-type contract: client declares individual|business on requirements + submit (no silent default), Sumsub level-per-type resolution, KYC approval path without the UBO gate, Conduit fail-closed as reference-onlyAccepted d110
D111sFOX real-time ingestion: REST polling + reconciliation first; WebSocket listener deferred until sub-minute latency demandedAccepted d111
D112API-only backend: operator UI moves to the standalone stables-core-web-backoffice SPA, frontend toolchain + Fortify Blade views removed, passkeys become cross-origin-ready; emails are the only rendered output (supersedes the operator-UI half of D12/D20)Accepted d112
D113RequiresApprovedOnboarding bus gate: paired 1:1 with RequiresAccountActive on all 18 money-movement commands, binding-inversion seam mirroring the freeze gate, enforce-on-by-default (no live tenants), no route-middleware counterpart, cancellations unmarkedAccepted d113

Thematic clusters

  • Tenancy / RLS: D1–D11, D13, D14, D53, D105 (operator writes enter tenant context)
  • CQRS bus + API contract: D12, D17, D21, D22, D23, D75, D76, D113 (RequiresApprovedOnboarding gate)
  • Identifiers / schema conventions: D25, D26, D84, D90
  • Money safety: D27–D32, D61, D91, D92
  • Features / entitlements / jurisdictions: D33–D35, D72, D73, D80, D81
  • Auth + RBAC: D15, D16, D18, D24, D36, D37, D51, D62, D71, D112 (operator auth goes fully headless)
  • Conduit integration: D38–D69
  • Audit / DLP / documents: D41, D44, D46, D54, D57, D69, D76, D78, D79, D85, D86, D87, D88, D109
  • Retention / lifecycle: D31, D70, D77, D78, D83
  • Async platforms: D83, D93
  • Multi-provider routing: D94, D95, D102, D110 (RENUMBER-ON-MERGE)
  • Custody (multi-provider): D96, D97, D98
  • Observability: D74, D85, D89
  • Dev tooling / CI: D82, D99, D100, D101, D103, D104, D106, D107, D108

(D38–D69 numbers cited above resolve inside the Conduit register / docs/conduit/README.md §3.)

Appendices

Open items, the tenancy package inventory, the historical file manifest, team conventions, and the changelog: appendices.md.


← Engineering wiki index