Architecture Decisions
Architecture Decisions
Section titled “Architecture Decisions”When to add an ADR: if you’d put it in CLAUDE.md, put it here instead.
Last updated: 2026-08-23
ADR-16 — 2026-08-23 — Active
Section titled “ADR-16 — 2026-08-23 — Active”Title: Consolidate the 15 deferred Workers’ D1 schemas into the 10 existing critical databases by theme, instead of upgrading to Workers Paid
Context: ADR-15 deployed only the 10 critical money-moving Workers to the new Finstack account, deferring 18 Workers (15 with their own D1) because the Free plan caps an account at 10 D1 databases total and finstack needs 25. That decision left payment’s PAYFAC/WALLET bindings and order’s INVENTORY/RECEIPT bindings stripped (5 open BUGS.md rows, all logged “Workers Paid or a D1 swap required”) and every deferred Worker’s gateway route 500ing (misconfigured_gateway). Confirmed via the D1 HTTP API before making any change: the account genuinely holds exactly 10 databases today, all real (svc-auth-db has 155KB and 19 real api_keys rows, payment-db has 76 real payments rows) — this is a live-data migration, not a greenfield layout exercise, and none of the 10 existing databases could be renamed/recreated without risk. Cloudflare’s Free plan D1 storage cap is 5GB total across the whole account (not per-database), so consolidating multiple Workers’ schemas into one physical database trades nothing on the storage axis — the account was using ~91KB of its 5GB before this change.
Decision: Fold each of the 15 deferred Workers’ schemas into whichever of the 10 existing critical databases is thematically closest, as pure schema additions — zero new databases created, zero existing databases touched beyond adding tables, zero data moved for the 10 critical Workers. Inventoried every real table name across all 25 Workers’ migrations/*.sql on disk first and confirmed zero naming collisions within any proposed group (the one near-miss — payfac’s own payments table vs. payment-db’s existing payments table — was resolved by routing payfac to routing-db instead, which only had processor_configs). Final mapping:
| Target database (existing, critical) | Deferred Workers folded in |
|---|---|
payment-db | tax, tip, split |
order-db | receipt, inventory |
compliance-db | fraud |
merchant-db | customer, loyalty |
routing-db | payfac |
platform-db | billing, ai, composer, database |
ledger-db | analytics |
payout-db | webhooks |
svc-auth-db, recipient-db | (unchanged — no thematic fit) |
Each deferred Worker’s wrangler.toml [[d1_databases]] block now points at its target database’s existing database_name/database_id, plus a per-Worker migrations_table (e.g. d1_migrations_tax) — without this, every Worker’s 0001_*.sql would collide in a shared d1_migrations bookkeeping table on the first apply, since wrangler d1 migrations apply keys purely on filename. Verified safe at the application-code layer, not just the schema layer, before deploying: @finstack/core’s TenantScopedDb always takes a hardcoded, developer-chosen table name (never attacker-controlled) and every query is parameterized; the one Worker with an arbitrary-SQL-shaped surface (database’s /database/query//transaction routes) proxies that SQL to the external Post AI service, never touching its own env.DB — so sharing a physical database introduces no new SQL-injection or cross-tenant-read surface beyond what already existed per-Worker.
Execution, same session: applied all 15 Workers’ migrations against their new shared target (wrangler d1 migrations apply --remote), confirmed via direct D1 queries that all 10 databases together now hold all 25 Workers’ tables with zero collisions and the account is still at exactly 10 total databases. Since this closed the reason the 5 BUGS.md rows were blocked, went further and deployed all 18 previously-deferred Workers (the 15 above, plus wallet/fanout/fraud-scoring-consumer, none of which have a D1 dependency and were only deferred as part of ADR-15’s blanket “18 non-critical” scoping) — created the 5 missing queues (analytics-queue(+dlq), webhook-delivery-queue(+dlq), fraud-scoring-queue) the Free plan account didn’t have yet, set INTERNAL_ASSERTION_SECRET (and webhooks’s WEBHOOK_ENDPOINT_MASTER_KEY) from vault on each, then restored payment’s PAYFAC/WALLET and order’s INVENTORY/RECEIPT service bindings and redeployed both, then expanded gateway’s service-binding table from 9 to the full 25 and redeployed it. Live-verified in production: every previously-500ing /v1/* route (tax, loyalty, inventory, fraud, billing, customers, receipts, splits, tips, ai, composer, database, wallet, analytics, webhooks/endpoints, disputes) now returns 401 (reached its Worker, correctly auth-gated) instead of 500 misconfigured_gateway.
Consequences: All 5 BUGS.md rows this ADR was meant to close are now resolved at the infrastructure level — see BUGS.md for each row’s closure note. finstack-ops-mcp’s DATABASE_IDS map (packages/finstack-ops-mcp/src/db.ts) was updated so every one of its 25 keys resolves to a real database id (the 15 that used to be null, e.g. fraud-db, now point at their consolidated target’s id, e.g. compliance-db’s) — a caller asking for “fraud-db” transparently gets routed to the physical database fraud’s tables actually live in, no other tool file needed to change. Known, accepted limitation, not hidden: a shared database means a shared blast radius for that database’s underlying Durable Object/storage layer — an outage or corruption affecting, say, platform-db now also takes down billing/ai/composer/database, not just platform, where before each had its own independent failure domain. finstack-ops-mcp’s local audit log (~/.finstack-ops-mcp-audit.log) records the caller’s logical database name, not the physical id queryD1 resolves it to — before this ADR the two were 1:1, so an operator reconstructing “what did this tool touch” from the audit log now needs to cross-reference DATABASE_IDS to de-alias a logged name (e.g. webhooks-db) to the physical database it actually queried (payout-db’s id); a query against sqlite_master/PRAGMA table_list for any of the 15 aliased names also now surfaces every Worker’s tables sharing that physical database, not just the named Worker’s own (documented in db.ts’s comment, not a leak — every named table itself stays collision-free). This was judged an acceptable tradeoff against the alternative (stay on Free plan and leave 15 Workers permanently 500ing) or upgrading to Workers Paid (a billing decision not made here). database’s POST_AI_API_KEY remains unset (pre-existing, already-documented gap, unrelated to this change — its arbitrary-SQL routes proxy externally and were never going to touch D1 regardless of consolidation) and ai’s optional NEUREUS_API_KEY was likewise left unset. Revisit this consolidation (split shared databases back apart) if/when the account upgrades to Workers Paid and the 10-database cap no longer applies — nothing about this design is hard to unwind, since migrations_table isolation means each Worker’s own migration history stays independently trackable even inside a shared physical database.
ADR-15 — 2026-08-22 — Active
Section titled “ADR-15 — 2026-08-22 — Active”Title: Migrate finstack off the shared Galactic CF account to a dedicated account; deploy the 10 critical Workers now, defer 18, on the Free plan
Context: finstack had run in the shared Galactic account since the Rust→CF cutover (ADR-9/ADR-11), alongside unrelated portfolio products. User requested a dedicated account, matching the same-day pattern already applied to Equitas (ADR-24) and Youkraine. The new account (Finstack, b38d376e1fe34bf09ddab7cfbf34c3f9) was still on Cloudflare’s Free plan, capped at 10 D1 databases — finstack needs 25.
Decision: Rather than wait on a Workers Paid upgrade, deployed only the 10 Workers that carry real money movement (svc-auth, payment, order, ledger, compliance, recipient, payout, platform, routing, merchant), traced from the actual wrangler.toml service-binding graph rather than guessed — plus messaging (a leaf dependency with no D1 of its own) and a gateway deliberately reduced from 24 to 9 service bindings (only the ones now deployed). The other 18 Workers (15 with their own D1, plus wallet/fanout/fraud-scoring-consumer) stay on Galactic, undeployed on the new account — their gateway routes correctly return 500 misconfigured_gateway in production, an accepted tradeoff, not a bug. Real production data was migrated (export/strip-schema/import) for the 10 critical databases; docs.finstack.sh was found to have been broken pre-migration and stays that way (not caused by this work). Full DNS cutover was executed the same session: the new zone got different nameservers than the live one (a real registrar NS-propagation window, not an instant swap), so the old zone’s api./webhooks.finstack.sh Custom Domain bindings were detached first (accepting real, bounded downtime) rather than risk a write landing in the now-stale old database during propagation.
Consequences: Cutover is real and live — api./webhooks./app./finstack.sh all confirmed serving through the new account. Two collateral gaps outside the original Workers-migration scope were found and fixed during verification: app.finstack.sh (dashboard, CF Pages) and bare finstack.sh (marketing, a separate Worker) both broke when the DNS zone moved, since neither was part of the 26-Worker migration plan — fixed via a cross-account CNAME for the Pages case (confirmed working per Equitas ADR-24) and by actually deploying marketing-site to the new account for the Worker case (a CNAME to *.workers.dev does not work, per the same ADR). Five real production defects were found in PR #554 review, all tracing to the same root cause (the 4 stripped service bindings plus payment’s orphaned payment-events queue producer) — real Stripe disputes retry forever unrecorded, a wallet-topup payment can capture without crediting, orders silently skip inventory decrement on the money-safety-critical /complete path, receipts go unissued, and downstream events are dropped after 24h. None were part of the original critical/deferred scope decision (that covered whole domains 500ing at the gateway, not live code paths failing past it) and none are fixed here — each needs its own explicit decision (provision the missing Worker, which itself needs Workers Paid or a D1 swap, vs. accept the risk) and is tracked as an open BUGS.md row instead. RECIPIENT_BANK_MASTER_KEY and RESEND_API_KEY also have unresolved gaps (see TODO.md’s 2026-08-22 entry). Only read-path soak testing was done on the new account before cutover — the first real payment there is also the first real write-path test. finstack-ops-mcp (the local operator tool) still had all 25 old-account database ids hardcoded post-cutover, silently answering from the abandoned account with no error — closed by making the 15 undeployed entries explicit null so the tool now fails loud (“not yet provisioned”) instead. Old Galactic account’s gateway/D1s left untouched (not deleted) as a rollback path, but rollback is data-losing, not clean — new-account writes since cutover have no old-account counterpart.
ADR-14 — 2026-08-01 — Active
Section titled “ADR-14 — 2026-08-01 — Active”Title: Payout creation gates on platform’s existing availableBalanceMinor read (check-only), not a real debitIfSufficient() reservation
Context: The 2026-08-01 launch-readiness audit (docs/LAUNCH_READINESS_2026-08-01.md, Critical finding #3 / TICKET-16.5) found that payout’s POST /payouts never checks platform’s operating-account balance at all — Stripe Connect payouts (live, real money, ADR-12) can be created and processed regardless of platform_accounts.balance_minor. platform’s debitIfSufficient() (platform/src/index.ts) is real, atomically TOCTOU-safe (single UPDATE…WHERE…RETURNING against its own D1), and unit-tested — but calling it from POST /payouts at create time double-counts: platform’s own GET /platform/accounts/:currency already computes availableBalanceMinor = balance_minor - pendingOutboundMinor, where pendingOutboundMinor is a live cross-Worker sum of payout’s own pending/processing rows. The instant a payout is inserted as pending, it’s already counted in pendingOutboundMinor — a second, real decrement of balance_minor via debitIfSufficient() would subtract the same payout from availability twice.
Two ways to resolve this were considered:
- (A) Check-only:
POST /payoutsreads the existingavailableBalanceMinor(unchanged formula, unchangedGET /platform/accounts/:currencyroute) and rejects with422 insufficient_balanceif the requested amount exceeds it.balance_minoritself is never touched at create time — only by real funding credits (ACH, still unbuilt) or a future settle-time reconciliation.pendingOutboundMinorcontinues to be the reservation mechanism, exactly asGET /platform/accounts/:currencyalready implied before anything gated on it. - (B) Real atomic reservation: switch to
debitIfSufficient()at create time (fully TOCTOU-safe withinplatform’s own D1), removependingOutboundMinor’s subtraction fromavailableBalanceMinor(now justbalance_minor, since the debit already reflects it), and add release-on-failure/cancel logic (acredit()call) everywhere a payout can leavepending/processingwithout settling (/fail,/cancel,/mark-returned, and the ambiguous-Stripe-outcome paths Task 7 already treats specially). This is fully race-safe but touchesplatform’s response contract and every payout-exit path inpayout, not just creation.
Decision: (A), check-only. It closes the Critical gap (zero gate → a real, enforced gate) with the smallest possible change: one new cross-Worker read in POST /payouts, no changes to platform’s existing routes or response shape, no new release-on-failure bookkeeping. Known, accepted limitation, not hidden: this introduces a real but narrow TOCTOU race — two concurrent POST /payouts requests can both read the same availableBalanceMinor and both pass the check before either’s row lands in payouts, together exceeding available balance. platform_accounts and payout-db are separate D1 databases (different Workers), so no single atomic cross-database transaction can close this race outright — (B) would narrow it (real reservation is atomic within platform’s own D1) but not eliminate the deeper cross-Worker check-then-insert shape entirely, and costs materially more to build correctly (touches /fail//cancel//mark-returned, the ambiguous-outcome paths, and platform’s public contract).
Consequences: Ship (A) now to close the live financial-risk gap immediately. Revisit (B) — or a narrower fix scoped just to the TOCTOU window (e.g. a short-lived reservation row in platform created before the check and consumed/expired around the payout’s own create-or-fail outcome) — once real ACH funding (TICKET-16.6, currently unbuilt) makes balance_minor a live, frequently-changing number where the race window matters more in practice. Today, with no ACH funding wired up, balance_minor only changes via manual/operator credit, making the race’s practical likelihood low even though it’s real. debitIfSufficient() remains real, tested, unused code — same status as before this ADR, not dead by mistake but deliberately deferred to option (B). See docs/LAUNCH_READINESS_2026-08-01.md, docs/LAUNCH_PLAN_2026-08-01.md’s TICKET-16.5/16.6.
Addendum, 2026-08-01 (same day, PR #520 review) — currency normalization + a residual data-check follow-up. Two independent PR review passes found validateCreatePayoutBody never normalized currency casing, so "USD" vs "usd" from an otherwise-funded tenant would resolve to two different platform_accounts rows — inert before this ADR (nothing gated on currency), newly load-bearing once the balance check shipped. Fixed by lowercasing at validation time (matches Stripe’s own currency-code convention and platform’s funding default). The same review round also found — and fixed — that KycGatedPayoutWorkflow’s POST /payouts/gated route used the identical insertPayout() call as POST /payouts but had no balance check at all, a complete bypass of this ADR’s gate; the Workflow now runs the identical checkPlatformBalance() check as its own step. One residual item, not verified from this session (no D1 query access available): whether any pre-existing production payouts row has uppercase currency. If so, pending-outbound-total’s WHERE currency = ? (now always queried lowercase) would silently exclude that row from the sum, overstating availableBalanceMinor for that tenant/currency pair until the row leaves pending/processing. This only affects the new gate’s precision for legacy mixed-case rows — it’s still strictly better than the pre-ADR-14 state of no gate at all, and self-corrects as those rows settle/fail. Recommend SELECT DISTINCT currency FROM payouts WHERE status IN ('pending','processing') via an ops-DB tool before/around the next deploy; if all lowercase, no action needed.
ADR-13 — 2026-07-29 — Active
Section titled “ADR-13 — 2026-07-29 — Active”Title: Internal ops MCP ships as a local stdio process, not a deployed admin Worker
Context: The user asked for a FinStack ops MCP matching the pattern already used elsewhere in the portfolio (codemind-ops, neureus-ops, appedge-ops, agentic-ops-mcp). That pattern splits into two shapes across the portfolio: deployed HTTP Workers mounted on an existing product domain (neureus-ops at app.neureus.ai/ops/mcp, appedge-ops at appedge.finhub.vc/ops/mcp), and local stdio processes that hit the Cloudflare D1 HTTP API directly with a vaulted token (codemind-ops-mcp, agentic-ops-mcp — the latter on the same Galactic CF account FinStack uses). FinStack has no existing single-Worker product domain to mount an /ops/mcp route on the way neureus/appedge do — its only public hostname is gateway, the highest-trust Worker in the fleet, serving 100% of anonymous production traffic. Building a new standalone admin Worker + hostname was the other option, mirroring finstack-admin-gateway’s original shape.
Decision: Local stdio (the agentic-ops-mcp shape), not a deployed Worker. packages/finstack-ops-mcp runs only when launched by Claude Code, reads CF_ACCOUNT_ID/CF_API_TOKEN from environment variables (a static value in ~/.claude.json’s MCP server config, originally generated via the vault but not fetched from it at launch time), and talks to https://api.cloudflare.com/client/v4/accounts/{id}/d1/database/{id}/query directly — no Cloudflare deployment, no hostname, nothing internet-reachable at all. This was a deliberate rejection of both a new standalone Worker (repeating finstack-admin-gateway’s shape, deleted 2026-07-26 for being unauthenticated and un-allowlisted — see BUGS.md) and mounting an /ops/mcp route onto gateway itself (would add cross-tenant D1 access to the one Worker whose entire job is untrusted public input, days after the ADR-11/fleet workers_dev=false work specifically shrank that Worker’s blast radius). Read-only is enforced in code (queryD1 rejects anything but a single SELECT/PRAGMA before any network call — no WITH/CTE, no stacked statements) and every returned row is redacted: secret/credential/password/hash by substring match, code/evidence by exact name only (narrowed after a review round found the broader substring form silently redacting real, non-sensitive columns this tool’s own primary use case needs — see BUGS.md-worthy detail in db.ts’s comments). This is a key-name filter over the returned row shape, not a query-plan analysis, and it is bypassable by design, not by oversight: aliasing a sensitive column to an innocuous name (with or without AS, or wrapped in an expression) defeats it, and a WHERE-clause predicate can exfiltrate a value blindly without it ever reaching the response. The actual privilege boundary is possession of CF_API_TOKEN itself — whoever holds it already has unmediated D1 access outside this tool (a raw curl to the same endpoint bypasses redaction completely) — so the filter’s real job is catching accidental exposure (SELECT * surfacing a secret column nobody meant to look at), not defending against deliberate extraction by someone who already holds the credential. General PII (email/phone/address) is out of scope entirely, left visible. Documented in SECURITY.md/INTERFACES.md, not assumed covered.
Two Codemind build_feature attempts hit distinct, reproducible engine bugs rather than spec-quality problems — filed as codemind#688 (oracle sandbox lacks Node’s process global and can’t mock fetch for a “rejects before ever calling fetch” test case) and codemind#690 (oracle test can’t resolve the build’s own target file when existingFiles is also supplied — reproduced with two different file layouts). Implemented inline per the documented Codemind failure procedure. Three rounds of a 4-agent review (code-reviewer, silent-failure-hunter, pr-test-analyzer, type-design-analyzer) followed: round 1 caught the first inline version’s redaction gap (keyed off the returned column name, so SELECT client_secret AS sk FROM ... bypassed it) and a schema mismatch in the substring list (otp_code matched zero real columns; the real live-credential column, oauth_authorization_codes.code — stored plaintext per its own migration comment — was uncovered; replaced with code). The first fix (a regex rejecting AS-aliasing) itself turned out incomplete on round 2 — it missed expression-wrapped bypasses (substr(client_secret,...) AS x) while also false-positiving on ordinary table aliases (FROM oauth_authorization_codes AS c) — so it was removed rather than kept as a false sense of security; the docs now state the limitation plainly instead. Round 2 also found the D1-error-echo truncation fix only covered one of two flagged sites, closed in the same pass. 35 tests passing locally (vitest run, tsc --noEmit), plus live end-to-end verification against real production svc-auth-db: an aliased-secret query correctly returns unredacted (the documented, accepted limitation, not a regression), a legitimate table-alias query that the removed guard would have wrongly blocked now succeeds, and an ordinary SELECT * still redacts hash correctly.
Consequences: v1 ships two tools — ops_db_query (ad-hoc read-only SQL across all 23 named D1 databases) and ops_stale_payouts (the read side of the manual payout-db reconciliation gap CLAUDE.md’s ADR-12 entry documents — there’s still no API-level resolution path out of status='processing'). A migration-drift-check tool (this repo hit that bug class 4 times in July — see BUGS.md/CLAUDE.md) was scoped out of v1 since it needs local filesystem access to finstack/workers/*/migrations/ alongside D1 state, a materially different shape from the two shipped tools; it’s the natural next addition. Because this is a local process, it only runs where the vaulted CF_API_TOKEN and this session’s Claude Code config live — it is not available to any other operator or CI context without separately provisioning both. See COMPONENTS.md, INTERFACES.md, ARCHITECTURE.md, SECURITY.md.
Addendum, 2026-07-29 (same day) — scope correction to a domain-rich tool set, and the write-layer design. The v1 shape above (2 tools, one of them a generic SQL wrapper) turned out to be the wrong read of the portfolio pattern this ADR’s own Context section describes: every other ops-mcp (tap2-ops-mcp especially, the closest fintech analog) exposes ~20 domain-specific named tools per business entity (get_payment, list_recent_payments, resolve_dispute, billing_get_mrr, …), with a generic SQL tool as one escape-hatch among many, not the primary interface. Corrected by expanding to 22 tools across 9 domains (payments, payouts, recipients, merchants, compliance, ledger, billing, disputes, tenants — see COMPONENTS.md/INTERFACES.md for the full list) plus a local audit log (~/.finstack-ops-mcp-audit.log, every call’s tool/args-summary/outcome/duration, best-effort — matches tap2-ops-mcp’s ops_audit_log table pattern, adapted to a file since this stdio process has no database of its own to write into).
Write-layer design, deliberately not built in this pass. tap2-ops-mcp proxies writes to its real backend API for state-machine-sensitive operations (resolve_dispute → callCoreRs, an HMAC-signed call to tap2-core). The equivalent for FinStack looked like: mint a signed internal assertion (signAssertion(INTERNAL_ASSERTION_SECRET, tenantId, scopes), the same mechanism every Worker uses for Worker-to-Worker calls) and call the target Worker’s HTTP route directly. Live-verified this doesn’t work: every non-gateway Worker returns Cloudflare’s error code: 1042 edge-block (curl https://payout.glctc.workers.dev/... confirmed, HTTP 404 status but the distinctive 1042 body) — the ADR-11/fleet workers_dev=false lockdown this ADR’s own rejection of a standalone Worker relied on also means there is no HTTP endpoint on any target Worker for an external caller (this local process) to reach directly, signed assertion or not; that mechanism is Worker-to-Worker (service-binding) only. The actual fix is simpler than the aborted design: FinStack’s real public API is gateway at api.finstack.sh, and it’s fully reachable — POST /v1/payouts/:id/fail, POST /v1/compliance/kyc/:id/decide, etc. are ordinary /v1/* routes, no Worker-direct call or assertion-minting needed. The only constraint is that gateway authenticates via tenant-scoped sk_* keys, not a blanket admin credential, so cross-tenant writes need one key per tenant. Checked live: FinStack currently has no stable production tenant roster (6 tenant_ids across svc-auth-db’s api_keys, all dev/live-verification artifacts from earlier sessions this month, no overlapping activity across payout-db/merchant-db/recipient-db) — so minting write-capable keys now would provision credentials for tenants with no ongoing relevance. Decision: ship the full read surface now (this addendum), defer the write layer until either a real tenant exists to mint a key for, or the specific write is needed — at that point, mint a tenant-scoped sk_* key via the existing /internal/provision-key runbook (temporary workers_dev=true on svc-auth, call, revert — the same pattern already used repeatedly this month, see the 2026-07-28 entries in CLAUDE.md), store it in vault, and add a thin api.ts gateway client alongside the existing db.ts D1 client. True cross-tenant admin actions (list all tenants, provision a new tenant) stay a manual runbook step regardless, since there’s no /v1/tenants route — only /internal/provision-key, which has the same reachability constraint.
ADR-12 — 2026-07-28 — Active
Section titled “ADR-12 — 2026-07-28 — Active”Title: Multi-rail payout orchestration via the existing ProcessorRouter — Stripe Connect (Custom, separate charges and transfers) first, Moov second; neither exclusive
Context: A platform/merchant onboarding-readiness review found that payout, wallet withdrawal, and recipient bank-account verification are all internal D1/ledger bookkeeping with zero connection to any real processor — nothing has ever moved real money out of finstack. Two prior docs each picked a single vendor for this gap and conflict with each other: docs/epics/EPIC-019-stripe-connect.md (2026-06-ish) specified Stripe Connect using destination charges (real-time split at charge time) with Express accounts; docs/superpowers/specs/2026-07-05-seller-payout-wallet-withdraw-design.md explicitly chose Moov, “NOT Stripe Connect,” for the same underlying problem. Neither was ever implemented — processor_configs has zero rows in production and ProcessorRouter/routePayout() (built for exactly this purpose, ADR-5) has zero callers anywhere in the codebase.
Decision: finstack orchestrates money movement itself — it is not choosing one processor to hand payout responsibility to. Confirmed during this review: finstack’s actual model is “charge the customer to finstack’s own account now, decide later, on demand, how much to pay which recipient” — not real-time splitting. That rules out destination charges but does not rule out Stripe Connect: Stripe’s separate charges and transfers flow (plain PaymentIntent on finstack’s own account, no connected account referenced at charge time, followed by a decoupled Transfer to a Custom connected account whenever finstack’s own payout logic decides) is exactly this model. Custom (not Express) because finstack already collects its own KYC end-to-end (compliance’s Stripe Identity integration, ADR-10) and controls its own onboarding UX rather than redirecting to Stripe-hosted flows — Custom requires the platform to submit verification data via API instead of Stripe collecting it directly, which matches work already done, not new scope.
Both Stripe (Custom, separate charges and transfers) and Moov (ACH/RTP) are meant to be adapters behind the same ProcessorRouter/processor_configs abstraction ADR-5 already defined — selected per recipient/tenant/product requirement via processor_type, not a single platform-wide vendor choice. Stripe ships first (2026-07-27 decision) because it reuses the existing Stripe relationship and KYC investment; Moov remains the second rail, not a rejected one.
Consequences: payout must actually call PayoutRouter/routePayout() instead of writing a fake settled status (see BUGS.md’s is_tenant_root migration-drift entry from the same review — this is the same class of “designed but never wired in” gap). recipient’s data model needs to carry either a stripe_account_id (Stripe-routed recipients) or raw bank account/routing (Moov-routed recipients) — not just one shape. compliance needs two additions neither of which exist today: fetching and persisting Stripe Identity’s verified_outputs (currently the webhook handler never calls the follow-up GET .../verification_sessions/{id}?expand[]=verified_outputs, so no name/DOB/address survives anywhere), and a new SSN/EIN collection step for Custom-account onboarding — Identity verification structurally never supplies SSN/EIN regardless of what’s fetched. OFAC/sanctions screening (already a known stub, docs/cf-rewrite-deferred-scope.md) becomes a hard blocker the moment either rail moves real money, not before. Addendum, 2026-07-28 (user): this line assumed zero screening happens anywhere in the path — clarified that both rails route through processors (Stripe Connect Custom underwriting, Moov’s own KYC/AML) with their own sanctions-screening obligations, which materially changes the exposure this “hard blocker” framing implied. See SECURITY.md’s OFAC entry for the corrected picture; compliance’s own screening remains unbuilt regardless. docs/epics/EPIC-019-stripe-connect.md and the 2026-07-05 seller-payout doc are both annotated as corrected by this ADR, not deleted or rewritten. See docs/superpowers/specs/2026-07-28-stripe-connect-custom-payout-design.md for the implementation design.
ADR-11 — 2026-07-27 — Active
Section titled “ADR-11 — 2026-07-27 — Active”Title: Full teardown of ADR-9’s leftover Rust infrastructure — rollback path permanently closed
Context: ADR-9 was a code-only removal — finstack-rs’s Fly backend, its Neon Postgres database, and two of its three CF Worker gateways were deliberately left running and untouched as an instant-rollback safety net, and DEPLOYMENT.md explicitly said never to delete them. Two days later, on explicit user directive (“clean all up”), that safety net was retired outright.
Decision: Deleted, via direct CF/Neon API calls (confirmed no MCP tool existed for a scripted worker-delete, used wrangler-equivalent REST calls with vault-sourced tokens per CLAUDE.md’s Cloudflare Auth section):
finstack-api-gatewayandfinstack-webhooks-gateway(Galactic account) — the last two Fly-fronting gateways ADR-8’s rollback plan depended on.- A previously-undocumented discovery made while locating the above: pre-migration copies of all three original gateways (
finstack-admin-gateway,finstack-api-gateway,finstack-webhooks-gateway) still existed under the FGV account, orphaned since INC-003 (2026-07-25) moved DNS/Workers to Galactic. The FGVfinstack-admin-gatewaycopy still carried the unpatched unauthenticated-GATEWAY_TOKEN_ADMINexposure BUGS.md flagged Critical on 2026-07-18 — the 2026-07-26 “deleted outright” record (CLAUDE.md, BUGS.md, TODO.md) only ever covered the Galactic-account instance. All 5 stale scripts across both accounts are now deleted. - Neon project
ancient-heart-91326215(FGV org), containing real pre-cutover financial data (46 payments, 6 tenants, 3 recipients, 1 recipient bank account, 2 merchant accounts, KYC/KYB check history). Given the financial/compliance sensitivity and the Neon MCP tool’s own “never invoke destructively without asking” guardrail, exported a full backup first (pg_dumpcustom + plain-SQL format,/Users/am/backups/finstack-neon-2026-07-27/, owner-only file permissions) and verified row counts matched before deleting the project.
Not completed — a real gap, not a silent skip: The Fly.io finstack-api app itself could not be located or torn down. No Fly credential exists anywhere in the vault, and flyctl under the only available login (am@finhub.vc) returns Could not find App "finstack-api". This is the identical access gap TODO.md already logged on 2026-07-15 (blocking a separate RLS-role live-secret check) — re-confirmed here, still unresolved. If the app is still running and billing, or if it needs decommissioning for compliance reasons, someone with the owning Fly account’s login (or added as a collaborator) needs to do it directly.
Consequences: ADR-9’s third consequence (“rollback path is a fragile one-shot”) is now obsolete — there is no rollback path left through Cloudflare at all; reverting to Rust would require redeploying from git history and recreating Fly infrastructure from scratch, if the Fly app is even still there. Verified live post-deletion: api.finstack.sh/webhooks.finstack.sh continue routing correctly through finstack’s own gateway Worker only, and the old finstack-admin-gateway.fgvco.workers.dev URL now 404s instead of serving an unauthenticated admin token. See DEPLOYMENT.md, ARCHITECTURE.md, ENVIRONMENTS.md, BUGS.md, TODO.md, CHANGELOG.md for cross-referenced detail.
ADR-10 — 2026-07-26 — Active
Section titled “ADR-10 — 2026-07-26 — Active”Title: Stripe Identity as compliance’s KYC/KYB provider, replacing Persona
Context: Persona was removed outright the same day (see ADR-9-adjacent history in VENDORS.md/BUGS.md) after an audit found it was never reachable end-to-end even with a real API key — no gateway webhook route, no link from a KYC decision to recipients.status/merchant_accounts.status, no KYB decision route at all. A fresh KYC/KYB vendor decision was needed, scoped from zero rather than resuming the deleted code.
Decision: Stripe Identity’s embedded/client-side verification, chosen over a Persona-style hosted-redirect flow, for both recipient KYC and merchant primary-signer KYB (a scope the user explicitly caught mid-design — the initial design covered recipients only). Two architectural decisions carry the actual weight:
- Retry-safety write ordering: on a terminal webhook event, the cross-Worker
RECIPIENT/MERCHANTstatus update must succeed beforecompliancecommits its own local terminal state — otherwise a failed downstream call combined with a committed local terminal state leaves no way to retry (the state machine’s terminal-lock rejects a second attempt at the same transition). - Permanent vs. transient failure classification: both a 404 from the downstream Worker (
subject_idwill never resolve) and anillegal_transition/not_foundresult fromcompliance’s own state machine (deterministic given the row’s current state — Stripe Identity can emitrequires_inputmore than once per session, each a distinct event id, and a second one against an already-pending_reviewrow is a self-loop the state machine correctly rejects) are permanent failures that must be marked processed and logged loudly, not left for Stripe to retry forever. Both were found and fixed via PR review before/during merge — a first version of the fix only handled the subject-404 case and missed theillegal_transitioncase, which is the same failure class the removed Persona integration was found to have. - Stripe Identity is scoped to primary-signer identity verification only, not full beneficial-ownership KYB completeness — multi-owner cases still route through the existing manual
/beneficial-owners+/decideflow. merchant_accounts.statusgained apending_verificationdefault for new merchants (existing rows grandfatheredactive); nothing downstream reads this column today, so this is a real but currently-inert behavior change — new merchants have no automated path toactivewithout a KYB check, a known consequence, not silently glossed over.
Consequences: Closes the exact gap the Persona-removal audit found — a terminal KYC/KYB decision now genuinely propagates downstream. Dashboard UI (Stripe.js embed to trigger verification) is deliberately deferred to a follow-up plan, not built here. See VENDORS.md, DATA_MODEL.md, ARCHITECTURE.md, docs/superpowers/specs/2026-07-26-stripe-identity-kyc-design.md.
ADR-9 — 2026-07-25 — Active
Section titled “ADR-9 — 2026-07-25 — Active”Title: Full finstack-rs removal — finstack becomes the sole backend
Context: ADR-8 (below) cut api./webhooks./paystream.api.finstack.sh over to finstack in production the same day, with Rust’s Fly backend kept running as an instant-rollback target and the dashboard deliberately left on Rust pending a /passkeys/* route on the CF side. On explicit user directive, treating the Rust track as no longer needed at all — full, immediate code removal, accepting the consequences below rather than waiting for CF passkey parity first.
Decision: Deleted finstack-rs/ (all crates, bins, migrations, Cargo/Fly config) and the three Cloudflare Worker gateways that existed solely to proxy to it (api-gateway, finstack-admin-gateway, finstack-webhooks-gateway — dead code once there’s no Fly container to forward to). This is a code-only removal: Fly and Neon infrastructure are left running and untouched (not destroyed, not scaled down) — fully reversible via git checkout <pre-removal-sha> -- finstack-rs/ if ever needed, though the deployed Fly apps would need to be recreated from that history since nothing there was torn down or preserved as a redeploy target. finstack was verified to have zero source dependency on any deleted path before removal (grep across finstack/ found only historical port-attribution comments, e.g. “ported from finstack-rs’s service.rs::transition” — no imports); gateway and payment Worker test suites pass unchanged post-deletion.
Consequences: Three concrete, previously-undocumented regressions, on top of the dashboard-login gap ADR-8 already named:
/admin/*now has no Worker at all. It only ever existed via the deletedfinstack-admin-gateway→ Rustapi-admin. No CF equivalent exists. Revisit before any operator tooling is needed.developer-portal’spnpm run update-specfetches/openapi.jsonfromfinstack-api-gateway.workers.dev, which no longer exists (andfinstack’sgatewayWorker has no/openapi.jsonroute). Revisit before publishing API docs again.The rollback path ADR-8 relied on is now a fragile one-shot, not gone outright. Deleting the repo source didn’t touch the deployedSuperseded by ADR-11 (2026-07-27): this rollback path was deliberately and permanently closed — see ADR-11 for the full teardown (CF gateways in both accounts, Neon database, Fly status unresolved).finstack-api-gateway/finstack-admin-gateway/finstack-webhooks-gatewayWorker scripts (confirmed still live on Cloudflare) or the Fly backend they proxy to (also untouched) — rebinding Custom Domains back to them would still work if Fly is still serving. What’s gone is the ability to ever redeploy those scripts if they’re lost, since the source no longer exists. Never delete or overwrite them in Cloudflare; seedocs/cf-rewrite-rollback-plan.md.
Everything already listed in docs/cf-rewrite-deferred-scope.md (Moov, wallet P2P/withdrawal, consumer OTP, live Persona KYC, database/Post AI, Paystream flow automation) remains deferred, unchanged by this ADR. See CLAUDE.md and ARCHITECTURE.md’s “CF Architecture” section for the current-state description; the pre-removal Rust architecture is preserved in git history rather than re-narrated in either doc.
ADR-8 — 2026-07-25 — Active
Section titled “ADR-8 — 2026-07-25 — Active”Title: Partial finstack cutover — api./webhooks./paystream.api.finstack.sh moved to production, dashboard deliberately excluded
Context: The Rust→finstack go/no-go decision was made earlier the same day (full cutover approved, scoped deferrals recorded in docs/cf-rewrite-deferred-scope.md). Real-traffic proof was established afterward (7.3s end-to-end payment cycle via a newly-fixed webhook receiver route). On explicit real-time user directive (“CUT OVER NOW”), the actual DNS/Custom-Domain switch was executed the same day — not deferred to a separately-scheduled maintenance window.
Decision: Rebound the Cloudflare Workers Custom Domains for api.finstack.sh, webhooks.finstack.sh, and paystream.api.finstack.sh from Rust’s finstack-api-gateway/finstack-webhooks-gateway to finstack’s own gateway Worker (PUT .../workers/domains with override_existing_origin: true). Deliberately excluded from this cutover: app.finstack.sh (dashboard) — checked finstack/workers/svc-auth/src/index.ts’s actual route table directly before proceeding and found no /passkeys/* route exists at all (only /internal/validate, /internal/provision-key, /mfa/*, /oauth/*) — cutting the dashboard over would have locked out every user with no login path, a functional gap, not a risk tradeoff to weigh. finstack.sh (marketing) and docs.finstack.sh (static docs) were never in scope — no CF-rewrite equivalent exists for either.
Consequences: This is genuinely the first real production traffic finstack has ever served. Rust’s Fly backend (finstack-api) remains fully running, untouched, as the rollback target — reverting is the same Custom Domain rebind in reverse, an edge-level operation with no DNS propagation wait. All of finstack’s previously-deferred scope (Moov, OFAC/sanctions, wallet send/pay/withdraw, consumer OTP, database/Post AI, real Persona KYC) remains deferred and now applies to a live production surface, not just an evaluation one — see docs/cf-rewrite-deferred-scope.md. Dashboard login staying on Rust means the dashboard’s api.finstack.sh BFF calls now cross from Rust’s session/auth model to finstack’s gateway/svc-auth — not yet verified end-to-end; worth a dashboard smoke test. The unrelated custom_domains.rs live bug (BUGS.md — Rust’s hardcoded constants point at the zone deleted in INC-003) is untouched by this ADR and still blocked on Fly.io credentials.
ADR-7 — 2026-07-13 — Active
Section titled “ADR-7 — 2026-07-13 — Active”Title: merchant_accounts is the sole Merchant identity; payfac_sub_merchants retired
Context: Two merchant-like entities existed unlinked. payfac_sub_merchants (migration 0019, 2026-05-17) shipped first as a general PayFac sub-merchant onboarding primitive. A 2026-07-01 Tap2 consolidation spec evaluated it as a “close conceptual match” for Tap2’s platform/merchant needs but flagged a blocker: no membership/identity concept (no way to say “these users can act as this merchant”). That gap was never resolved. A 2026-07-05 tenancy spec then designed the real three-tier model (tenant → merchant → user-with-role) and shipped merchant_accounts + merchant_account_members (migration 0065) as clean new tables, without referencing or reconciling the earlier svc-payfac finding. The two entities coexisted, unlinked, until this ADR.
Decision: merchant_accounts (svc-merchant) is the canonical Merchant identity going forward. payfac_sub_merchants’s CRUD/FSM is removed (migration 0072); svc-payfac’s disputes/risk/fees modules survive unchanged since they were already keyed on connect_account_id, never on payfac_sub_merchants, and require no re-homing.
Consequences: One merchant identity, matching what payments.merchant_account_id, customers.merchant_account_id, and connect_accounts.merchant_account_id already FK to. svc-payfac narrows to its liability-tooling role (disputes, risk scoring, fee reporting). Verified before removal: 0 prod rows in payfac_sub_merchants, no OpenAPI/developer-portal surface, no shared scopes with the retained disputes_routes.rs.
ADR-6 — 2026-07-09 — Active
Section titled “ADR-6 — 2026-07-09 — Active”Title: Card-present acceptance abstracted behind a separate CardPresentProcessor trait, not folded into PaymentProcessor
Context: api-rest/src/terminal.rs (EPIC-018) hardcoded Stripe for every terminal operation — calling api.stripe.com directly with a stripe_secret() helper, bypassing ProcessorRouter/PaymentProcessor (the ADR-5 abstraction every other payment path uses). Bringing card-present in line and adding Moov Tap-to-Pay as a second processor raised a design question: extend PaymentProcessor, or introduce a new trait? Card-present session models differ fundamentally between processors — Stripe Terminal issues a connection token that drives server-registered reader objects (register/list/deregister/cancel-action); Moov Tap-to-Pay registers a terminal application linked to a merchant account (async, confirmed via the terminalAppRegistration.updated webhook) with no server-side reader object. These operations have no counterpart in PaymentProcessor (create_intent/capture/cancel/refund), and forcing them in would make every processor stub methods it cannot support.
Decision: Add a separate CardPresentProcessor trait in primitive-payment (card_present.rs), parallel to PaymentProcessor. Methods only one processor supports carry a default impl returning ProcessorError::Terminal("...not supported"): register_terminal_app (Moov-only) and the four reader methods (Stripe-only). create_session — the one operation common to both session models — has no default; every processor implements it. terminal.rs routes through ProcessorRouter.route(tenant, "card_present", currency, None) and dispatches to the impl selected by config.processor (mirroring finstack-worker’s PaymentProcessor dispatch). Credentials flow as decrypted serde_json::Value and responses as raw serde_json::Value, matching PaymentProcessor’s conventions.
Consequences: Adding a card-present processor requires only a new ProcessorCredentials variant + impl CardPresentProcessor + one dispatch arm — no migration (processor_configs.processor is a format regex, not an enum; methods is a bare TEXT[], so card_present is a valid routing value — verified against migration 0034), no FSM change, consistent with ADR-5. Reader routes stay Stripe-shaped; a processor whose model lacks readers returns the trait default, surfaced as a 400. The asymmetry between session models is expressed once, in the trait’s default impls, instead of leaking into every adapter.
ADR-5 — 2026-06-07 — Active
Section titled “ADR-5 — 2026-06-07 — Active”Title: Payment processors abstracted behind PaymentProcessor trait; tenants configure multiple processors with routing rules
Context: primitive-payment had stripe_payment_intent_id and stripe_processor_status baked into its domain model with no processor trait. Adding a second processor required schema changes, new Stripe-named columns, and code duplication. Tenants also need to route payments across multiple processor accounts (e.g. different Stripe accounts for different currencies, or Stripe + Adyen in parallel).
Decision: Introduce a PaymentProcessor trait in primitive-payment. Each processor (Stripe, Adyen, etc.) implements the trait in its own crate (primitive-payment-stripe, primitive-payment-adyen, …). A new svc-routing crate holds ProcessorRouter, which selects the best processor_config for a payment based on method, currency, and optional explicit label. The payments table stores generic processor_config_id, processor_intent_id, processor_status columns. Per-tenant credentials live in processor_configs (AES-256-GCM encrypted JSONB, keyed by PROCESSOR_MASTER_KEY). Migration 0034_multi_processor.sql migrates existing Stripe data and drops the old Stripe-specific columns.
Consequences: Adding a new processor requires only a new ProcessorCredentials variant + impl PaymentProcessor — no migration and no FSM change. The processor_config_id FK on payments durably binds each payment to the config used at creation time, so in-flight payments are unaffected by later config changes. stripe_tenant_configs is kept but not dropped until the new config path is confirmed stable.
ADR-4 — 2026-06-07 — Active
Section titled “ADR-4 — 2026-06-07 — Active”Title: Dashboard auth is passkeys-only — no passwords, no OAuth providers, no D1
Context: The original dashboard used NextAuth v5 (magic link + GitHub + Google OAuth) with sessions in D1 and AES-GCM-encrypted API key cache. This required D1, AUTH_DB binding, FINSTACK_KEY_SEED, and a self_provision.rs endpoint in the Rust core. The passkeys implementation (R0–R4, D1–D4 PRs) made all of this redundant.
Decision: The dashboard uses WebAuthn passkeys as the sole auth method. No email/password, no OAuth providers. D1 finstack-dashboard-auth and the old Next.js dashboard/ have been deleted. self_provision.rs and the /auth gateway route are removed.
Consequences: Dashboard auth is biometric-first, phishing-resistant, and stateless w.r.t. secrets (no keys in D1). Session state lives in session_tokens (Postgres, FORCE RLS). Account recovery is POST /admin/users/{id}/passkeys/reset.
ADR-3 — 2026-06-06 — Active
Section titled “ADR-3 — 2026-06-06 — Active”Title: Four dedicated CF Worker gateways (API, dashboard, webhooks, admin)
Context: ADR-2 mandated gateways but left the topology unspecified. A single multiplex gateway handling all surfaces conflates security domains (Stripe IP allowlist logic shouldn’t run on every API call) and makes surface-specific rotation impossible without redeploying everything.
Decision: Four dedicated gateways, each with its own GATEWAY_TOKEN_* secret:
finstack-api-gateway—/v1/*,/mcp,/oauth(SDK/developer consumers)finstack-admin-gateway—/admin/*(operator/internal)finstack-webhooks-gateway—/webhooks/stripe(Stripe, IP allowlist via KVfinstack-stripe-ips)- Dashboard BFF (
[[route]].tsCF Pages Function) —/passkeys/*viaGATEWAY_TOKEN_DASHBOARD
Consequences: Per-surface token rotation. Stripe IP allowlist only runs at the webhooks gateway. Dashboard BFF is co-deployed with the SPA (no separate Worker to manage). Core validates the surface-specific token via gateway::TokenMap; wrong surface → 403.
ADR-2 — 2026-05-31 — Active
Section titled “ADR-2 — 2026-05-31 — Active”Title: All APIs must be exposed via dedicated CF Worker gateways, not directly from the core
Context: The Rust backend (finstack-rs) runs on Fly.io with a public URL
(finstack-api.fly.dev). The current architecture lets the dashboard and
external callers hit the Fly container directly. This exposes the container
URL to the open internet, bypasses the Cloudflare edge (DDoS protection, WAF,
geo routing, caching), and forces cross-cutting concerns (auth pre-flight,
rate limiting, tenant routing) to live inside the Rust core rather than at the
edge where they belong.
Decision: Every API surface — public REST (/v1/*), admin REST
(/admin/*), MCP (POST|GET /mcp), OAuth endpoints, and inbound webhooks —
must be fronted by a dedicated Cloudflare Worker gateway. The Fly container
must only accept traffic from those Workers (enforced via a shared
X-Gateway-Token secret header or Fly private networking). No consumer —
dashboard, SDK users, Stripe, third-party integrations — may call the Fly URL
directly in production.
This applies equally to internal service-to-service APIs (e.g. the orchestration sweeper calling a primitive): internal callers must go through a Worker gateway or use Fly private networking, never the public Fly URL.
Consequences:
- Each public API surface gets its own CF Worker (or a single multiplex gateway worker that routes by path prefix). The gateway handles TLS termination, auth header forwarding, rate limiting, and geo logic; the core stays dumb about CF-specific concepts.
- The Fly container’s public URL becomes an internal implementation detail.
It must be protected: either restrict inbound via Fly’s private network
(
internaladdress) or validateX-Gateway-Tokenon every request and return 403 if absent. - Aligns with the existing
finstack-marketingWorker pattern — extends it to the API layer. - Stripe webhooks: Stripe calls a public URL. The gateway Worker handles HMAC
pre-validation (or forwards the raw headers) before proxying to the core.
The Fly container still validates
Stripe-Signature— defence in depth. - Dashboard → backend calls:
getOrProvisionApiKey()inbackend-auth.tsmust target the CF gateway URL, notfinstack-api.fly.dev.
Rule: See RULES.md worker-gateway-only. Hard rule — no exceptions without
a new ADR.
Open work: Gateway Worker(s) not yet built. Current direct-Fly calls are a known gap to resolve before Phase 6 cutover.
ADR-1 — 2026-05-17 — Active
Section titled “ADR-1 — 2026-05-17 — Active”Title: Derive status_changed_at instead of storing it
Context: Several domain rows (payfac.sub_merchants,
compliance.kyc_checks, workflow.workflow_runs) already store
per-transition timestamp columns (activated_at, suspended_at,
closed_at, reviewed_at, decided_at, started_at,
finished_at). Operators want a single status_changed_at field
to answer “how long has this row been in its current status?”
without crawling event_outbox.
Decision: Don’t add a status_changed_at column. Instead,
derive it at row hydration in Rust from the per-transition
timestamps already on the row. Each model exposes a
derive_status_changed_at(&mut self) method; service-layer row
loaders call it after every SELECT. The field carries
#[serde(default = "chrono::Utc::now")] so the struct stays
round-trippable through JSON without forcing API consumers to
supply a value they can’t influence anyway (caller-provided values
are silently overwritten on the next hydration).
Consequences:
- No migration per domain; no write-path discipline burden (no risk of a transition path forgetting to bump the column).
- ~10ns CPU cost per hydrated row on the read path. Negligible.
- The derivation logic is duplicated across each model’s
derive_status_changed_at. Acceptable for now (3 sites, each short, each domain-shaped). If we hit 5+ identical implementations, extract a trait or proc-macro. - A future migration that hand-edits one of the
*_atcolumns without going through a service-layer transition will silently flow intostatus_changed_aton the next read — that’s the desired behavior (the timestamps remain the source of truth).
Rule: none (judgment call applied per-domain, not a hard rule)
Sites (Rust derivation): svc-compliance::KycCheck,
svc-compliance::KybCheck, svc-workflow::WorkflowRun,
svc-orchestration::OrchestrationRun, primitive-payout::Payout
— 4 domains / 5 struct-impls remaining (svc-payfac::SubMerchant
retired, ADR-7). Correction (2026-07-13): the prior “3 remaining
sites” count after ADR-7 was itself wrong — it dropped
primitive-payout::Payout and svc-compliance::KybCheck, which
were never listed here despite being live (see CLAUDE.md’s
“Operator SLA stale lists” section, which does list payout’s
/v1/payouts/stale). At 5 struct-impls, this is at the “5+
identical implementations” threshold this ADR itself named as the
trigger to extract a trait or proc-macro — worth revisiting.
Sites (SQL CASE status WHEN ... THEN ts END mirror): the
same per-domain mapping appears inline in stale-list filter
queries — svc-compliance::list_stale (covers both KycCheck and
KybCheck), svc-workflow::list_stale_runs,
svc-orchestration::stale_runs, primitive-payout’s stale-list
query backing /v1/payouts/stale (also missing from this list
prior to the 2026-07-13 correction above).
These must stay in sync with the Rust derivation. Counted as
one application per domain (not one per language) for the 5-site
threshold: a future Rust-derive-only domain still counts, even
without an SQL mirror.