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-appis 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/tenancyv4. No multi-database, no separate-schema, Postgres only. - Two faces of the app:
- Central app — backoffice/admin. Runs under a
BYPASSRLSPostgres role. Sees all tenants' data plus central/reference tables. - Tenant app — client-facing. Runs under a
NOBYPASSRLSRLS role; every query is auto-scoped to the current tenant by a Postgres session variable + RLS policies.
- Central app — backoffice/admin. Runs under a
- 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/:
- 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. - Add a row to the index table below (D-number, title, status, file link).
- 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). - Sub-amendments to an existing decision go in the parent's file as
D<n>.<m>(H2 heading — theD74.1/D76.1precedent), not as a new file.
The decisions
Rows marked ⚠ carry a RENUMBER-ON-MERGE: parallel branch comment (see above).
| D | Title | Status | File |
|---|---|---|---|
| D1 | Single-DB + Postgres RLS | Resolved | d001 |
| D2 | Tenant identification: request data (X-Tenant) | Resolved | d002 |
| D3 | Postgres everywhere, including tests | Resolved | d003 |
| D4 | RLS roles (central BYPASSRLS vs stables_rls) | Resolved | d004 |
| D5 | RLS manager + session variable | Resolved | d005 |
| D6 | Bootstrappers enabled (single-DB RLS profile) | Resolved | d006 |
| D7 | Table scoping: scopeByDefault = true | Resolved | d007 |
| D8 | Tests on Postgres — approach pending | Superseded by D13 | d008 |
| D8a | Package version: track dev-master | Resolved (stable-tag pin still open) | d008a |
| D9 | Tenant model uses HasDatabase | Resolved | d009 |
| D10 | RLS role provisioning & grants | Resolved | d010 |
| D11 | Session variable locked to stables.current_tenant | Resolved | d011 |
| D12 | Central Livewire backoffice, tenant headless REST API | Operator-UI half superseded by D112 (now API-only) | d012 |
| D13 | Dedicated Postgres test database (stables_testing) | Resolved | d013 |
| D14 | RLS isolation tests without RefreshDatabase | Resolved | d014 |
| D15 | Client auth: Sanctum SPA + tenant-scoped ClientUser (+ D15.1 central-session store fix, D15.2 password-change session revocation) | Resolved | d015 |
| D16 | Email-first tenant discovery (central) | Resolved (hardening tracked) | d016 |
| D17 | API versioning: URL /api/v1/… | Resolved | d017 |
| D18 | Cloudflare Turnstile on unauthenticated endpoints | Resolved | d018 |
| D19 | Modular monolith: code in modules, App\ empty | Resolved | d019 |
| D20 | Module-owned Blade views + Livewire | Superseded by D112 (operator UI is now a standalone SPA; no Blade/Livewire) | d020 |
| D21 | Inspiration mined from the legacy stablesxyz/core repo | Partly superseded by D22 | d021 |
| D22 | CQRS bus + JSON:API envelope + Scramble extensions | Resolved (supersedes D21's CQRS-skip) | d022 |
| D23 | Fundamentals-readiness hardening | Resolved | d023 |
| D24 | RBAC in a dedicated Authorization module | Resolved (schema superseded by D25/D26) | d024 |
| D25 | Internal bigint PK + external UUIDv7 | Resolved | d025 |
| D26 | Soft deletes + blamable, codified | Resolved | d026 |
| D27 | Money is integer minor units + explicit currency | Resolved (enforced) | d027–d032 |
| D28 | Write idempotency for externally-triggered commands | Resolved (realized in Phase 0 / D56) | d027–d032 |
| D29 | Pessimistic row locks for balance mutations | Recorded convention | d027–d032 |
| D30 | PII encryption-at-rest + data classification | Recorded convention | d027–d032 |
| D31 | Retention + GDPR erasure vs soft-delete/blamable | Resolved (floor realized in D70) | d027–d032 |
| D32 | Ledger correctness invariants (double-entry, append-only) | Recorded convention | d027–d032 |
| D33 | Custom Features/Entitlements engine; Pennant dormant | Resolved | d033–d035 |
| D34 | Jurisdiction gates, tier entitles | Resolved | d033–d035 |
| D35 | Stepped/banded entitlements + usage metering | Resolved | d033–d035 |
| D36 | Per-module permission registry, both guards | Resolved | d036 |
| D37 | Permission dependencies at role-build time only | Resolved | d037 |
| D38–D69 | Conduit integration register — authoritative home: docs/conduit/README.md §3; the log file records per-phase realizations + refinements | Pointer register | d038–d069 |
| D70 | Age-based retention prunes + the 7-year compliance floor | Resolved | d070 |
| D71 | DefaultRoleRegistry: default roles per module | Resolved | d071 |
| D72 | Real per-module entitlement features | Partial (provisioning gap tracked) | d072 |
| D73 | Per-tenant entitlement overrides | Resolved | d073 |
| D74 | Observability app-code slices (+ D74.1 hardening) | Resolved | d074 |
| D75 | DomainException carries JSON:API source + meta | Resolved | d075 |
| D76 | Expected client 4xx reclassified (+ D76.1 durable carve-out) | Resolved (narrow compliance question open) | d076 |
| D77 | Class-aware notification-log retention | Resolved (mechanism-only) | d077 |
| D78 | Document-vault retention + RTBF crypto-shred | Resolved (mechanism-only) | d078 |
| D79 | DLP V2 egress detection | Resolved (mechanism-only) | d079 |
| D80 | Jurisdictions: full ISO hierarchy + gate inheritance | Resolved (evolved by D81) | d080 |
| D81 | Jurisdiction enrichment: M2M blocs + currency/region data | Resolved | d081 |
| D82 | Local S3-compatible object storage: MinIO | Resolved | d082 |
| D83 | Async-request platform (pollable request/status) | Resolved | d083 |
| D84 | Typed finance-provider identity (FinanceProvider enum) | Resolved | d084 |
| D85 | Audit/DLP growth: WORM segment export | Resolved | d085 |
| D86 | Documents direct-to-object-storage transfer | Resolved | d086 |
| D87 | Direct-path encryption at rest: bucket-default SSE-KMS | Resolved | d087 |
| D88 | Documents malware scanning: quarantine lifecycle | Resolved | d088 |
| D89 | Notification recipient resolution + CTAs as plain data | Resolved | d089 |
| D90 | Typed supported-asset vocabulary (SupportedAsset enum) | Resolved | d090 |
| D91 | Per-tenant base currency | Resolved | d091 |
| D92 | Defined pair-rate providers registry | Resolved | d092 |
| D93 | Durable async-task execution (task-runner) | Resolved | d093 |
| D94 | Concurrent multi-provider routing (ProviderRouter substrate, supersedes D60) | Resolved | d094 |
| D95 | First non-Conduit primary grant — Sumsub Onboarding go-live (GB/EU) | Resolved | d095 |
| D96 | Ceremony-less custody — drop the hosted co-sign/enrolment URL; signer/quorum changes are the MPC API-vote model | Resolved | d096 |
| D97 | Custody approval quorum — one general N-of-M engine, two consumers, via a published seam | Resolved | d097 |
| D98 | Utila custody go-live — Custody→utila (all six), no Conduit fallback, accepted travel-rule originator drop | Resolved | d098 |
| D99 | CI runs the dev compose stack — gates execute inside the app container | Resolved | d099 |
| D100 | CI test execution moves to Blacksmith ARM runners; static gates stay GitHub-hosted (refines D99) | Resolved | d100 |
| D101 | CI docs-only skip gate: last-green anchor + base-ancestry guard | Resolved | d101 |
| D102 | Fraud / device-intelligence go-live — Fingerprint primary + SEON permitted (all six), DeviceRiskGuard postures, deploy-gated | Resolved | d102 |
| D103 | Companion preview labels are a desired-state toggle (unlabel tears down, remove + re-add rebuilds); workflows linted by pinned actionlint | Resolved (deferred: shellcheck pass, companion build identity) | d103 |
| D104 | Tiered agent roster (stables-agent-kit plugin), Serena/Graphify code-graph MCP, tiered QA review, compressed reporting | Accepted (pilot) | d104 |
| D105 | Operator 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 |
| D106 | Postgres 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 cache | Accepted | d106 |
| D107 | Backend preview deploys gated on preview label; unlabel or PR close tears down (mirrors D103's companion label pattern) | Accepted | d107 |
| D108 | Probe-then-truncate-subset "fast reset" toggle for the tenant-DB test reset (TEST_FAST_RESET / TEST_FAST_RESET_PARANOID, default OFF, instrumented) | Accepted | d108 |
| D109 | Documents 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-tolerant | Accepted | d109 |
| D110 | Onboarding 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-only | Accepted | d110 |
| D111 | sFOX real-time ingestion: REST polling + reconciliation first; WebSocket listener deferred until sub-minute latency demanded | Accepted | d111 |
| D112 | API-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 |
| D113 | RequiresApprovedOnboarding 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 unmarked | Accepted | 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 (
RequiresApprovedOnboardinggate) - 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.