Architecture — FinStack
Architecture — FinStack
Section titled “Architecture — FinStack”Last updated: 2026-08-23 — ADR-16: all 28 Workers now live on the Finstack account (previously only 10 critical + messaging). The 15 non-critical Workers needing D1 had their schemas folded into an existing critical Worker’s database by theme, keeping the account at exactly 10 physical databases against the Free plan’s cap, instead of upgrading to Workers Paid — see DECISIONS.md’s ADR-16. gateway now carries the full 25-of-25 service-binding table; payment’s PAYFAC/WALLET and order’s INVENTORY/RECEIPT bindings (stripped by ADR-15) are restored. Both diagrams below updated. Prior: 2026-08-09 — order’s /complete confirm+poll redesign (PR #546) is deployed to production, closing the “not safe for real money” gap; see the “/complete confirm+poll redesign” entry below. Prior: 2026-08-07 — new order Worker (pre-payment commerce primitive, both diagrams + a new narrative entry below) and payment’s dispute-forward addition — both deployed the same day the entry below was written (see that entry for the deploy sequence and the wrangler two-config-file quirk hit during provisioning). Prior: 2026-07-29 — new finstack-ops-mcp local operator tooling (D1 read-only access, not a deployed Worker — see the Deployment Topology diagram and Component Map). Prior: 2026-07-27 — ADR-9 rollback path permanently closed. Deleted the two remaining Fly-fronting CF Worker scripts (finstack-api-gateway, finstack-webhooks-gateway, Galactic account) that ADR-8’s rollback plan depended on — previously “never delete” per DEPLOYMENT.md, now deliberately removed on user directive. Discovered in the process: pre-migration copies of all three old gateways (finstack-admin-gateway, finstack-api-gateway, finstack-webhooks-gateway) still existed under the FGV account too (orphaned since INC-003, 2026-07-25) — including an finstack-admin-gateway instance that carried the same unauthenticated-GATEWAY_TOKEN_ADMIN exposure BUGS.md flagged Critical on 2026-07-18, never actually closed on the FGV side despite CLAUDE.md recording the exposure as fully deleted 2026-07-26 (that deletion only covered the Galactic copy). All 5 stale scripts across both accounts deleted; live-verified api.finstack.sh/webhooks.finstack.sh still route only to finstack’s gateway Worker, unaffected. Neon project ancient-heart-91326215 (pre-cutover financial data — payments, recipients, bank accounts, KYC checks) exported to a local backup (/Users/am/backups/finstack-neon-2026-07-27/, owner-only permissions) then deleted outright. Fly app finstack-api could not be located or torn down — no working Fly credential exists anywhere in the vault, and flyctl under the only available login ([email protected]) returns “Could not find App”; this is the same gap TODO.md already logged (2026-07-15) as blocking a separate RLS-role verification, still open. Prior: 2026-07-26 — Stripe Identity wired up as compliance’s KYC/KYB provider (recipients + merchants), closing the KYC-decision-to-downstream-status gap the Persona-removal audit identified. compliance gains two new outbound cross-Worker edges (RECIPIENT/MERCHANT, terminal-event-only, retry-safety-ordered) and a Stripe outbound edge — see the CF Architecture diagram and VENDORS.md/TODO.md for full detail. Prior: 2026-07-25 — ADR-9: finstack-rs (Rust/Postgres/Fly.io) fully deleted from the repo. finstack is now the sole backend — see the “CF Architecture” section below, which was previously framed as a partial/evaluation track and is now the whole story. Fly and Neon infrastructure are left running untouched (code-only removal), but no longer maintained from this repo. Prior: 2026-07-25 — INC-003: the finstack.sh Cloudflare zone was deleted from FGV (accidental, outside this session), taking down all public production hostnames. Recovered by activating the Galactic zone that already existed from the in-progress FGV→Galactic account migration’s earlier inert-prep phase — production Rust’s own gateway Workers now live on the Galactic CF account (moved from FGV), serving finstack.sh for real. This is that migration completing via emergency path, not the originally-planned deliberate cutover — see TODO.md and INCIDENTS.md INC-003 for full detail. Does not touch the separate, still fully-gated finstack cutover decision at all — same CF account now, but finstack.sh DNS binds only to Rust’s gateway Workers, never to finstack’s own Workers. A real live bug was found as a result: Rust’s custom_domains.rs hardcoded constants point at the now-deleted FGV zone (see BUGS.md), still blocked on the same missing Fly.io credentials that blocked this fix before the incident. Prior: 2026-07-21 — CF Rewrite: EPIC-027 TICKET-27.5, the final ticket in EPIC-027 (scoping decision: svc-orchestration/svc-workflow NOT ported as generic engines, replaced by purpose-built Cloudflare Workflows per use case — proven via a new KycGatedPayoutWorkflow in payout gating payout creation on compliance’s live KYC status; svc-database ported directly as a new database Worker; the drop of tenant-authored no-deploy flow-authoring is stated plainly and backed by real prod-usage evidence, not assumed safe. Task review found a real SELECT-then-INSERT idempotency race in the new gated-payout route, fixed to atomic INSERT ... ON CONFLICT; whole-branch review (Opus) returned MERGEABLE with only Minor findings, both addressed. EPIC-026 and EPIC-027 are both now complete — see that section for full detail; does not change anything above. Prior: 2026-07-21 — CF Rewrite: EPIC-027 TICKET-27.4 (platform + routing + wallet, 3 new Workers; the wallet Worker required a genuine design decision — D1/SQLite has no FOR UPDATE, so Rust’s Postgres row-lock was replaced with a WalletDO per consumer mirroring ledger’s already-proven Durable-Object pattern; also surfaced an unrelated real production Rust bug candidate in route_card_present()’s NULL-ordering, logged in BUGS.md; all Workers deployed to Galactic and live-verified including a true concurrent-request proof against the deployed WalletDO). See that section for detail; does not change anything above. Prior: 2026-07-21 — CF Rewrite: EPIC-027 TICKET-27.3 (merchant + composer, 2 new Workers, no ledger/money coupling; a Critical race condition in merchant create and a read-modify-write race in composer’s enable/disable+PATCH both found and fixed via atomic D1 statements; both Workers deployed to Galactic and live-verified). See that section for detail; does not change anything above. Earlier CF Rewrite history (EPIC-027 TICKET-27.1/27.2, EPIC-026 TICKET-26.1 through 26.5, sub-project 0’s foundation/ledger/payment slice) is preserved in full in each ticket’s own ### section below and in git history — not re-narrated here to keep this line from growing unbounded. Non-CF-rewrite history prior to 2026-07-16 (Tap-to-Pay SDK, Moov CNP increments, primitive-ai/Neureus integration) is preserved in DECISIONS.md, BUGS.md, and CHANGELOG.md.
Overview
Section titled “Overview”FinStack is an AI-native developer platform for financial services.
finstack (Cloudflare Workers + D1) is the sole production backend as of 2026-07-25 (ADR-9). finstack-rs (Rust/Postgres/Fly.io) was fully deleted from the repo the same day. As of 2026-07-27, the leftover infrastructure is torn down too: the Neon Postgres database is deleted (backed up first), and every Fly-fronting CF Worker gateway across both the Galactic and FGV accounts is deleted. The Fly.io finstack-api app itself was never confirmed torn down — it simply can’t be located with any credential available in this repo’s tooling (see below).
Deployment Topology (ADR-8 + ADR-9 — post-cutover)
Section titled “Deployment Topology (ADR-8 + ADR-9 — post-cutover)”graph LR Browser["Browser / SDK / Stripe"]
subgraph CF_Pages["Cloudflare Pages"] Dashboard["app.finstack.sh\n(finstack-dashboard, Vite SPA)\nBFF: CF Pages Function [[route]].ts\nLOGIN BROKEN — see gap below"] DevPortal["docs.finstack.sh\n(Astro+Starlight, static)"] end
subgraph CF_Workers["Cloudflare Workers (Finstack acct b38d376e… — all 28 live, 2026-08-23 ADR-16)"] Marketing["finstack.sh\n(finstack-marketing, Hono)"] Gateway["api.finstack.sh / webhooks.finstack.sh\n(finstack gateway Worker,\nroutes.ts dispatch table, 25 service bindings)"] SvcAuth["svc-auth\nsk_* keys + session tokens\n+ /passkeys/* (live, 2026-07-25)"] DomainWorkers["payment · fraud · receipt · customer · tax · tip · split\ninventory · loyalty · analytics · ledger · payout · recipient\nplatform · compliance · ai · billing · messaging · merchant\ncomposer · routing · wallet · database · payfac · order\n(10 physical D1 databases shared by theme, not 1-per-Worker —\nsee DECISIONS.md ADR-16; ledger also has a DO, wallet is DO-only,\nmessaging/fanout/fraud-scoring-consumer have neither)"] end
subgraph External["External Services"] Stripe["api.stripe.com"] Neureus["Neureus\n(multi-tenant AI gateway)"] end
subgraph Retired["Retired — code deleted 2026-07-25"] Fly["Fly.io (finstack-api)\nSTATUS UNKNOWN — no accessible\ncredential to confirm teardown"] end
subgraph LocalOps["Local operator tooling (2026-07-29) — NOT deployed"] OpsMcp["finstack-ops-mcp\n(stdio process, launched by Claude Code)"] end
OpsMcp -->|"D1 HTTP API\nCF_API_TOKEN, read-only SELECT/PRAGMA only"| DomainWorkers Browser -->|"HTTPS dashboard"| Dashboard Dashboard -->|"SVC_AUTH service binding\n(Pages Function BFF, live)"| SvcAuth Browser -->|"SDK /v1/* calls, webhooks, Paystream"| Gateway Gateway -->|"auth"| SvcAuth Gateway --> DomainWorkers Stripe -->|"webhook delivery"| Gateway Browser -->|"HTTPS marketing site"| Marketing Browser -->|"HTTPS developer docs"| DevPortal DomainWorkers -->|"Bearer NEUREUS_API_KEY"| Neureus/admin/* has no HTTP Worker — it existed only via the now-deleted finstack-admin-gateway → Rust api-admin; nothing replaces it as an HTTP surface. Operator-privileged D1 access does now exist, but as finstack-ops-mcp (local stdio, see the diagram above and SECURITY.md’s Trust Boundaries) — deliberately not a deployed Worker, so it adds no new hostname to the gap CLAUDE.md’s functional-gaps list describes.
Component Map
Section titled “Component Map”| Component | Runtime | Repo path | Purpose |
|---|---|---|---|
finstack-dashboard | CF Pages (Vite SPA + Pages Function BFF) | finstack-dashboard/ | Tenant dashboard — passkey login live (2026-07-25), BFF reaches svc-auth via SVC_AUTH service binding |
finstack-developer-portal | CF Pages (Astro+Starlight, static) | developer-portal/ | Public docs + API reference — spec pipeline currently broken (points at deleted finstack-api-gateway.workers.dev) |
finstack Workers | Cloudflare Workers + D1 | finstack/workers/ | All backend primitives + services + the public gateway Worker |
finstack-ops-mcp | Local Node stdio process (not deployed) | packages/finstack-ops-mcp/ | Read-only D1 query + payout-diagnostics MCP for operators, launched by Claude Code — see COMPONENTS.md, SECURITY.md |
See COMPONENTS.md for the full per-Worker inventory.
Authentication (current)
Section titled “Authentication (current)”svc-auth provides sk_* API keys (argon2id hash + key_prefix lookup) and session tokens (fs_sess_*/cs_sess_*). Passkey/WebAuthn is live (2026-07-25) — /passkeys/{register,authenticate}/{begin,complete} (src/passkeys.ts + src/webauthn.ts, @simplewebauthn/server@^10.0.1) mints fs_sess_* sessions on success. Dashboard login (app.finstack.sh) works via the BFF’s SVC_AUTH service binding. cs_sess_* (consumer sessions) still has no minting path — that’s consumer OTP, separately deferred scope, not this feature. See CLAUDE.md’s functional-gaps list and docs/superpowers/specs/2026-07-25-svc-auth-passkeys-design.md for the design.
OAuth at_* tokens are live (2026-07-26, PR #501) — validateBearerToken (sessions.ts) gained an at_ branch (hashed lookup against oauth_access_tokens, revoked_at-checked) alongside the existing sk_/fs_sess_/cs_sess_ branches, so a token minted via POST /oauth/token can now actually authenticate through /internal/validate. GET /oauth/authorize also gained a scope-subset check (authenticate() now returns the caller’s own assertion scopes; a requested scope exceeding them 400s invalid_scope) — closed in the same PR since it was inert until at_ tokens became usable.
POST /oauth/clients added (2026-07-27) — closes the first of the two remaining gaps: registerOauthClient previously had no HTTP route at all (seed/test-only). Auth mirrors authorize/introspect (any caller with a valid internal assertion, scoped to their own tenant via TenantScopedDb); client_id is server-generated (client_${16 random hex bytes}), not caller-suppliable, avoiding any collision/validation concern.
gateway’s oauthPassthrough gate added (2026-07-27, PR #504) — closes the second gap: oauthPassthrough now converts a caller’s sk_*/session credential into a signed internal assertion for POST /oauth/clients/GET /oauth/authorize/POST /oauth/introspect (mirrors dispatch()’s existing /internal/validate step for /v1/*), and explicitly rejects at_* OAuth access tokens as the credential for those three routes (403) — an at_* token re-entering /oauth/authorize could otherwise mint its own unbounded-lifetime successor and defeat POST /oauth/revoke. This closed the gap for traffic routed through the public gateway; every Worker’s own direct *.workers.dev reachability was closed separately, the same day, in two passes — svc-auth first (the one Worker with a confirmed live exploit), then the remaining ~26 Workers (gateway excluded — the intended public entry) after explicit go-ahead for the fleet-wide change. All now set workers_dev = false and are deployed, live-verified to return a Cloudflare edge error (1042) instead of reaching the handler, with gateway/custom-domain/service-binding traffic confirmed unaffected. See CLAUDE.md’s functional-gaps list and BUGS.md for the full verification detail.
The detailed Rust WebAuthn/OAuth flow that previously lived in this section (dashboard passkey ceremonies, at_/fs_sess_/cs_sess_/sk_ bearer-prefix dispatch in api-rest/auth.rs, PKCE OAuth) described a system that has been deleted; it is preserved in git history (this file, pre-2026-07-25) rather than kept here as if still running.
Key Design Decisions
Section titled “Key Design Decisions”See DECISIONS.md for all ADRs — ADR-9 records the full finstack-rs removal. ADRs 1, 5, and 6 documented Rust-specific mechanisms (derived status_changed_at, PaymentProcessor/ProcessorRouter, CardPresentProcessor) whose patterns were ported into finstack during EPIC-026/027 (see the CF Architecture section below for the per-Worker detail) but whose original Rust implementations no longer exist.
CF Architecture (production since 2026-07-25 — ADR-8, ADR-9)
Section titled “CF Architecture (production since 2026-07-25 — ADR-8, ADR-9)”Status: api./webhooks.finstack.sh are live production traffic on finstack’s gateway Worker as of 2026-07-25 (ADR-8), and finstack is now the sole backend in the repo (ADR-9, same day) — there is no other track. (paystream.api.finstack.sh was a 3rd ADR-8 hostname; its Custom Domain binding and DNS record were removed 2026-07-26 after its TLS handshake never recovered — see BUGS.md.) What follows was originally built and evaluated as a risk-first vertical slice (foundation + ledger + payment) before any commitment to migrate the full platform; the go/no-go decision to make it the whole platform was made 2026-07-25. Lives entirely under finstack/ — a separate npm-per-package tree (no workspace/build step; cross-package imports resolve via file: deps + package.json subpath exports pointing straight at .ts source). Deploys to the dedicated Finstack CF account (b38d376e1fe34bf09ddab7cfbf34c3f9) as of 2026-08-22 — cut over from Galactic, see CLAUDE.md’s Account row and ENVIRONMENTS.md for the migration’s full history. All 28 Workers are live there as of 2026-08-23 (ADR-16) — the 15 non-critical Workers that need D1 had their schemas folded into an existing critical Worker’s database by theme rather than waiting on a Workers Paid upgrade; see DECISIONS.md’s ADR-16 for the database-to-Worker mapping. app.finstack.sh (dashboard) still excluded from DNS cutover — svc-auth has no /passkeys/* route, so dashboard login is simply broken now (no Rust fallback exists anymore — see the Authentication section above). finstack.sh (marketing) and docs.finstack.sh have no CF-rewrite equivalent and were never in scope. Plan: docs/superpowers/plans/2026-07-18-cf-rewrite-foundation-ledger-payment.md; progress ledger: .superpowers/sdd/progress.md; cutover: ADR-8; full removal: ADR-9.
graph LR Browser["Client (test harness / curl)"]
subgraph Workers["Cloudflare Workers (Finstack acct b38d376e…)"] SvcAuth["svc-auth\npasskey ceremonies (D1) + argon2id API keys\nissues signed internal assertions\nMFA TOTP (enabled/enabled_at state machine)\nOAuth 2.0 + PKCE (authorize/token/revoke/introspect)"] Payment["payment\nStripe adapter + PaymentWorkflow\nD1: payments, processor_webhook_events"] Ledger["ledger\naccounts registry (D1) +\nLedgerAccountDO (SQLite storage)\nD1 projection (read-side)"] Fanout["fanout\nQueues consumer -> 3 producers"] AnalyticsPrim["analytics\nGET /metrics/:metric (rollup fast-path/facts/groupBy)\nqueue() fact materialization + scheduled() hourly rollup\nD1: analytics_facts, analytics_daily_rollups"] Webhook["webhooks\nfetch(): endpoint CRUD (D1: webhook_endpoints)\nqueue(): delivery (still stub — Part C)"] FraudConsumer["fraud-scoring-consumer (stub)"] FraudPrim["fraud\ntenant rules + rules-v1 score model\nD1: fraud_rules, fraud_scores"] TaxPrim["tax\nstatic-v1 rate table + banker's rounding\nD1: tax_calculations"] TipPrim["tip\none-tip-per-recipient-per-payment (hardened)\nD1: tips"] SplitPrim["split\natomic multi-leg groups + release/reverse\nD1: payment_splits"] ReceiptPrim["receipt\nimmutable snapshots + monotonic numbering\nD1: receipts, receipt_counters"] InventoryPrim["inventory\nappend-only movements, CHECK-enforced no-oversell\nD1: inventory_items, stock_movements"] RecipientPrim["recipient\npayee identity + AES-256-GCM bank accounts + KYC gate\n+ POST /stripe-account (real Custom Connect onboarding,\nADR-12 Task 5)\nD1: recipients, recipient_bank_accounts"] PayoutPrim["payout\ndisbursements, 4 distinct transition guards\n+ POST /payouts/:id/process (real Stripe Transfer execution via routing,\nrecipient+compliance re-check before claim, ADR-12 Task 7)\nD1: payouts"] CustomerPrim["customer\nCRUD + idempotent create + soft delete\nD1: customers"] LoyaltyPrim["loyalty\nearn/redeem + redemption tokens\nD1: loyalty_balances, loyalty_movements, loyalty_redemption_tokens"] BillingPrim["billing\nplans/subscriptions/feature-gating/metering\nD1: billing_plans, billing_plan_features,\nbilling_subscriptions, billing_meters, billing_metering_events"] AiPrim["ai\ntask CRUD + /run (idempotency-before-provider,\nrepair-retry, Neureus integration)\nD1: ai_tasks, ai_task_runs"] MessagingPrim["messaging\nemail (Resend) + SMS (Twilio)\nno D1 — fully stateless"] PayfacPrim["payfac\ndisputes/evidence + nightly risk-scoring cron\n(auto-suspend >= threshold) + fee/portfolio reports\nD1: disputes, dispute_evidence, connect_account_risk\n+ 3 PLACEHOLDER tables, connect_accounts reconciliation\npending merchant_account_id FK wiring (see TICKET-27.3)"] CompliancePrim["compliance\nKYC/KYB state machine: manual (operator-driven)\n+ Stripe Identity (embedded verification, 2026-07-26)\nD1: compliance_kyc_checks, compliance_kyb_checks,\nstripe_identity_webhook_events"] MerchantPrim["merchant\nvendor-neutral merchant identity + membership/roles\n+ locations (soft-close only)\nD1: merchant_accounts, merchant_account_members, locations"] ComposerPrim["composer\nper-tenant primitive enablement + config (D1, not a DO —\nRust's own DO->Postgres retirement carried forward)\nD1: tenant_products"] PlatformPrim["platform\noperating account balances + funding requests\nD1: platform_accounts, funding_requests\n(platform_bank_accounts NOT ported — zero writers in Rust)"] RoutingPrim["routing\nprocessor_configs CRUD + route/routeCardPresent/routePayout\nselection logic + POST /payouts/execute (real Stripe Transfer call, ADR-12 Task 3)\nD1: processor_configs (credentials AES-256-GCM encrypted, ROUTING_CREDENTIALS_MASTER_KEY)"] WalletPrim["wallet\nconsumer wallet balance/topup/pay/withdraw/disputes\nWalletDO per (tenant,consumer) — SQLite storage, no D1 at all\n(FOR UPDATE replaced by DO single-threaded-per-instance execution)"] DatabasePrim["database\nPost AI proxy: query/query-one/transaction/migrations/audit\n+ local idempotent provision bookkeeping\nD1: database_provisions only (7 of 8 routes are pure proxy)"] OrderPrim["order (2026-08-07, deployed)\npre-payment commerce workflow — 9-state lifecycle,\noptional inventory_item_id link, lease-gated /complete\ncomposition (atomic claim -> charge -> confirm+poll\ncapture -> best-effort inventory/receipt)\nD1: orders, order_line_items"] end
subgraph Queues["Cloudflare Queues"] Ingress["payment-events"] AnalyticsQ["analytics-queue"] WebhookQ["webhook-delivery-queue"] FraudQ["fraud-scoring-queue"] end
Stripe["api.stripe.com (test mode)"] Neureus["api.neureus.ai\n(finstack-cf-experimental tenant)"] PostAi["post-ai.finhub.workers.dev\n(no live credentials provisioned yet)"]
Browser -->|"POST /payments\nGET /payments/:id/status"| Payment Payment -->|"create/poll PaymentIntent\nverify Stripe-Signature webhook"| Stripe RoutingPrim -->|"POST /payouts/execute (internal, scope processor:execute)\nGET /v1/accounts/:id + POST /v1/transfers\n(ADR-12 Task 3 — real Stripe Transfer call; Task 7 wires payout in as the caller)"| Stripe PayoutPrim -->|"Bearer signed assertion\n(recipients:read)\nGET /recipients/:id\n(resolve payoutMethod/stripeAccountId, ADR-12 Task 7)"| RecipientPrim PayoutPrim -->|"Bearer signed assertion\n(compliance:read)\nGET /compliance/kyc\n(re-check before claim, ADR-12 Task 7)"| CompliancePrim PayoutPrim -->|"Bearer signed assertion\n(processor:execute)\nPOST /payouts/execute\n(ADR-12 Task 7 — real Stripe Transfer, replaces fake caller-trusted settlement)"| RoutingPrim RecipientPrim -->|"POST /recipients/:id/stripe-account (scope recipients:write)\nPOST /v1/accounts + POST /v1/accounts/:id/persons\n(ADR-12 Task 5 — real, caller-reachable today)"| Stripe Payment -->|"Bearer signed assertion\n(ledger:write)"| Ledger Payment -->|"Bearer signed assertion\n(wallet:write)\nPOST /wallet/topup\n(consumerUserId payments only, 2026-07-26)"| WalletPrim OrderPrim -->|"Bearer signed assertion\n(payments:write) POST /payments\n+ GET /payments/:id (duplicate-idempotency-key recovery)"| Payment OrderPrim -->|"Bearer signed assertion\n(inventory:write) POST /adjust\n(best-effort, per line, on /complete)"| InventoryPrim OrderPrim -->|"Bearer signed assertion\n(receipts:write) POST /receipts\n(best-effort, on /complete)"| ReceiptPrim Payment -->|"Bearer signed assertion\n(disputes:write) POST /internal/orders/by-payment/dispute\n(alongside the existing PAYFAC forward, charge.dispute.*)"| OrderPrim SplitPrim -->|"Bearer signed assertion\n(ledger:write)\nPOST /post-entry, POST /accounts"| Ledger PayoutPrim -->|"Bearer signed assertion\n(ledger:write)\nsettle() double-entry"| Ledger PayoutPrim -->|"Bearer signed assertion\n(recipients:read)\nGET /payout-eligibility"| RecipientPrim PlatformPrim -->|"Bearer signed assertion\n(payouts:read)\nGET /payouts/pending-outbound-total\nGET /payouts/settlement-report"| PayoutPrim PayoutPrim -->|"Bearer signed assertion\n(compliance:read)\nKycGatedPayoutWorkflow: GET /compliance/kyc\n(TICKET-27.5 proof flow)"| CompliancePrim CompliancePrim -->|"Bearer signed assertion\n(recipients:write)\nPOST /recipients/:id/update-kyc-status\n(Stripe Identity webhook, terminal events only)"| RecipientPrim CompliancePrim -->|"Bearer signed assertion\n(merchant:write)\nPATCH /merchants/:id\n(Stripe Identity webhook, terminal events only)"| MerchantPrim CompliancePrim -->|"create VerificationSession /\nverify Stripe-Signature webhook"| Stripe AiPrim -->|"GET /ai/models, POST /ai/chat"| Neureus DatabasePrim -->|"POST /query, /query/one, /transaction,\n/migrations/apply, GET /migrations/status,\nGET /audit/..., GET /audit/verify/...\n(x-tenant-id header, no live creds)"| PostAi Payment -.->|"internal validate\n(session/key auth)"| SvcAuth Ingress --> Fanout Fanout --> AnalyticsQ --> AnalyticsPrim Fanout --> WebhookQ --> Webhook Fanout --> FraudQ --> FraudConsumerorder primitive — implemented 2026-08-07, deployed to production the same day (design: docs/superpowers/specs/2026-08-07-order-invoice-primitive-design.md; plan: docs/superpowers/plans/2026-08-07-order-invoice-primitive.md). A generic pre-payment commerce workflow (customer requests → merchant confirms/fulfills → money may or may not ever move), driven by Open Commerce (fintech/open) needing exactly this and having no FinStack primitive to build on. 9-state lifecycle (pending→confirmed→fulfilling→completing→completed, plus payment_ambiguous/cancelled/refunded/disputed), server-side transition guard, line items with an optional inventory_item_id link (serves both inventory-tracked and service/custom-quote merchants). payment’s dispute webhook gained a second forward (to order, alongside its existing payfac forward) so a chargeback on an order-linked payment marks the order disputed. Open questions (inventory not reversed on refund, no inventory reservation/hold, no operator-recovery tool for payment_ambiguous) are named in the design doc’s Open Questions section, not silently dropped. POST /orders/:id/complete’s original composition sequence (charge → persist payment_id → best-effort inventory/receipt) is superseded — see “/complete confirm+poll redesign” below for the current sequence. order-db (f49285cc-cc4a-4dae-a3fe-a427a6893a28, Galactic account) was provisioned via native wrangler, not the galactic CLI — finstack was confirmed not a Galactic Platform product per its own dispatch-namespace/.galactic criteria (see global CLAUDE.md’s “Galactic CLI When Possible” scope test). Wrangler quirk: order/ has two wrangler config files (wrangler.toml for real deploys, wrangler.workers.toml for the vitest pool) — a bare npx wrangler d1 create/deploy with no --config flag resolves the wrong one and fails authentication; pass --config wrangler.toml explicitly. Worth checking for on any other Worker with both files present.
/complete confirm+poll redesign (2026-08-09, PR #546, four review rounds, closes issues #540/#541’s residual “not safe for real money” gap; DEPLOYED to production). The original composition treated a POST /payments 200 as a completed charge — but under payment’s capture_method: automatic design (see “Payment capture confirmation” above), a 200 only proves the Stripe PaymentIntent was created, not captured. completeOrder() now tracks three independently-persisted states rather than one: orders.payment_id is written immediately after intent creation (unchanged timing from before); a new orders.payment_confirmed_at column (migration 0003) is written once payment’s POST /payments/:id/confirm route accepts the confirm; and capture completion is checked via a poll of GET /payments/:id/status, gated on the response body’s workflowStatus being absent AND status === 'captured' (the only sound signal — payment’s Workflow clears workflowStatus only once genuinely complete). Only a confirmed capture allows the order to reach completed/decrement inventory/issue a receipt. Because capture is asynchronous (webhook-driven, payment-workflow.ts’s waitForEvent('await-capture'), up to 15 minutes), a first /complete call frequently sees 409 on an otherwise-successful charge and must retry after the completing lease expires (~30s) — this is a documented liveness contract (see INTERFACES.md), not a defect. A real Stripe decline (402) or payment’s own pre-Stripe validation 400 reverts the order to fulfilling (keeping payment_id for retry, since Stripe reverts a declined intent to requires_payment_method); any other confirm failure (network throw, unexpected status, an errored/terminated Workflow — money may already have moved with only downstream ledger posting failing) holds in payment_ambiguous. payment/src/adapters/stripe.ts’s confirmIntent scopes its Stripe idempotency key to {paymentId}/confirm/{paymentMethodId} (not just {paymentId}/confirm) so a retry with a different card after a decline gets a real second Stripe attempt rather than replaying a cached 402 or hitting Stripe’s own idempotency_error. Full round-by-round history (a decline permanently wedging an order via idempotency-key PK collision, a broken regression test mutation testing exposed, the idempotency-key scoping bug, and the async-capture liveness gap) in CLAUDE.md’s Current State and TODO.md — not re-narrated here. Verified: order 65/65, payment 127/128 (1 pre-existing skip). Deployed 2026-08-09: the pre-deploy reconciliation query (status='completed' AND payment_id IS NOT NULL AND payment_confirmed_at IS NULL against production order-db) returned zero rows before migration 0003 was applied — no real order had reached completed under the old, unsafe assumption, so no manual Stripe reconciliation was needed. Migration 0003 applied via wrangler d1 migrations apply, then order and payment redeployed; live bundles independently re-pulled via workers_get_worker_code and confirmed to contain the fix (paymentConfirmedAt/classifyCaptureStatus in order, the scoped {paymentId}/confirm/{paymentMethodId} idempotency key in payment); GET /v1/orders and GET /v1/payments through api.finstack.sh both return 401 (routed).
Key decisions carried from the design/implementation review (full detail in DECISIONS.md-style notes inside the plan doc and .superpowers/sdd/progress.md):
- Internal assertion auth (not
forwardHeaders()alone): every cross-Worker call that mutates or reads tenant-scoped state requires asignAssertion-issued Bearer token verified by the callee’sauthenticate()gate (scope-checked, tenantId derived solely from the verified claims — never from the request body).forwardHeaders()(X-Request-Id/X-Tenant-Id) is correlation-only, not auth. This applies to every Worker’s inbound HTTP surface too, not just Worker-to-Worker calls — the payment Worker’sPOST /payments/GET /payments/:id/statusinitially shipped with no inbound auth at all (caught by the final whole-branch review, not a per-task review — tenantId came straight from the request body, and the status route had no tenant filter, a live IDOR). Fixed to requirepayment:write/payment:readscoped assertions, tenantId from claims only, and a tenant-ownership check against thepaymentsD1 table (viaTenantScopedDb) before ever addressing the Workflow instance — mirroring ledger’sresolveAccount()ownership-check-before-DO-addressing pattern. - Ledger is DO-authoritative, D1 is a read-side projection:
LedgerAccountDO(SQLite storage API) is the single writer and source of truth for balance/entries; the D1ledger_entries_projectiontable is written through after each DO commit with bounded alarm-driven retry (RETRY_BACKOFF_MINUTES = [2,10,30,120]), and is not consulted for authoritative reads in this slice. - Money as D1
TEXT, neverINTEGER:INTEGER-affinity columns silently coerce bound bigint-strings to lossy JSnumberaboveNumber.MAX_SAFE_INTEGER(~$90M at this schema’s scale) on read. Allamount_p8columns useTEXT+readMoney()/writeMoney()(@finstack/core/money). - Workflows require
isolatedStorage: falsein@cloudflare/vitest-pool-workerstest config, which in turn means every test file in a Workflow-using project shares one Miniflare runtime —singleWorker: trueplus every workflow-driving test polling its instance to real completion (not a loose in-flight assertion) are both required to avoid cross-filevi.stubGlobal('fetch', ...)races. - Workflow event type names: letters/digits/
-/_only, no periods — real Cloudflare Workflows rejects.inwaitForEvent/sendEventtype strings (workflow.invalid_event_type); Miniflare’s local simulation does not enforce this, so no unit test catches a dotted name.PaymentWorkfloworiginally used'payment.captured'(Task 13) and this went undetected through every unit test plus Task 16’s own live verification, because Task 16 never actually calledsendEvent()against a real deployed instance from outside the workflow (it used a shortened timeout + the internal poll-fallback instead). The webhook receiver hardening pass (2026-07-19) was the first code to callsendEvent()externally against a real instance, which surfaced this immediately viawrangler tail. Renamed to'payment_captured'before this PR ever merged to main. Lesson: any Workflow event-type string change must be spot-checked against a real deployed Worker, not just Miniflare-run tests — the same “confirm-live-first” discipline this codebase already applies to unconfirmed third-party API contracts now applies to this platform’s own event-naming rules too. - Queues fan-out is at-least-once, not exactly-once: the dedicated
fanoutWorker republishes one ingress message to 3 producer queues inside a singlePromise.all; a partial failure (2 of 3 already sent) causes all 3 to be resent on retry. Downstream idempotency is deferred to whichever sub-project 1 task gives the stub consumers real logic — not yet built. Update 2026-08-01 (US-002-012 Part A, PR #521):payment-eventsnow has a real producer —paymentpublishespayment.createdafterPOST /paymentssucceeds, using the canonicalEventEnvelopeshape (see theanalyticsentry below).webhook-delivery-consumer/fraud-scoring-consumerare still log-and-ack stubs for this event type, so at-least-once idempotency for them is still deferred as stated above;analyticsis live but doesn’t mappayment.createdto a fact (unmapped event types are dropped, not stored). Update 2026-08-02 (US-002-012 Part B):webhook-delivery-consumerrenamed towebhooksand gained afetch()handler (tenant webhook-endpoint registration CRUD —webhook_endpointstable, its ownwebhooks-dbD1 database, AES-256-GCM-encrypted signing secret via a newWEBHOOK_ENDPOINT_MASTER_KEY, mirroringrouting/src/crypto.ts’s tenant-bound wire format exactly). Itsqueue()handler is UNCHANGED — still the log-and-ack stub; Part C (real HMAC-signed delivery, retry/backoff, DLQ) is the next ticket. Rename followed theanalytics-consumer→analyticsprecedent above: old script detached fromwebhook-delivery-queueviawrangler queues consumer remove, then deleted, before the renamed Worker deployed. Update 2026-08-02 (US-002-012 Part B2, PR #523): now reachable throughgateway—webhooks: 'WEBHOOKS'added toROUTES, andfs_sess_*dashboard sessions carrywebhooks:read/webhooks:write(svc-auth’sSESSION_SCOPES). The reachability switch (route + scope) shipped as one atomic PR, mirroring TICKET-10.5’s own reachable-vs-usable split for MFA. Existing livefs_sess_*sessions minted before this change keep their old scope string until they expire (7-day TTL) or the user re-logs in. Update 2026-08-02 (US-002-012 Part C, PR #526):queue()is no longer a log-and-ack stub — it HMAC-signs and POSTs to every enabled, matchingwebhook_endpointsrow, retrying per a 1m/5m/30m/2h/12h/24h schedule before Cloudflare’smax_retries=6routes towebhook-delivery-queue-dlq. Update 2026-08-04:webhook-delivery-queue-dlqnow has a real consumer too — bound as a second[[queues.consumers]]on the samewebhooksWorker script;queue()dispatches onbatch.queue(exhaustive, throws on an unrecognized value rather than silently taking the wrong branch) and durably records every dead-lettered message to a newwebhook_delivery_dead_letterstable before acking, closing the gap where a permanently-undelivered webhook previously vanished with no signal.
Sub-project 0 hardening — all 4 known gaps now closed. GET /ledger/projection-lag, the unindexed payments.processor_intent_id lookup, and the POST /payments orphan-on-partial-failure gap closed in the 2026-07-19 hardening pass (see BUGS.md for the orphan fix’s own test-infrastructure bug). registry.ts’s doName = "tenantId:accountId" colon-charset gap closed 2026-07-20 (PR #480): both provisionAccount/resolveAccount now reject any tenantId/accountId outside [A-Za-z0-9_-], closing the collision risk and translating the violation to a clean 400 at the two live HTTP routes. The identical doName = "tenantId:consumerUserId" gap in wallet’s getWalletStub (2026-07-26, PR #500) was closed the same way, enforced once at the idFromName choke point rather than per-route.
EPIC-026 TICKET-26.1 — Fraud, Tax, Tip primitives (merged 2026-07-20)
Section titled “EPIC-026 TICKET-26.1 — Fraud, Tax, Tip primitives (merged 2026-07-20)”First ticket of the primitive fan-out phase (15 remaining primitives, see EPIC-026/EPIC-027). Three new, independent Workers — fraud, tax, tip — each with its own D1 database, deployed to Galactic (*.workers.dev only, same as the rest of this track). Design spec (docs/superpowers/specs/2026-07-20-cf-rewrite-ticket-26-1-fraud-tax-tip-design.md) was corrected 3 times against the actual Rust source before/during implementation (fraud rule precedence, tax’s full jurisdiction/rounding table, @finstack/core/money’s p8-scaled functions being wrong for these Workers’ _minor columns) — each correction is recorded in the spec and in commit history, not silently absorbed.
fraud: tenant rule engine (severitydecline > challenge > flag, tie-broken by oldestcreated_atthenid— a direct port oftenant_rules.rs::evaluate’smax_by_key, not a “first match” shortcut) + the built-inrules-v1weighted-score model.score/risk/reasons/evaluatorare always computed from the weighted model (observability, matchesFraudService::score()exactly); a rule hit overrides onlyrecommendation. Rule CRUD built beyond the EPIC’s literal acceptance-criteria text (scoring is meaningless without rules to evaluate) — documented as a deliberate resolution, not scope creep.tax:static-v1rate table ported bit-for-bit fromrules.rs(US state table incl. explicit 0%-tax states and the 6% generic fallback; 3 genuinely distinct category-scaling formulas per branch — US/2, EU*7/19, GB flat0; exact integer round-half-to-even via BigInt) — verified against the Rust reference’s own test suite values, not just internal self-consistency.tip: a deliberate hardening beyond the Rust reference —finstack-rs’stipstable has no DB-level one-tip-per-(payment,recipient) constraint (convention only); this port adds a partial unique index enforcing it, with the D1 conflict-vs-idempotent-retry distinction verified empirically against real Miniflare D1 error strings (not assumed to match Postgres).
All three: authenticate()/TenantScopedDb/idempotent-insert patterns identical to ledger/payment; full input validation before any D1 call (a Critical gap found and fixed in fraud’s task review, applied proactively in tax/tip from the start); no raw D1/JSON exception surfaces as a bare 500. Live-verified via wrangler tail/worker_health post-deploy. See TODO.md’s CF rewrite section for the per-task review history.
EPIC-026 TICKET-26.2 — Split, Receipt, Inventory primitives (merged 2026-07-20)
Section titled “EPIC-026 TICKET-26.2 — Split, Receipt, Inventory primitives (merged 2026-07-20)”Second ticket of the primitive fan-out phase. Three new, independent Workers — split, receipt, inventory — each with its own D1 database, plus one small additive change to the already-deployed ledger Worker (new POST /accounts route, wrapping the already-tested-but-previously-unrouted provisionAccount()). Design spec (docs/superpowers/specs/2026-07-20-cf-rewrite-ticket-26-2-split-receipt-inventory-design.md) corrected a real design error via /gaps review before any code was written: an earlier draft claimed “split does not call ledger” — the EPIC’s own acceptance criteria requires it to, and finstack-rs has zero consumers of split.released/split.reversed to port from, meaning the ledger-posting shape here is genuinely new design work, not a port.
split: atomic multi-leg group creation via a single multi-rowINSERT(notdb.batch()— D1 can’t read mid-batch to decide, so the sum/currency invariant is validated in application code before one atomic multi-row statement; the “kill mid-group” acceptance test forces a CHECK violation on a later leg and asserts zero rows committed for thatgroup_id, proving atomicity for this actual mechanism).POST /splits/:id/release/.../reversecallledger’s/post-entrydirectly (a deliberate hardening beyond the Rust reference) — release postsDEBIT escrow/CREDIT payee_{payeeId}, reverse the exact compensating pair, with lazy account provisioning via the newledgerroute on a 404. The three-way transition (same-state no-op / pending-only transition / anything-else 409) is a direct port ofservice.rs::transition’s exact branch, not a simplified “already terminal = no-op.” A Critical bug found in task review: payee account IDs were built as`payee:${payeeId}`— the colon violatesledger’s ownSAFE_IDcharset guard, so every real release/reverse would have 400’d; fixed to`payee_${payeeId}`plus a caller-facingpayeeIdcharset validator, and the test’s ownLEDGERstub was hardened to replicate the charset rule it had been missing (closing the exact stub-fidelity gap that let the bug ship undetected through 15/15 passing tests).receipt: immutable snapshots (noupdated_atcolumn, no update route) with monotonic per-tenant numbering (R-{year}-{6-digit, uncapped}) via a single-statementINSERT ... ON CONFLICT (tenant_id) DO UPDATE SET next_value = next_value + 1 RETURNING next_value - 1— verified empirically atomic against real Miniflare D1, not assumed. Idempotency check runs before any counter touch (a replay must never burn a sequence number). An Important bug found in task review: the payment-receipts list route orderedASC; the Rust reference (service.rs::list_for_payment) ordersDESC(newest first) — the query and its supporting D1 index agreed with each other internally, so nothing failed loudly, and the original test only checked list length. Fixed toDESCwith a real ordering-assertion test.inventory: append-onlystock_movements+ denormalizedquantityoninventory_items, withCHECK (quantity >= 0)as the actual oversell guard (not a JS pre-check) — the movement insert and quantity update run as oneenv.DB.batch()call (D1’s real cross-statement atomicity primitive, called directly sinceTenantScopedDbhas nobatch()method) so a CHECK violation rolls back both statements together; verified by asserting zero orphan movement rows survive a rejected oversell. The real D1 constraint-violation error string ("CHECK constraint failed: quantity >= 0: SQLITE_CONSTRAINT") was confirmed empirically, correcting a wrong initial guess before it shipped. Low-stock crossing implements the exactprior_qty > threshold && new_qty <= thresholdtransition, verified across all four cases (approach, cross, stay-below, re-entry-above). A proactive hardening fix (not a formal review finding) closed a fidelity gap where idempotent replay resolved the item via the request URL’sitemIdinstead of the stored movement’s ownitem_id, as Rust’s reference does.
All three, plus ledger’s new route: authenticate()/TenantScopedDb/idempotent-insert patterns identical to prior Workers; money guard (Number.isSafeInteger, never @finstack/core/money’s p8-scaled helpers) applied at both write and read boundaries from the start. Final whole-branch review (dispatched on the most capable available model, per this program’s review discipline) verified the split→ledger integration seam directly — request/response shapes match exactly, and the CENT_TO_P8 = 1_000_000n conversion constant was checked against money.ts’s own MONEY_SCALE/STRIPE_CENT_SCALE definitions, not just trusted from a code comment. All 4 Workers (ledger redeployed + 3 new) deployed to Galactic and live-verified with real signed-assertion HTTP requests — including the release/reverse path actually posting to the live ledger Worker, sequential receipt numbering, and a real oversell rejected via the live D1 CHECK constraint. See TODO.md’s CF rewrite section for the per-task review history and the tracked non-blocking follow-up (inventory’s idempotent-replay race window vs. receipt’s more robust pattern).
EPIC-026 TICKET-26.3 — Payout, Recipient primitives (merged 2026-07-20)
Section titled “EPIC-026 TICKET-26.3 — Payout, Recipient primitives (merged 2026-07-20)”Third ticket of the primitive fan-out phase. Two new, independent Workers — recipient, payout — each with its own D1 database. Structurally different from every prior ticket: payout depends on services that don’t exist in this CF track yet (svc-wallet’s platform-balance debit, a processor adapter for dispatch, svc-compliance’s KYC webhook) — the design spec, corrected via a documented /gaps round before implementation, resolves each with an explicit seam decision rather than a silent gap: the platform-balance debit is dropped entirely (a real non-goal — svc-wallet is EPIC-027’s own ticket); process/settle/fail/mark-returned/cancel and recipient’s update-kyc-status are exposed as direct authenticated routes instead of processor/webhook-triggered internals, since exposing them is what makes this ticket’s actual point — porting the state machine and its ledger integration — live-verifiable now.
recipient: payee identity + AES-256-GCM-encrypted bank accounts (Web Cryptocrypto.subtle, wire formatnonce[12] || ciphertext, GCM tag appended — byte-compatible withfinstack-rs’s owncrypto.rsand the same format already used by the Paystream migration’s bank-account sentinel) + a KYC gatepayoutcalls. Recipient create is a genuine upsert on(tenant_id, external_ref)(ON CONFLICT DO UPDATE SET ... = excluded.*, verified empirically against real Miniflare D1), not a plain idempotent replay — a second create with different fields updates the row, matching Rust’s exact semantics. Nokyc_check_idcolumn (Rust’s is a required FK to a compliance table that doesn’t exist here — a column that could only ever be null was dropped, not kept “for shape parity”). Micro-deposit amounts are generated by this Worker itself (no ACH adapter in this track) and returned exactly once, at creation.set-defaultbank account is hardened beyond Rust to verify the target account belongs to the URL’s recipient (D1 has no RLS fallback the way Postgres incidentally provides in the Rust reference). The encrypted account number is never selected back by any route, matching Rust’s ownload_bank_accountexactly.payout: disbursements with a recipient-eligibility gate (a real cross-Worker call torecipient’sGET /payout-eligibility, the recipient’s own distinct rejection reason —recipient_suspendedvsrecipient_kyc_required— passed through verbatim) and direct ledger settlement (a second cross-Worker call, toledger’s/post-entry) — the first Worker in this track with two cross-Worker dependencies in one primitive. Ports four DISTINCT state-transition guard shapes verified directly againstservice.rs, not a uniform pattern:process/settle/canceleach require one exact source status (no idempotent no-op, unlikesplit’s release/reverse);failaccepts any non-terminal source;mark-returnedhas genuinely no guard at all, matching Rust’s unconditional UPDATE.settle()posts the ledger double-entry BEFORE flipping status — a deliberate divergence from Rust’s own order (Rust flips status first with no compensation on ledger failure), independently applyingsplit’s own TICKET-26.2 hardening rationale. A Critical bug found in task review:settle()’s ledger calls never lazy-provisioned theoperating/recipient_escrowaccounts on a 404 — no other Worker in this track ever creates those accounts, so every tenant’s first settlement would have 500’d in reality; fixed by portingsplit’s provision-then-retry-once pattern exactly, with a dedicated test proving the fallback (the original PR’s vitest stub had a deadPOST /accountshandler that never got exercised — direct evidence the path had gone untested).
Both, plus the two new cross-Worker seams: authenticate()/TenantScopedDb/idempotent-insert patterns identical to every prior Worker; money guard applied at both boundaries from the start. Final whole-branch review (opus) verified both integration seams directly against the real code on both sides (not just each Worker’s own test stub of the other) — the payout↔recipient eligibility response shape and the payout↔ledger post-entry/lazy-provision shape both match exactly. Both Workers deployed to Galactic and live-verified end-to-end: recipient create → KYC approval → payout-eligibility flip from recipient_kyc_required to eligible → payout create (real cross-Worker gate) → process → settle (real cross-Worker ledger call, lazy-provisioning operating/recipient_escrow on the very first call for the tenant) → confirmed both ledger account balances directly (+2,500,000,000/-2,500,000,000 p8 units, exactly balancing a $25.00 payout) — plus wrong-state rejection, idempotent create replay, cancel-from-pending, bank-account creation with real AES-256-GCM encryption, and micro-deposit verification, all against the live deployment. See TODO.md’s CF rewrite section for the per-task review history.
EPIC-026 TICKET-26.4 — Customer, Loyalty primitives (2026-07-20)
Section titled “EPIC-026 TICKET-26.4 — Customer, Loyalty primitives (2026-07-20)”Fourth ticket of the primitive fan-out phase. Two new, independent Workers — customer, loyalty — each with its own D1 database. persona was originally scoped into this ticket but descoped during design: it has no D1 schema of its own (a stateless HTTP client wrapper around the third-party Persona Identity API), no in-track consumer until svc-compliance is ported, and its two real routes require PERSONA_API_KEY/withpersona.com access this track doesn’t have — it rolls into TICKET-27.2 alongside svc-compliance instead, so the full KYC flow becomes one live-verifiable unit rather than two disconnected halves. No cross-Worker calls between customer and loyalty — Rust’s own loyalty_balances.customer_id has no FK/existence check against customers, so unlike payout↔recipient in TICKET-26.3, the two Workers stay fully independent; customerId is an opaque, unvalidated string on the loyalty side.
customer: CRUD + idempotent create + soft delete, with a genuine expression + partial unique index (UNIQUE ON (tenant_id, lower(email)) WHERE email IS NOT NULL AND deleted_at IS NULL) — confirmed D1 accepts this directly (no shadow-column fallback needed), verified both locally and against real remote D1. The empirical violation string (UNIQUE constraint failed: index 'customers_tenant_email': SQLITE_CONSTRAINT) is a genuinely different shape frominventory’s/recipient’s matchers — SQLite reports expression-index violations by index name, not column name, confirmed via a throwaway probe test rather than assumed.merchant_account_id(migration 0067 in Rust, an FK tomerchant_accounts/svc-merchant, not yet ported) dropped entirely, matching TICKET-26.3’s precedent of dropping columns with nothing behind them rather than keeping them always-null.loyalty: earn/redeem points/cents balances, append-only movements, single-use redemption tokens. The ticket’s most load-bearing code is the shared earn/redeemapply(): a single 3-statementenv.DB.batch()gated on a caller-generatedmovementIdUUID via anEXISTSsubquery makes the balance update fire exactly once per distinct idempotency key while aCHECK(balance>=0)violation rolls back the whole batch together — no orphan movement row is ever left on an over-redeem (proven by a test asserting the movements list itself, not just the balance).isInsufficientBalanceViolationwas written fresh and confirmed empirically against real Miniflare D1 ('CHECK constraint failed'+'balance >= 0'— NOT a copy ofinventory’s differently-worded'quantity >= 0'matcher). Redemption tokens use a two-phase design: Step 1 is a single atomicUPDATE ... WHERE redeemed_at IS NULL AND expires_at > ? ... RETURNINGclaim (its own discrete statement, not batched) — D1’s normal write serialization makes this fully race-safe on its own, proven by a dedicatedPromise.allconcurrency test asserting exactly one200/one400among two simultaneous redeem requests for the same token. Step 2 (balance-row-ensure + movement insert + deduction) is deliberately left ungated, since the claim already proves uniqueness before Step 2 ever runs — a documented, accepted gap: a Step-2 CHECK failure leaves the token consumed without rolling back the Step-1 claim (narrower than Rust, which wraps both in one transaction), bounded to a rare mint/redeem race with no double-spend risk either way. One deliberate deviation from the design spec’s literal SQL: the movements-list query addstenant_id = ?(the spec’s text omitted it) — needed for the required tenant-isolation test to be meaningful and consistent with every other tenant-scoped read in this track.- Both Workers deployed to Galactic and live-verified end-to-end with real signed-assertion HTTP requests —
customer’s full create→retrieve→list(case-insensitive)→update→soft-delete→email-conflict-409 chain, andloyalty’s full earn→redeem→balance/movements→mint→redeem→duplicate-redeem chain, confirming the uniforminvalid_redemption_token400 on replay against the real deployment.
See TODO.md’s CF rewrite section for the per-task test counts and review history.
EPIC-026 TICKET-26.5 — Billing, AI, Analytics primitives (2026-07-21)
Section titled “EPIC-026 TICKET-26.5 — Billing, AI, Analytics primitives (2026-07-21)”Fifth ticket of the primitive fan-out phase, and the last one before EPIC-027’s services layer. Three Workers — billing (new, own D1), ai (new, own D1, real external HTTP integration), and analytics (renamed from the sub-project-0 analytics-consumer stub — its own comment said full materialization was deferred to this ticket, and it is the first hybrid Worker in this track, implementing fetch()/queue()/scheduled() together in one export). messaging was originally scoped alongside these three but descoped to TICKET-27.2 for the same reason persona was in TICKET-26.4 — no D1 schema of its own, no in-track consumer yet.
- A track-wide bug found mid-ticket, not scoped to this ticket alone: investigating this ticket’s own
billingimplementation surfaced a real, previously-shipped divergence across 8 already-merged Workers from TICKET-26.1/26.2/26.4 (fraud,tax,tip,customer,loyalty,split,receipt,inventory) — all assumed HTTP 201 on create routes, carried forward as an unverified REST convention from the Worker scaffolding template, while every one of their Rust references actually returns axum’s implicit 200 (zeroStatusCode::CREATEDoccurrences in any of the 8 corresponding Rust files, confirmed by direct grep).recipient/payout(TICKET-26.3) were unaffected — their Rust references genuinely do useStatusCode::CREATED. Fixed in a standalone PR (#485, all 8 Workers + this ticket’s own in-progressbillingWorker, which had the identical bug in 4 routes) — see BUGS.md’s 2026-07-21 entry. This is why every route inbilling/ai/analyticsbelow was individually checked against its actual Rust status code, not assumed. billing: plans/subscriptions/feature-gating/metering, all 14 routes mirroringapi-rest/src/billing.rsone-to-one.check_feature’s meter-name-matches-feature-key join (notmeter_id) andcancel_subscription’s identical-404-for-never-existed-vs-already-terminal are both faithfully reproduced, including their documented Rust gaps. A Critical cross-tenant IDOR found in task review:check_feature’s D1 join lackedAND f.tenant_id = s.tenant_id(Rust gets this for free frombilling_plan_features’sFORCE ROW LEVEL SECURITY; D1 has no RLS to bypass, so the CF port must replicate tenant scoping manually — it had been done for every other table in the query except this one), andadd_plan_featurehad no plan-ownership check before its upsert, so a cross-tenantplan_idcould silently overwrite another tenant’s row viaON CONFLICT. Fixed by adding a tenant-scoped plan-ownership check (closing the write-side gap at its root) plus the join’s own tenant filter (defense-in-depth).ai: task CRUD +/run’s retry loop, the most complex Worker in this ticket — real Neureus HTTP integration, JSON Schema validation (@cfworker/json-schema, chosen overajvsinceajv’s default mode uses runtime codegen incompatible with the Workers V8 isolate), and a repair-retry loop with cumulative cost/token tracking. The idempotency-before-provider-call ordering (checking BEFORE any provider call, not just at the INSERT’sON CONFLICT) is the single most load-bearing behavior — a real prior Rust bug (commitcf0a450) re-billed Neureus on a same-key retry when this ordering wasn’t followed; proven here by a test that exploitsfetchMock’sdisableNetConnect()directly (a replayed key registers zero new mocks, so a regression that re-calls the provider would throw, not silently succeed). Three real bugs found live-verifying against the real, already-provisionedfinstack-cf-experimentalNeureus tenant (see BUGS.md): (1)GET /ai/modelsreturns{models: [...]}, not the bare arrayadapter-ai-neureus/client.rsdeserializes into and locks in with its own test fixture; (2)RESERVED_DEFAULT_MODELwas specified (in both the design spec and Rust’s ownbuiltin.rs) as'@cf/openai/gpt-oss-20b', which doesn’t exist in the real live catalog — the real id is'openai/gpt-oss-20b', no@cf/prefix; (3) Neureus’s ownPOST /ai/chatreturns a 422 for ANYresponse_format.json_schemarequest (confirmed schema-content-independent) with a server-side"EvalError: Code generation from strings disallowed for this context"— the same V8-isolate restriction this Worker’s own client avoids, but on Neureus’s backend, not fixable from this repo. (1) and (2) are fixed in this Worker’s own client code; (3) is an accepted, externally-blocked gap (documented in the live test’s own block comment) until Neureus’s platform team fixes it — impact on finstack-rs’s ownprimitive-aiis latent, not a confirmed active outage, since itsNEUREUS_API_KEYis unset/undeployed in production. A Minor batch-error-isolation gap found in task review: a D1 fault on the idempotency check or the success-path insert propagated to the top-level handler, 500-ing the entire batch and discarding other items’ already-computed results — Rust catchesrecord_run’s failure per-item (never letting one item’s DB error take down sibling items in the same batch); fixed to match.analytics:GET /metrics/:metric’s 3-branch query logic (day-aligned rollup fast path / direct facts sum / groupBy viajson_extract) matchingprimitive-analytics::query()exactly, including the day-alignment check that must reject an off-by-one-hour range even though it’s otherwise eligible for the fast path. Introducesanalytics:readas a new scope (Rust’s route has zero scope check, same deliberate-hardening pattern asloyalty:read/customer).queue()materializes facts from the canonical envelope this ticket defines (there is no pre-existing producer —fanoutonly republishesmessage.bodyverbatim, and nothing in this track has ever called.send()on the ingress queue); idempotent on(tenant_id, metric, source_event_id).scheduled()is a real hourly Cron Trigger (0 * * * *) rolling up yesterday’s UTC day — not a stubbed gap, matchingfinstack-worker/src/main.rs’s own real rollup loop. Two spec corrections found via direct Rust-source verification (not live API testing this time): the query route’s real accepted parameter isgroup_by(verified againstapi-rest/src/analytics.rs’s own doc comment), notgroupByas the design spec’s route signature stated; and the canonical queue envelope was missingtenantIdentirely — Rust’s realEventEnvelopestruct (finstack-events/src/lib.rs) hastenant_idas a required field, and a fact literally cannot be stored under the right tenant without it. Both corrected in the spec and the implementation in the same commit. Deploying this Worker required removing the oldanalytics-consumerscript’s queue-consumer registration first — Cloudflare Queues allow exactly one consumer per queue, so the renamedanalyticsWorker couldn’t attach until the old one was detached and deleted. Update 2026-08-01 (US-002-012 Part A, PR #521): “there is no pre-existing producer” is no longer true —paymentnow publishespayment.createdintopayment-events,fanout’s first real upstream producer. Confirmed live via Galactic queue listing before this producer shipped:payment-eventshad 0 producers, 1 consumer (fanout). The envelope’s exact field names (eventType/sourceEventId/payload/occurredAt/tenantId) matter operationally, not just cosmetically — a differently-shaped envelope throws insidefactFromEventbefore theeventTypeswitch is reached (accessing a property onundefined), andanalytics-queue-dlqhas no consumer, so a shape mismatch here silently and permanently drops every message with no visible error. Caught in PR review before merge, not in production.- All three Workers deployed to Galactic and live-verified against real infrastructure:
billing’s full plan→feature→subscription→metered-usage-limit-flip→cancel chain;ai’s full CRUD lifecycle plus the built-incategorize_transactiontask and a custom task with an explicit custommodelId, both against the real Neureus tenant (the/rungeneration path itself is the one exception, blocked by finding (3) above);analytics’sfetch()against a directly-seeded D1 fact,queue()via a synthetic message enqueued through the real Cloudflare Queues HTTP API (no real producer exists yet), andscheduled()via a temporary debug route (added, verified, then removed and reconfirmed absent via 3 independent checks before the final deploy).
See TODO.md’s CF rewrite section for the per-task test counts and review history.
EPIC-027 TICKET-27.1 — svc-auth completion: MFA TOTP + OAuth 2.0/PKCE (2026-07-21)
Section titled “EPIC-027 TICKET-27.1 — svc-auth completion: MFA TOTP + OAuth 2.0/PKCE (2026-07-21)”First ticket of EPIC-027’s services layer, and the first ticket in this track to extend an already-shipped Worker (svc-auth) rather than create new ones. Covers 2 of the 3 remaining svc-auth subsystems — MFA TOTP and OAuth 2.0/PKCE — both fully tenant-scoped and self-contained. Consumer OTP is explicitly deferred, not implemented: its Rust verify() path ends by linking a cross-tenant identities row (BYPASSRLS, no RLS) and minting a cs_sess_* consumer session, and neither substrate exists on this track yet (svc-auth’s D1 schema has only passkey_ceremonies/api_keys/session_tokens) — building it here would mean scoping EPIC-024’s cross-tenant identity model inside an auth-completion ticket, the same speculative-generic-engine trap this EPIC’s own doc warns against for TICKET-27.5. Tracked in TODO.md as follow-up, alongside whichever future ticket ports the identity/consumer-session substrate.
- MFA TOTP (
mfa.ts, migration0002_mfa_totp.sql): RFC 6238 TOTP over HMAC-SHA256 (not the more common SHA-1), 6 digits, 30s period, ±1 step skew window — all via Web Crypto, no library. A from-scratch RFC 4648 base32 codec (nothing in this repo had one) verified against the standard"foobar"→MZXW6YTBOItest vector. Secrets encrypted at rest with AES-256-GCM under a newMFA_MASTER_KEYWorker secret (vaulted atcloudflare/FINSTACK_CF_MFA_MASTER_KEYfor redeploys). A Critical gap found in task review: the initial port treated enrollment as immediately active and 409’d on re-enrollment — Rust’s actual design (migration0018’s own doc comment) is anenabled/enabled_atconfirmation state machine: enrollment alone leavesenabled=false(protects against a user enrolling, never finishing the QR scan, and being locked out at next login by an MFA requirement they can’t satisfy yet), the first successfulverify()flipsenabled=true/stampsenabled_at(COALESCE — later verifies don’t move it), and re-enrollment is an UPSERT that always overwrites the secret and resetsenabled=false(deliberate lost-device recovery, not a bug to guard against).challenge()calls the same verify-and-maybe-enable path Rust’s ownchallenge()delegates to internally. Fixed to match exactly, including a regression test proving re-enrollment resets a previously-confirmed enrollment back to unconfirmed. - OAuth 2.0 + PKCE (
oauth.ts, migration0003_oauth.sql): mirrors Rust’s own route split exactly —authorize/introspectrequire tenant auth,token/revokeare public (PKCE possession is the proof, not a request-time assertion) — confirmed againstapi-rest/src/oauth.rs’s ownauthenticated_routes()/public_routes()split, not assumed from this track’s usual “everything needs a scope” convention. Authorization codes stored in plaintext (short-lived 10-min TTL, single-use, 64 hex chars of entropy) matching Rust’s own schema exactly; access tokens SHA-256-hashed. Single-use code claim viaUPDATE ... WHERE used_at IS NULL RETURNING— D1’s single-writer-per-database serialization means noSKIP LOCKED-equivalent concern exists here, unlike Postgres. A Critical finding in task review: initial responses used this track’s usual internal camelCase convention (accessToken,tokenType, etc.) — but OAuth’s wire format is externally specified (RFC 6749 §5.1, RFC 7662), so a real OAuth client library can’t parse camelCase; fixed to the RFC-mandated snake_case (access_token,token_type,expires_in,client_id) across all four routes, the one deliberate exception to this track’s own JSON convention. Client registration (registerOauthClient()) has no REST route, matching Rust’s ownregister_clienthaving noapi-restroute either — not a CF-side gap. - Both subsystems deployed to Galactic and live-verified end-to-end against the real deployment: MFA’s full enroll→verify(enables)→status→challenge→disable cycle with a real client-computed TOTP code; OAuth’s full authorize→token→(double-exchange 400)→introspect(active)→revoke→introspect(inactive, still carrying
client_id/scope/exp) cycle, PKCE checked against the RFC 7636 Appendix B vector both in-suite and against the live deployment.
EPIC-027 TICKET-27.2 — Compliance, Payfac, Persona, Messaging (2026-07-21)
Section titled “EPIC-027 TICKET-27.2 — Compliance, Payfac, Persona, Messaging (2026-07-21)”Persona removed 2026-07-26 — an audit found the integration this ticket shipped was never actually reachable end-to-end (no gateway route to the webhook, no connection from a KYC decision to payout gating) even setting aside the missing API key; deleted rather than finished. Stripe Identity added the same day, replacing it and closing the gap the audit found: compliance now supports manual (operator-driven) KYC/KYB plus Stripe Identity’s embedded verification, and a terminal decision genuinely propagates to recipients.status/merchant_accounts.status (not just payout’s KycGatedPayoutWorkflow read path below). See VENDORS.md/BUGS.md/TODO.md for both the removal and the replacement detail. History below is preserved as-shipped at the time, not rewritten.
Second EPIC-027 ticket, bundling 4 Rust subsystems into 3 new Workers: compliance (svc-compliance’s KYC/KYB state machine + primitive-persona’s Identity API client and webhook receiver, bundled together since the webhook directly mutates compliance state), payfac (svc-payfac’s disputes/risk-scoring/fee-reporting), and messaging (primitive-messaging’s email+SMS, picked up here after being deferred out of TICKET-26.5 for having no D1 schema at the time). Three explicit seam decisions, none of them silent:
-
OFAC/sanctions screening is NOT implemented in
compliance— Rust’sSanctionsScreenerfetches the full US Treasury SDN list at runtime (multi-MB, 24h background-task refresh), which has no clean Workers-compatible equivalent (no persistent background-task model, strict per-request limits).full_name/country_codeare accepted and stored but never screened — a deliberate, documented, and tested gap (a test proves an obviously-sanctionable name still creates the check normally, rather than the screener silently always resolving to a fixed “Clear” that would misleadingly imply a check ran). -
payfacgets 3 local placeholder tables (connect_accounts,payments,connect_transfer_events) mirroring data owned bysvc-merchant/thepaymentWorker, neither ported yet (EPIC-027 TICKET-27.3). Seeded directly in tests/live-verification, documented with-- PLACEHOLDER:SQL comments, reconciliation flagged for whenever those services land on this track. -
Messaging push notifications (APNs/FCM) are deferred, not this ticket — blocked on the same unported
consumer_userssubstrate TICKET-27.1 already deferred consumer OTP over. Shipping only email+SMS keepsmessaginggenuinely stateless (no D1 binding at all), which is what justified its original TICKET-26.5 deferral in the first place. -
compliance: KYC state machine (initiated → pending_review → approved | rejected, terminal-reject, no self-loops) + KYB withbeneficial_ownersas an atomically-appended JSON array (D1’sjson_insert('$[#]', ...), not a read-mutate-write round trip). The Persona webhook (POST /webhooks/persona) validatesPersona-Signature: t=<ts>,v1=<hex>— HMAC-SHA256 over"{timestamp}."+ raw body bytes, ±300s tolerance, constant-time compare — with its ownpersona_webhook_eventsidempotency table (Rust reusespayment’s sharedprocessor_webhook_events, impossible here since each CF Worker owns a separate D1 database). A Critical bug found in task review: the webhook’s state-transition dispatcher swallowed failed/illegal transitions silently —transitionKycreturned an HTTP Response (404/400) on failure rather than signaling failure to its caller, so a genuinely new event (not a duplicate delivery) targeting an already-terminal check fell through the dispatcher’stry/catchuntouched, got marked processed, and returned 200 with compliance state left silently unchanged. Refactored to a discriminated result type the dispatcher explicitly checks; a failed transition now correctly 500s and leavesprocessed_atNULL so a genuine Persona retry gets another chance — live-verified against the real deployment, not just the test suite: sent a real HMAC-signed illegal-transition event, confirmed 500 and confirmed the check’s status never moved. -
payfac: disputes CRUD + evidence submission (no real Stripe Connect integration on this track, sois_evidence_submittednever flips — a second submission attempt is rejected via thedispute_evidence.dispute_idUNIQUE constraint instead of Rust’s application-level flag check, which is friendlier than Rust’s real 500 in that exact scenario, not equivalent to it — documented, not silently claimed as parity). Discovered mid-implementation:RiskService::score_merchanthas no REST route in Rust at all — it’s driven by a nightly (24h) cross-tenant cron infinstack-worker/src/main.rsthat auto-suspends accounts scoring at or aboveAUTO_SUSPEND_THRESHOLD(80). Ported asrunNightlyScoringPass()behind a Cloudflare Cron Trigger (0 3 * * *), queryingenv.DBdirectly rather than throughTenantScopedDbsince it’s a genuinely cross-tenant admin job (matching Rust’s own BYPASSRLS approach). 4 Important findings fixed in task review:GET /disputes’s sort order was wrong (created_at DESCinstead of Rust’s realdue_by ASC NULLS LAST, id DESC— soonest-due-first, meaningful for a response-deadline queue); three JS falsy-zero bugs where an explicitmin_score=0/limit=0was silently coerced to the default instead of being honored; missing format validation onmonth/from/toquery params let garbage input silently produce wrong (usually zero) aggregates instead of a clean 400; andportfolio_report’s status-bucketing was wrong (Rust’sinactive_countis a catch-allstatus != 'active' AND status != 'suspended', not three literal grouped status values — the nightly job’s own'restricted'status falls into that catch-all bucket, distinct from'suspended'). An Important finding in the final whole-branch review:GET /payfac/reports/high-riskhad 3 undocumented divergences from Rust’s realSELECT DISTINCT ON (connect_account_id) ... WHERE COALESCE(override_score, risk_score) >= min_score ORDER BY connect_account_id, scored_at DESC— Postgres’sDISTINCT ONfilters before picking the latest row per account, so a stale high score still surfaces an account even after its true latest score has dropped below threshold; the port had taken the true latest first, then filtered, the opposite (if more intuitive) behavior, silently diverging without the divergence comments the rest of this branch uses religiously. Rewritten to match via a correlated subquery (SQLite has noDISTINCT ON), and to return a bare array rather than{items:[...]}— this one endpoint has no cursor concept in Rust at all, unlike the disputes/KYC list endpoints. -
messaging: stateless proxy over Resend (email) and Twilio (SMS). 3 findings fixed in task review:towas astring[]array for email (Rust’s is a singlestring— an unrequested scope expansion); the idempotency key was read from a JSON body field instead of the inboundIdempotency-KeyHTTP header (svc-orchestrationinjects it as a header, not a body field, on every primitive call); response fields were a generic{id}instead of Rust’s actualmessage_id/message_sid(camelCase-translated tomessageId/messageSidhere). A background automated security review separately flagged the unrestrictedfromaddress and the raw vendor-error echo in client-facing responses — both investigated and confirmed to be faithful ports offinstack-rs’s own actual behavior (checkedapi-rest/src/error.rs’sApiError::into_response(), which puts the full error string into every client-facing response regardless of status class, andprimitive-messaging/src/lib.rs’ssend_email, which validatesfromno more than this port does) — documented in BUGS.md rather than silently changed, since “fixing” only the CF port would itself be a new divergence from the reference platform’s actual, shipped convention. -
All 3 Workers deployed to Galactic and live-verified against real infrastructure:
compliance’s full KYC lifecycle (initiate→review→decide) plus the Persona webhook with a real HMAC-SHA256-signed synthetic payload (both the happy-path approval and the illegal-transition 500 above);payfac’s full dispute→evidence(→duplicate-rejected) chain and both report routes against directly-seeded placeholder data;messaging’s validation and missing-vendor-credential error paths (no Resend/Twilio test credentials exist in the vault for this track, so the actual send path is untested live — a partial, documented live-verification gap, not a silent skip).
EPIC-027 TICKET-27.3 — Merchant & Tenant Configuration: svc-merchant, svc-composer (2026-07-21)
Section titled “EPIC-027 TICKET-27.3 — Merchant & Tenant Configuration: svc-merchant, svc-composer (2026-07-21)”Third EPIC-027 ticket, and the smallest/simplest on this track so far — no ledger/money coupling, no external dependencies, both Workers fully live-verifiable end-to-end. Two Workers: merchant (vendor-neutral merchant identity + membership/role model + LocationService) and composer (per-tenant primitive-enablement + config).
merchant:merchant_accountsis distinct frompayfac’s ownconnect_accountsplaceholder table (TICKET-27.2) — in real Rust,connect_accounts.merchant_account_idFKs to this vendor-neutral merchant identity; vendor rails link to the merchant, not the other way around. Reconcilingpayfac’s placeholder with a real FK to this table is a forward-looking note, not this ticket’s scope. Caught and fixed independently during implementation, before any review:GET /locationsinitially requiredmerchant_account_id(400 if absent) — verified directly againstlocations.rs’s realListQuery(Option<Uuid>), which treats it as an optional tenant-wide filter, fixed before the code ever reached a reviewer. 5 further findings fixed in task review, one Critical: merchant create used SELECT-then-INSERT for idempotency (a real race — two concurrent creates with the sameexternal_refcould both pass the check, then the second insert throws instead of returning the idempotent 200), fixed to atomicINSERT ... ON CONFLICT DO NOTHING, matching Rust’s realservice.rs::createand this codebase’s own explicit anti-pattern rule. Four Important status-code divergences, each independently verified against Rust source rather than assumed symmetric with a similar-looking check: location PATCH used presence-of-key semantics instead of Rust’s realCOALESCE($1, name)(an explicit{"closed_at": null}could silently reopen a soft-closed location, which Rust has no code path to do at all); location-create’s cross-tenant check returned 404, but Rust usesInvalidRequest/400 for that specific check — genuinely different fromadd_member’s structurally similar check, which really does useNotFound/404;list_memberssilently returned[]for a cross-tenantmerchant_account_idinstead of Rust’s explicit 404;remove_memberalways returned 204, but Rust checksrows_affected()and 404s if the membership never existed (not an idempotent-204-always design, unlike a different route elsewhere on this track that genuinely is idempotent by Rust’s own design there — two independently-verified, different behaviors, not assumed to match).composer: D1, not a Durable Object — Rust’s own migration comment documents the DO being retired for cheap, consistent config reads; this port carries that reasoning forward rather than resurrecting the DO pattern (per the EPIC’s own explicit acceptance-criteria wording). One deliberate departure from a faithful port:enable_primitivevalidatesnameagainst every primitive Worker actually deployed on this CF track and 400s on an unknown name — Rust’s own route accepts any string with zero validation (validation only happens later, at flow-definition save time, insvc-orchestration). The EPIC’s acceptance criteria for this ticket explicitly requires this hardening; the literal wording (“hasn’t shipped in EPIC-026”) is narrower than the actual validation set used (which includespayment/ledger/messaging/compliancetoo) — resolved as an explicit interpretive decision in the spec, not silently picked.payfacis deliberately excluded from the validation set (an operator/platform surface, not a tenant-toggleable feature, unlikecompliance). 2 findings fixed in task review: bothenable/disableandPATCH /composer/configused a SELECT-then-JS-mutate-then-UPSERT shape — the same read-modify-write race class this track already found and fixed once incompliance’s beneficial-owner handler — rewritten to single atomic D1 statements (json_each/json_group_arrayfor enable/disable idempotent set operations, and narrowing PATCH’sSETclause to only the columns actually present in the request rather than reading-then-rewriting untouched fields); and a non-object JSON body (null, an array, a bare scalar) crashed with an uncaughtTypeErrorinstead of a clean 400.- Both Workers deployed to Galactic and live-verified end-to-end against real infrastructure, including the exact scenario the read-modify-write race would have broken: enabling
fraudthentaxback-to-back and confirming both persist, then disablingfraudand confirmingtaxsurvives — against the real deployedcomposerWorker, not just the test suite.merchant’s full create→idempotent-replay→member-add→member-remove(→404-on-repeat)→cross-tenant-400 chain also live-verified.
EPIC-027 TICKET-27.4 — Money Orchestration: svc-platform, svc-routing, svc-wallet (2026-07-21)
Section titled “EPIC-027 TICKET-27.4 — Money Orchestration: svc-platform, svc-routing, svc-wallet (2026-07-21)”Fourth EPIC-027 ticket, and the one requiring a genuine design decision rather than a mechanical port: Rust’s svc-wallet FIFO lot deduction relies on Postgres FOR UPDATE to serialize concurrent balance checks against the same wallet, and D1/SQLite has no row-level lock. Three Workers: platform, routing, wallet.
-
platform: mechanical port of operating account balances + funding requests.platform_bank_accountsdeliberately NOT ported — grepping the entire Rust workspace found zero writers anywhere for that table (only the migration creates it;funding_requests.bank_account_idis accepted but never validated against anything real in Rust either), so it was ported as a plain opaque nullable column instead of inventing a feature Rust itself never shipped.balance()’s pending-outbound total andsettlement_report()both read Rust’s sharedpayoutstable directly — on this per-Worker-D1 architecture that table lives only in thepayoutWorker’s own database, so both became new small cross-Worker read endpoints onpayout(GET /payouts/pending-outbound-total,GET /payouts/settlement-report) rather than local queries; both returnnull(not0) on failure, never a sentinel that looks like a real answer.completeFundingRequest/failFundingRequestare genuinely dead code in Rust itself (zero callers anywhere, not even a webhook handler) — ported as internal functions for parity, no route invented. -
routing: processor_configs CRUD + the three selection functions (route,routeCardPresent,routePayout), deliberately unreachable by design at the time this ticket landed — no processor adapters existed on this CF track, and even production Rust hadprocessor_configsempty.credentialswas a plain unencrypted JSON blob at that point (nothing existed yet to decrypt it). Superseded 2026-07-28 (ADR-12, PR #505):credentialsis now AES-256-GCM encrypted at rest (workers/routing/src/crypto.ts, keyed byROUTING_CREDENTIALS_MASTER_KEY) as the first real CF processor adapter (Stripe Connect payout) lands behind this Worker — see the “CF Rewrite” section’s payout-rail entries for the current state.merchant_account_idis an opaque cross-Worker reference, same seam-decision class as TICKET-26.3’s payout↔recipient linkage. Task review foundroutePayout()had drifted from Rust in two ways: it wrongly excluded merchant-scoped rows (Rust’s realroute_payout()has no such filter at all) and was missing Rust’s optionallabelexact-match branch entirely — both fixed to match Rust exactly. InvestigatingrouteCardPresent()’sORDER BYfor that same review surfaced a genuine, unrelated production Rust bug candidate: Postgres and SQLite disagree on NULL ordering underDESC(ORDER BY (v = 2) DESCsorts NULL first in Postgres, last in SQLite — confirmed empirically against the real Neon instance), meaningroute_card_present()’s intended “merchant-scoped always outranks tenant-wide” precedence is likely inverted in real production Postgres while this CF port (on SQLite) behaves correctly. Logged in BUGS.md — a Rust production issue, out of scope to fix in this CF-track ticket, but too significant to leave unrecorded. -
wallet— the design decision. Evaluated two options before writing any implementation code: (1) an atomic single-statement D1 SQL update using a window-function CTE to compute a running-sum FIFO allocation in one statement (spiked and empirically confirmed working in the realvitest-pool-workersD1 environment — window functions,UPDATE...FROM, anddb.batch()’s all-or-nothing atomicity all verified directly, not assumed from SQLite version docs), versus (2) aWalletDOper consumer mirroringledger’s already-provenLedgerAccountDOpattern. Chose the DO: option 1’s elegance doesn’t generalize past a single-table update —deduct()’s real shape is a 3-table write (lots + transaction + audit rows), and making that atomic viabatch()would rest on a non-local invariant (exact statement ordering) a future edit could silently break with nothing to catch it; the DO’s correctness is structural (single-threaded execution per instance) and needs no such convention to hold.WalletDOis keyed per (tenant, consumer), not per (tenant, consumer, currency) — one DO instance holds all of a consumer’s currency-scoped wallets/lots/transactions/disputes, so even the multi-currency-spanning disputes list is a single DO call. No D1 projection at all for this Worker (unlikeledger) — no cross-consumer aggregate route exists in Rust’s wallet API to justify one. The safety property rests on one concrete rule, not a vague “DOs are single-threaded” appeal: every check-then-write sequence uses the synchronousctx.storage.sql.exec()API with zeroawaitin the critical section — verified twice by deliberately injecting a realawaitgap into the FIFO deduction and confirming the concurrency test caught it both times (once pre-review with a clean assertion failure, once post-review-fixes where the runtime itself crashed withabortAllDurableObjects()from the resulting race), then reverting.Task review caught that the design spec itself was wrong on dispute resolution, not just the code: reading
disputes.rs::resolve()more carefully than the module doc comment, the merchant-outcome reversal is all-or-nothing — it calls the identicalfifo_deduct_tx()the normaldeduct()path calls, not a “take whatever’s available” partial reversal as both the spec and the first implementation assumed. On insufficient funds, Rust deducts nothing at all, resolves the dispute withreversal_txn_id = NULLand the reason tagged;reversal_shortfall, and logs loudly for ops to recover out-of-band. This single correction fixed three findings at once: an inflatedtotal_minorrecorded before a (no-longer-possible) partial reversal loop ran; the provisional-credit lot’sis_withdrawableflag was backwards (hardcoded0, Rust sets1) combined with an unfiltered reversal query, which together could have let a merchant-outcome reversal wrongly claw back an unrelated non-withdrawable bonus lot; andresolveDispute()404’d on an already-resolved dispute instead of Rust’s idempotent as-is return. Fixed by extracting a shareddeductFromWalletId()helper bothdeduct()andresolveDispute()call, so all three properties hold by construction rather than by two implementations staying in sync.send()(P2P transfer),pay_external()(cross-tenant payment), andwithdraw-to-bankare explicitly out of scope, not stubbed — each requires a cross-DO or cross-Worker saga (deduct-then-credit-elsewhere with compensation on failure), a different design problem from the single-wallet locking question this ticket’s acceptance criteria is actually about.withdraw-to-bankin particular is a documented 3-way reserve-then-compensate saga acrossplatform→wallet→payoutwith an admitted-unfixed idempotency gap in Rust itself. Tracked in TODO.md as a follow-up. -
All three Workers (plus
payout’s two new endpoints) deployed to Galactic and live-verified against real infrastructure — including firing 5 truly concurrent$50 payHTTP requests at a liveWalletDOinstance holding a$100balance and confirming exactly 2 succeeded, 3 failed withinsufficient_wallet_balance, and the final balance was exactly0, never negative or double-deducted.
EPIC-027 TICKET-27.5 — Execution Engines & Data Proxy: scoping decision + database (2026-07-21)
Section titled “EPIC-027 TICKET-27.5 — Execution Engines & Data Proxy: scoping decision + database (2026-07-21)”Final EPIC-027 ticket. Per the EPIC’s own explicit framing, this ticket starts with a scoping spike, not implementation: Rust’s svc-orchestration (a generic, tenant-authored JSONB DAG interpreter — POST /v1/flows accepts arbitrary step graphs with depends_on/JSONPath-style conditions/parallel dispatch) and svc-workflow (a simpler linear saga stepper, driven by the caller manually recording each step) are both Rust-specific solutions to “run a multi-step business process reliably.” Cloudflare Workflows is a genuinely different primitive — durable, code-defined steps, not a runtime interpreter over stored data.
The decision: neither Rust service is ported as a generic engine. svc-workflow is replaced outright by Cloudflare Workflows directly — a step.do() sequence already is a durable linear stepper with strictly better guarantees (automatic retry/backoff, no sweeper to write). svc-orchestration’s generic DAG-interpreter capability is not replaced by an equivalent — replaced instead by purpose-built Workflow classes per identified use case, per the EPIC’s explicit instruction not to cargo-cult a Rust-shaped interpreter onto a runtime whose actual strength is a small number of known process shapes expressed directly in code. Building a FlowWorkflow class that interprets stored JSONB flow definitions at runtime was considered and explicitly rejected as the anti-pattern this ticket’s technical notes warn against, even though it’s technically possible (a Workflow’s run() is just async code that could loop over ready steps).
This is a real capability reduction, stated plainly rather than implied away: a tenant can no longer author and version arbitrary multi-step flows via an API call without a code deploy. Backed by real evidence, not assumption — queried prod Neon directly (ancient-heart-91326215): flow_definitions has 9 rows, orchestration_runs has 4, workflow_runs has 0. All 9 flow_definitions rows belong to one tenant (Paystream, 4333f0d0-…), all created within a 2.5-hour window on 2026-07-04 — the one-time EPIC-017 Paystream migration/onboarding setup — with zero runs of any flow since that day, and svc-workflow has never had a single row for any tenant, ever. This is real evidence the drop is safe for the traffic that exists today — explicitly not evidence no future tenant will ever want self-service flow authoring; that risk is accepted knowingly.
The proof flow — KycGatedPayoutWorkflow: the EPIC names “KYC-gate-before-payout” as its own example. Investigating what exists today found a genuine, non-contrived gap: POST /payouts already calls checkPayoutEligibility(), but that function checks recipient’s own locally-set status field — set independently of any compliance KYC decision. A recipient can have status='kyc_approved' in its own table while having zero approved KYC checks on file in compliance — the two Workers had never been cross-wired. New KycGatedPayoutWorkflow (workers/payout/src/kyc-gated-payout-workflow.ts), a new [[services]] COMPLIANCE binding on payout, and new POST /payouts/gated + GET /payouts/gated/:id/status routes (additive — POST /payouts’s existing behavior is unchanged). Three steps, cheapest-check-first: (1) reuse the existing checkPayoutEligibility() (extracted to a new shared payout-operations.ts to avoid a circular import with the Workflow class, which itself gets re-exported from index.ts); (2) the new gate — call compliance’s GET /compliance/kyc?subject_type=recipient&subject_id=…, gate-fail on null/non-approved; (3) insert the payout. recipient_not_found/recipient_ineligible/kyc_gate_failed are all normal Workflow returns (status: 'complete' with structured output) — a deliberate, documented divergence from PaymentWorkflow’s own convention of throwing for a legitimate “never captured” business outcome — reserving throw/'errored' for genuine infra failures.
Workflow instance id ≠ idempotency key: unlike payment (payments.id doubles as both the row’s global PK and the caller’s idempotency key), payout’s own schema scopes idempotency only per-tenant (UNIQUE (tenant_id, idempotency_key)), so reusing it directly as a Workflow instance id (which must be globally unique across the whole binding) would risk a cross-tenant collision. A fresh crypto.randomUUID() is minted instead, tracked in a new gated_payout_workflows table for idempotent replay and a tenant-ownership check on the status route (Workflow instance ids carry no tenant scoping of their own). Task review found a real SELECT-then-INSERT race in the idempotency check — two concurrent identical-key requests could both pass the SELECT before either INSERT landed, violating this codebase’s own explicit “never use SELECT-then-INSERT for idempotency” rule; fixed to an atomic INSERT … ON CONFLICT DO NOTHING RETURNING, mirroring insertPayout()’s existing shape. Whole-branch review (Opus) returned MERGEABLE with only Minor findings: the .create()-failure compensating-cleanup path was untested (fixed — extracted createGatedPayoutWorkflowOrCleanup, mirroring payment’s createPaymentWorkflowOrCleanup, with 2 new tests proving both the single- and double-failure branches never rethrow); POST /payouts and POST /payouts/gated sharing one idempotency namespace on the payouts table (logged in BUGS.md, low severity, not fixed — requires deliberate cross-route key reuse).
database (svc-database port): no ambiguity — a standalone HTTP proxy to Post AI (https://post-ai.finhub.workers.dev). One correction made before implementation, by reading svc-database/src/service.rs directly rather than trusting CLAUDE.md’s own prose summary: record_provision does not call Post AI at all — it’s pure local idempotent D1 bookkeeping (database_provisions, UNIQUE(tenant_id)), while the other 7 routes are pure proxies. validate_path_segment ([A-Za-z0-9_\-:.] allowlist, checked before any network call) ported exactly — a security control against path/URL injection into the downstream Post AI request, not incidental behavior. Task review found 2 Important gaps: none of the 4 body-bearing routes (query, query/one, transaction, migrations/apply) validated request-body shape beyond JSON-syntax parsing, so a malformed body either threw an uncaught TypeError or silently proxied an incomplete request to Post AI — fixed with explicit shape validators returning clean 400s before any network call; /query/one and /migrations/apply had zero test coverage — added. Also surfaced (and logged in BUGS.md, not fixed — a pre-existing Rust-side latent bug, out of scope for a CF-track ticket) that Rust’s real transaction() doesn’t default a missing per-statement params to [] the way query()/query_one() do, contradicting CLAUDE.md’s own documented rule — the CF port deliberately does not reproduce this divergence.
Both Workers deployed to Galactic and live-verified end-to-end against real infrastructure: database’s provision route (idempotent, camelCase response) and path-segment validation (400 before any network call) confirmed live; the actual Post AI proxy calls could not be fully live-verified (no real Post AI credentials available to this evaluation-only track — noted, not silently glossed over). KycGatedPayoutWorkflow verified against the real deployed recipient/compliance/payout Workers: a recipient made locally-eligible via recipient’s own KYC flag but with no compliance KYC record on file correctly gate-fails (kyc_gate_failed: no_kyc_check_on_file); after initiating and approving a real KYC check in compliance, a retried gated-payout request creates a real payout row (status: 'created', real payoutId); cross-tenant status reads 404; repeated identical-idempotency-key requests return the same instanceId.
With EPIC-026 and EPIC-027 both now complete, the CF rewrite has a complete-enough surface (all primitives + services, minus processor adapters) that the actual go/no-go migration decision can be made on real evidence — that cutover decision itself remains separate, explicitly gated, and requires genuine real-time user confirmation.
Public API Gateway + svc-auth sk_* API keys (2026-07-22, standalone prep task)
Section titled “Public API Gateway + svc-auth sk_* API keys (2026-07-22, standalone prep task)”Not part of EPIC-026/027 — a post-completion prep task authorized separately, addressing the #1 structural gap docs/cf-rewrite-cutover-readiness-report.md §2 identified: no public entry point existed on this track at all. Every prior ticket’s live-verification hit an individual Worker’s own *.workers.dev subdomain directly; there was no equivalent of finstack-api-gateway’s single api.finstack.sh → one Fly container → all /v1/* routes.
Scoping decision made mid-design: while designing the gateway, found a second, previously undocumented gap — svc-auth’s /internal/validate only recognized fs_sess_/cs_sess_ session tokens; sk_* tenant API keys (the primary way real tenants authenticate against the production REST API) had argon2id hash/verify helpers written but no lookup wired in. A gateway that can only serve session-token callers isn’t a meaningful proof for the traffic that actually matters — svc-auth was extended first.
Binding rule: gateway’s wrangler.toml service bindings must list every domain in gateway/src/routes.ts’s ROUTES table by matching binding name, or that route 500s as misconfigured_gateway. routing and ledger are deliberately absent from the public route table — internal-only, cross-Worker-call surfaces, mirroring their pre-removal Rust equivalents having no public route either — not evidence of a wiring gap.
svc-auth: key_prefix-indexed lookup (sk_{env}_{short_code}_{secret}, mirroring Rust’s real key format) added to validateBearerToken — the api_keys.hash column is a salted argon2id digest and can’t be looked up by exact match the way session_tokens’ SHA-256 hash is; key_prefix gives an O(1) narrowing query before the constant-time verifyApiKey compare. New POST /internal/provision-key mints a test key once (never recoverable after), gated behind X-Provision-Secret (constant-time compare, fail-closed on an unconfigured secret) after an automated security review of the initial commit correctly flagged it as deployed with zero authentication — anyone reaching the public Worker URL could have minted a fully-scoped key for any tenant. Fixed same session, before any further build depended on it.
New gateway Worker (finstack/workers/gateway/): raw fetch handler (this repo’s existing per-Worker convention — no framework), not a redeploy of the Rust gateway’s Hono-based pattern verbatim. Fronts /v1/* (validates via a service-binding call to svc-auth’s /internal/validate, then dispatches to the matched backend’s own service binding using a 23-segment route table — verified line-by-line against finstack-rs/crates/api-rest/src/lib.rs’s real .route(...) registrations, not assumed from a domain-name guess) and /oauth/* (unauthenticated passthrough — OAuth’s own token/authorize/revoke/introspect endpoints handle auth internally). Strips only the literal /v1 prefix before forwarding, never /v1/{domain} — every backend Worker’s own routes already include their first path segment (payment handles bare /payments, database handles /database/provision). routing and ledger have no entry in the route table: neither has a public Rust route (both are cross-worker-call-only).
A real Miniflare/Cloudflare Workers semantics finding, not assumed: a service-binding mock (or, in real production, a bound Worker) throwing an uncaught exception is converted into a resolved HTTP 500 Response for the calling Worker — it does not reject the caller’s fetch() promise. dispatch()’s 502 upstream_unavailable catch block guards a different failure mode (e.g. a binding pointing at an undeployed/nonexistent Worker), confirmed by writing a test expecting 502 on a throwing mock and observing 500 instead — corrected the test to match reality rather than force the plan’s original (inaccurate) expectation.
Both Workers deployed to Galactic and live-verified end-to-end: minted a real sk_* key via /internal/provision-key, called the gateway with it, and the request reached the real payment Worker’s POST /payments handler (PaymentWorkflow instance created, instanceId matched the sent idempotency key) — full chain proven: curl → gateway → svc-auth validate → signed assertion → PAYMENT binding → real backend. Negative paths spot-checked live too: invalid auth → 401, unknown route segment → 404.
Two pieces (sessions.ts’s sk_* branch; the provision-key.ts auth-gate fix) were implemented inline after 2 consecutive Codemind BUILD_FAILED_QA failures each — both traced to build-sandbox issues (a D1 mock not supporting .all()/a generation-stage failure; a monorepo scoped-package subpath-import resolution issue), not spec-quality problems. Issues filed per the mandatory failure-reporting policy: g-a-l-a-c-t-i-c/codemind#470, #471.
Stripe capture/cancel (2026-07-23, standalone prep task)
Section titled “Stripe capture/cancel (2026-07-23, standalone prep task)”Second post-EPIC prep task, addressing docs/cf-rewrite-cutover-readiness-report.md §2’s next-largest structural gap: payment’s Stripe adapter had only createPaymentIntent — no capture, cancel, or refund equivalent to Rust’s real PaymentProcessor trait. Scoped to capture+cancel only; refund is architecturally separate (needs a new RefundWorkflow class + refunds table + webhook-receiver extension, since Rust’s refund is pending-first with async confirmation happening after the original PaymentWorkflow instance has already completed) — deferred to a follow-up task, tracked in TODO.md.
A real architectural gap, not just missing functions: Rust’s real Stripe adapter creates PaymentIntents with capture_method: manual, confirm: false (authorize-only) — capture is a genuinely separate later action. The CF port’s createPaymentIntent had no capture_method param at all, so Stripe defaulted to automatic capture — the existing PaymentWorkflow’s single “create → await-capture → ledger” flow already auto-captured at intent-creation time, with no real capture step to add capture onto. Fixed by switching createPaymentIntent to capture_method: manual, confirm: false, matching Rust’s real params exactly — a behavior change to the previously-working, live-verified happy path, and the correct fix (the existing Workflow step is literally named await-capture and waits for a payment_captured event, already anticipating a real capture action), not a preference.
New captureIntent/cancelIntent adapter functions (workers/payment/src/adapters/stripe.ts) mirror Rust’s StripeProcessor::capture()/cancel() exactly, including the {paymentId}/capture and {paymentId}/cancel idempotency keys. New POST /payments/:id/capture and .../cancel routes: authenticate(payment:write) → 404 if the payment doesn’t exist or belongs to another tenant → 409 unless status === 'processor_intent_created' (a soft guard — nothing rolls payments.status back on a Workflow failure, so a stale row could still pass it; closed pragmatically by the next step) → call the Stripe adapter wrapped in try/catch, 502 with a clean error body on failure → only on success, update payments.status to capture_requested/cancel_requested (never before the Stripe call — updating first would permanently strand a payment on a transient failure, since the guard only allows retry from processor_intent_created).
Why no webhook or Workflow changes were needed: Stripe’s payment_intent.succeeded event fires on capture completion regardless of capture_method (Stripe’s documented semantics). The existing, unmodified webhook-receiver.ts and PaymentWorkflow’s waitForEvent('payment_captured') step already handle it. This was independently confirmed live, not just trusted from documentation — via the Cloudflare Workflows REST API directly (GET /workflows/payment-workflow/instances/{id}), not just inferred from a stuck poll: await-capture resolved with output: {"outcome":"succeeded"} from a real, correctly HMAC-signed webhook delivery, exactly as designed.
Live-verification found two things worth recording:
- The account’s Stripe Dashboard
automatic_payment_methodsconfiguration required an explicitreturn_urlon the manual test-confirm step (POST /payment_intents/{id}/confirmwithpm_card_visa) — a real Stripe account-level detail the design spec’s manual-confirm procedure hadn’t anticipated, not a code bug. Confirmed by reading Stripe’s own error message directly rather than guessing. post-ledger-entryfails with404 account not found for tenantfor a synthetic test account/tenant with no provisioned ledger account — a pre-existing dependency ofPaymentWorkflowitself (unmodified by this task, already live-verified working in sub-project 0’s original hardening pass against a real provisioned account), not a capture/cancel-specific gap. The Workflows REST API’s step-by-step output made this unambiguous:create-stripe-intent,record-processor-intent, andawait-captureall succeeded with the correct data; only the unrelated ledger-account-provisioning step failed.
Both routes live-verified end-to-end on Galactic: real Stripe test-mode PaymentIntent created authorize-only (confirmed via direct Stripe API: capture_method: manual, status: requires_payment_method), manually confirmed, captured via the gateway (200, capture_requested/succeeded), webhook delivered and picked up by the Workflow with the correct outcome. Cancel verified separately end-to-end: 200 cancel_requested/canceled, webhook delivered, Workflow reached errored via the pre-existing “reported as failed” handling. 409 confirmed live on both a real double-capture and a cancel-after-cancel attempt.
Given the confirmed-systemic Codemind sandbox bug from the gateway task (issues #471/#473/#474 — any @finstack/core/* subpath-export import fails the oracle identically) affects every file this task touches, stripe.ts’s and index.ts’s changes were implemented directly rather than repeating already-disproven build attempts; issue #473 was filed as the fourth confirmed instance before falling back.
Stripe refund (2026-07-23, standalone prep task — closes the Stripe adapter gap entirely)
Section titled “Stripe refund (2026-07-23, standalone prep task — closes the Stripe adapter gap entirely)”Third and final piece of docs/cf-rewrite-cutover-readiness-report.md §2’s Stripe adapter gap, shipped the same day as capture/cancel. Scoped to full-refund-only — no partial-amount input, no remaining-refundable tracking, no fee-reversal telescoping. Rust’s real refund (finstack-rs/crates/primitive-payment/src/refund.rs) supports partial refunds via a SUM-of-prior-refunds “remaining refundable” computation and a cumulative_before_minor fee-reversal baseline for splitting a partial refund’s fee reversal against previously-charged fees — the CF track has no fee-tracking concept on payments at all, so most of that complexity has nothing to attach to. One full refund per payment is enough to prove the pending-first + async-confirm architecture end to end.
A second real pre-existing gap found while designing this: accountId was never persisted to the payments D1 row — only ever a transient PaymentWorkflowParams field used at capture-ledger-post time. A refund’s reversing ledger entry needs to credit the same account the original capture debited. Fixed by adding payments.account_id, populated at POST /payments’s existing insert — RefundWorkflow reads it back rather than requiring the caller to resupply data they already gave once.
New refunds table (migration 0003, alongside payments.account_id): mirrors payments’ shape, id/workflow_instance_id = the caller’s idempotency key (same global-PK duplicate-prevention pattern), partial unique index on processor_refund_id mirroring migration 0002’s existing convention. New createRefund adapter function mirrors Rust’s StripeProcessor::refund() exactly — POST /v1/refunds with payment_intent+amount form fields, Idempotency-Key set to the refund’s own id (not a compound {paymentId}/action key — only one refund action exists per refund row under this scope).
New RefundWorkflow class (workers/payment/src/refund-workflow.ts) mirrors PaymentWorkflow’s exact shape: load-payment-intent (re-derives processor_intent_id from the payments row rather than trusting a possibly-stale Workflow param) → create-stripe-refund → record-processor-refund → waitForEvent('refund_confirmed') with the same poll-fallback pattern → post-ledger-entry posts a credit (the reversal of the original capture’s debit) to the account read back from payments.account_id. New POST /payments/:id/refund + status route: 404/409-guarded (only from capture_requested), same shape as capture/cancel’s routes. createPaymentWorkflowOrCleanup generalized to createWorkflowOrCleanup (adds a table parameter) so both payment and refund creation share the same compensating-cleanup logic rather than duplicating it. Webhook receiver gains refund.created/refund.updated dispatch (listening on both, matching Rust’s own hardening fix — unconfirmed whether Stripe emits .updated for a refund already succeeded at creation) via a new lookupRefundByProcessorRefundId, mirroring the existing intent lookup exactly.
Live-verified end-to-end on Galactic via the Cloudflare Workflows REST API (the same authoritative-proof pattern established during capture/cancel — an app-level status poll can look “stuck” for reasons unrelated to correctness): create-stripe-refund succeeded against real Stripe (re_... id returned), a manually-signed refund.updated webhook was delivered directly to the Worker (no live Stripe subscription targets it — same procedure as capture/cancel), and await-refund-confirmation resolved with the correct outcome. post-ledger-entry hit the same pre-existing, unrelated gap already documented for capture/cancel (no ledger account provisioned for the synthetic test account) — not a refund-specific bug.
A real, predicted-in-advance finding confirmed live: nothing in this design updates payments.status after a refund completes (only capture/cancel do), so a second refund attempt against the same payment passes this Worker’s own local guard — returns 200, not 409, exactly as traced from the code before ever running it. Live-verified this precisely: the second attempt’s create-stripe-refund step failed with a real Stripe 400 charge_already_refunded — confirming Stripe’s own server-side validation, not this Worker’s guard, is the actual backstop against double money movement. A known UX gap (a 502 instead of a clean 409), not a correctness or security gap.
A real D1 migration-tracking-drift incident found and fixed during deploy — the same class of issue as this repo’s own INC-001 (Postgres side), now on payment-db: wrangler d1 migrations apply --remote failed with table processor_webhook_events already exists, revealing the remote d1_migrations tracking table was completely empty even though migration 0001’s schema was already live (applied out-of-band at some point, never tracked) — and migration 0002’s index had genuinely never been applied. Fixed by baselining the tracker (INSERT INTO d1_migrations (name) VALUES ('0001_...'), verified against the real live schema first, not assumed) before applying 0002 and 0003 for real.
All 5 code tasks (migration test-fixture updates, createRefund, RefundWorkflow, the routes, the webhook extension) were implemented directly rather than via Codemind — by this point 6 confirmed instances of the identical @finstack/core/* subpath sandbox bug had accumulated this session across 3 different subpaths and 3 different source files (issues #471, #473, #474, #476), making further build attempts against any file with this import pattern a near-certain repeat failure rather than a good-faith retry.
With capture, cancel, and refund all shipped, the CF track’s Stripe adapter is now feature-complete relative to Rust’s real PaymentProcessor trait (minus terminal-reader/card-present methods, out of scope for this evaluation track). No further Stripe adapter work is planned on this track absent a specific new need.
Payment capture confirmation (2026-08-08, closes issue #541)
Section titled “Payment capture confirmation (2026-08-08, closes issue #541)”Closes the gap found during PR #540’s round-4 review: createPaymentIntent created every Stripe PaymentIntent with confirm: false and no payment method attached, so it could never leave requires_payment_method — every payment through this Worker was structurally unable to complete, for every caller (accountId ledger-funded and consumerUserId wallet-topup alike). Full design in docs/superpowers/specs/2026-08-08-payment-capture-confirmation-design.md.
Switched capture_method from manual to automatic — confirm and capture now happen as one Stripe-side action, matching PaymentWorkflow’s existing single-step await-capture design rather than adding a second async capture step. The pre-existing /capture route (added in the July 23 task above) is unreachable for new payments as a result — kept, not deleted, since deleting a live route is a separate decision.
Moved intent creation from PaymentWorkflow into POST /payments’s route handler — the only way the route can synchronously return clientSecret to the caller, since intent creation previously happened inside an async Workflow step with no way for the HTTP response to wait on it. PaymentWorkflowParams now takes processorIntentId as an input rather than creating its own; the create-stripe-intent and record-processor-intent Workflow steps are removed (their invariant — the payments row must exist before the Workflow references it — is enforced earlier, at the route handler’s INSERT, before the Workflow instance is even created).
Two caller shapes, one PR: interactive callers (Tap2’s topupCard wallet-topup flow) confirm client-side using the now-exposed clientSecret — this backend never sees a confirm call from them. Server-initiated callers (no end-user session — this codebase has no Stripe Customer/SetupIntent/saved-PaymentMethod concept anywhere, confirmed via grep across every Worker at design time) use a new POST /payments/:id/confirm route ({paymentMethodId}), which distinguishes a real Stripe card decline (card_error/invalid_request_error → 402, reason passed through) from a transient failure (502). order’s /complete is not wired to call this route in this PR — it still has no way to acquire a paymentMethodId (tracked separately, docs/cf-rewrite-deferred-scope.md) — this fix only makes the confirm mechanism exist and work correctly, for whichever caller eventually has one.
New payments.status = 'captured', written by PaymentWorkflow itself at the tail of each branch (after credit-wallet or post-ledger-entry succeeds — deliberately not immediately after await-capture resolves, to avoid a caller observing captured in D1 before the actual money movement has landed). The refund route’s precondition moves from capture_requested (now unreachable for new payments) to captured. payments.client_secret (migration 0006) persists the value so GET /payments/:id/status can re-serve it if the original POST /payments response was lost.
A real fail-loud gap found during TDD, not by design review: removing the old record-processor-intent step’s row-existence check meant the new record-captured-status D1 write could silently no-op via TenantScopedDb.update()’s bare UPDATE ... WHERE if the payments row were ever missing — a genuinely different Workflow instance than the one record-processor-intent used to guard, since it runs at the tail of each branch, not the top. Fixed by restoring an explicit select-then-check before both writes (wallet and ledger branches), throwing the same no payments row found for workflow instance ... error the removed step used to.
Two Codemind builds failed and were implemented inline instead, both filed: ai-codemind/codemind#796 — the QA sandbox can’t construct a WorkflowEntrypoint the way @cloudflare/vitest-pool-workers does (Failed to construct 'WorkflowEntrypoint': constructor parameter 1 is not of type 'ExecutionContext'), the same class of gap as the pre-existing #547/#779; and #798 — the oracle’s synthesized test for index.ts imports a named handler export, but this file (like every Worker in this repo) uses export default { fetch }, the correct shape already shown in the supplied existingFiles. adapters/stripe.ts’s pure-function changes (no WorkflowEntrypoint, no ambient export-shape assumption) built successfully via Codemind on the second attempt (first failed BUILD_FAILED_QA on an under-specified amountP8 test value — toStripeCents requires cent-alignment, tightened and retried per the documented policy).
Accepted residual gap, extending the pre-existing one: createWorkflowOrCleanup’s compensating cleanup on a PAYMENT_WORKFLOW.create() failure now also cancels the just-created Stripe intent, not just the D1 row — but if that Stripe cancel itself fails, the intent survives orphaned and uncancelled with no Workflow tracking it. Same “rare, not eliminated” posture as the existing D1-only double-failure gap this extends (see TODO.md); no fully-atomic fix exists across Stripe + D1 + Workflows.
Verified: payment’s full test suite (126 passing, 1 skipped e2e — unchanged skip, same as before this fix), order’s test suite unaffected (55/55, matching its pre-change baseline exactly).
Wallet-topup credit path (2026-07-26, PR #499)
Section titled “Wallet-topup credit path (2026-07-26, PR #499)”Closes a real gap found while auditing Tap2’s launch readiness: wallet’s POST /wallet/topup (from TICKET-27.4) had no Stripe verification at all — any caller holding a wallet:write-scoped assertion could credit an arbitrary amount directly, with no relationship to a real charge. POST /payments now accepts consumerUserId as an alternative to accountId (exactly one required); PaymentWorkflow branches on it after the same webhook-confirmed-or-polled capture wait every payment already goes through — an accountId payment still posts a ledger entry, while a consumerUserId payment runs a new credit-wallet step that POSTs to a new WALLET service binding’s /wallet/topup, using toStripeCents(amount) for amountMinor and the payment’s own idempotency key (belt-and-suspenders alongside WalletDO.topup()’s own idempotency).
Design decision: rejected trusting a BFF’s client-side “payment succeeded” self-report (option (a) in the original framing) in favor of this webhook-driven auto-credit (option (b)) — client-side confirmation is never authoritative for money movement (the same reason Stripe’s own guidance treats the webhook as the source of truth), and nothing in this repo enforced that trust boundary. Mirrors the write-ordering/retry-safety invariant already established for Stripe Identity → recipient/merchant propagation (PR #497/#498): the cross-Worker credit call happens as part of the Workflow’s own step sequence, and Cloudflare Workflow steps are memoized/retried by the engine itself, so a credit-wallet failure retries rather than silently completing the payment without crediting the wallet.
Two real bugs found in PR review, not by initial implementation: (1) !== undefined checks for the accountId/consumerUserId mutual-exclusivity validation misclassified an explicit JSON null (which typed HTTP clients commonly send for an omitted optional field) as “provided” — fixed by normalizing null to undefined before branching; a second pass then caught that the accountId branch’s PAYMENT_WORKFLOW.create() call still spread the raw request body, which could carry a literal consumerUserId: null through to the Workflow and trip its own defensive invariant check on an otherwise-valid request — fixed by constructing Workflow params explicitly instead of spreading. (2) consumerUserId had no charset guard even though it becomes a Durable Object name component (wallet’s ${tenantId}:${consumerUserId} idFromName key) — the identical bug class already closed once for ledger DO names (PR #480, see “Sub-project 0 hardening” above); now uses the same SAFE_ACCOUNT_ID charset guard as accountId. This closes the gap for payment’s own call path only — unlike ledger’s registry.ts, which enforces its guard inside ledger at the idFromName call site itself, wallet has no equivalent guard of its own; a round-2 review finding, logged open in BUGS.md rather than expanded into this PR. Also found: wallet’s POST /wallet/topup didn’t normalize currency case ("USD" vs "usd") — WalletDO storage is UNIQUE(consumer, currency), so an un-normalized topup silently created a case-distinct wallet no default-currency route (balance/pay/withdraw, all lowercase) would ever read from again; fixed at the wallet Worker (root cause) and defensively at the payment-workflow call site. (3) A round-2 review pass (3 independent agents) then caught that two of the round-1 fixes — the payment-workflow-side currency.toLowerCase() and a defensive throw guarding against WALLET returning 2xx with no lotId — were correct but had no covering test, so “mutation-verified” didn’t actually hold for them; closed with two more tests (an uppercase-currency happy-path test, a dedicated consumer-no-lotid WALLET-stub trigger). All six fixes across both rounds are now genuinely mutation-verified (temporarily reverted, confirmed the relevant test fails, restored) — not just the four from round 1.
Codemind build_feature failed twice with BUILD_FAILED_QA — env.DB came back undefined inside the QA sandbox even for a pre-existing, unmodified test, indicating the sandbox doesn’t reproduce this repo’s D1 + Cloudflare Workflows vitest-pool-workers harness (isolatedStorage: false + singleWorker: true, required specifically because Workflows run as a service binding to the Worker’s own entrypoint). Filed codemind#547 per CLAUDE.md’s failure procedure; implemented inline.
payments.consumer_user_id (migration 0004) — see DATA_MODEL.md. Sibling currency-case gap in wallet’s balance/pay/withdraw routes (same un-normalized pattern, not touched by this PR since nothing routes through them yet) logged in BUGS.md.
References
Section titled “References”CLAUDE.md— active rewrite notes, integration test patterns, scope catalogueDATA_FLOWS.md— detailed data flow diagrams (trace_id chain, webhook gating, observability surface)DECISIONS.md— architectural decision recordsDEPLOYMENT.md— deploy procedures per environmentENVIRONMENTS.md— all secrets, URLs, and environment variables