Skip to content

Re-review: admin personas + install wizard + mail UI + Network carve - #9

Open
keithfawcett wants to merge 259 commits into
pre-ultrareview-2from
main
Open

Re-review: admin personas + install wizard + mail UI + Network carve#9
keithfawcett wants to merge 259 commits into
pre-ultrareview-2from
main

Conversation

@keithfawcett

Copy link
Copy Markdown
Contributor

This PR exists purely as an ultrareview target — it represents everything on main that hasn't been reviewed since the last pass.

Scope

14 commits between `532d7c0` (last reviewed regression tests) and `HEAD`:

  • `fa6fcb1` Network code carved out of OSS to a separate private repo
  • `ea41a6f` Partner invite + magic-link signin
  • `abbb0dc` Mailer dev-mailbox dropped; Postmark or console
  • `999b050` Admins no longer mint partner credentials or links
  • `911fc5c` Partner revocation (session kill, attribution skip, router fraud-flag)
  • `bce56f0` Revoke notifications + optional reason + signin handling
  • `acf8eb5` UI-managed program settings (name + support email)
  • `c28f197` Admin personas, SMTP support, first-run install wizard
  • `fa0d0c7` UI-managed SMTP/Postmark with encryption at rest
  • `a922e30` Install split into 3-step wizard
  • `59526b7` Docs refresh

Plus three small CI fixes (`63f016d`, `ba67b70`, `a6db3a6`).

Areas of highest concern for review

  • Generalized Session + MagicLinkToken schema (partnerId → principalKind/principalId) — backfill correctness, FK loss, principal resolution branching
  • First-run `/install` — 409 guard against second installer, rate limit, transactional admin + program settings creation
  • `SECRETS_ENCRYPTION_KEY` — AES-GCM envelope, fallback in dev, key rotation story
  • Mail resolution order — UI config → env → console fallback; stale credential races on rotation
  • Revoke guards — last-active-admin protection, can't-revoke-self, session kill atomicity

Not for merge.

keithfawcett and others added 30 commits April 24, 2026 16:18
New Settings page at /admin/settings lets the admin edit runtime
content without shell access:

  - Program name   — replaces "OpenPartner" in the sidebar brand
  - Support email  — rendered in the sidebar footer as a mailto:
    link for partners to contact

Backed by the Config table, keyed 'program_settings'. GET /config/program
is auth-only (admin + partner sessions can both read). POST is admin
only. Partner portal fetches on mount with a 60s staleTime so edits
propagate quickly without hammering the endpoint.

Admin's Partners table now wraps each partner's email in mailto: too,
so one click opens a reply with that partner.

Env remains reserved for secrets + build-time properties; everything
user-facing goes through this pattern instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three tightly-coupled changes:

1. Admin as a first-class persona (not just ADMIN_API_KEY)

  - New Admin table (id, email, name, activatedAt, revokedAt,
    revokeReason, lastSignInAt).
  - Session + MagicLinkToken generalized from partnerId to
    (principalKind, principalId) so the same magic-link infra carries
    both admins and partners. Backfill stamps existing rows
    principalKind='partner'.
  - Unified /auth/signin and /auth/magic/verify branch on the token's
    principalKind — admins get adminInviteEmail + adminSigninEmail,
    partners get their existing templates.
  - /admins routes: list, invite, resend, revoke, reinstate. Revoke
    has two guardrails: can't revoke yourself (session-sourced), and
    can't drop active-admin count to zero.
  - ADMIN_API_KEY env stays as bootstrap / headless / CI path; human
    admins sign in via the account instead.
  - Portal gets an Admins page under Admin; principal chip shows
    "admin (env)" for env-bearer, admin name for session admins.

2. SMTP support via nodemailer

  - Mailer auto-select: SMTP_HOST + MAIL_FROM → SMTP, else
    POSTMARK_SERVER_TOKEN + MAIL_FROM → Postmark, else console
    fallback (dev only).
  - SMTP covers the universe of providers (Gmail, Workspace, SES,
    Mailgun, SendGrid, Resend, Postmark SMTP, self-hosted Postfix).
  - Postmark stays as a dedicated adapter.
  - .env.example documents the two transport options.

3. /install wizard — WordPress-style first-run

  - New InstallPage at /install, unauthenticated. Single form
    collects admin name+email + program name + support email.
  - GET /install/status lets the portal route to /install while
    needsSetup=true and back to normal once an admin is activated.
  - POST /install is rate-limited and refuses (409) once any active
    admin exists — second installer can't take over.
  - Submit creates the Admin row + saves program_settings +
    sends the magic-link invite in one round-trip.

New tests (4) cover: install → first-admin happy path; admin invite +
signin; last-active-admin revoke guard; duplicate-email 409. Partners'
existing revoke had to switch Session lookup from partnerId to
(principalKind, principalId) too.

56 api / 3 router / 11 sdk = 70 tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mail transport + credentials now live in the Config table and the
admin edits them from Settings → Email delivery. Rotating SMTP
passwords or Postmark tokens no longer requires a redeploy.

Encryption at rest:
  - New SECRETS_ENCRYPTION_KEY env (32 bytes hex/base64), required in
    production. Dev uses a fixed fallback with a warning.
  - AES-256-GCM envelope (12-byte IV + 16-byte tag + ciphertext, base64).
  - Only secret fields are encrypted (SMTP password, Postmark token).
    Host/port/user/from stay plaintext — identifiers, not secrets.
  - Public readers (the Settings UI) get a sanitized view: `hasPassword`
    / `hasToken` booleans instead of plaintext.

Resolution order at dispatch time:
  1. UI-configured settings (Config table) win
  2. Env vars (SMTP_HOST / POSTMARK_SERVER_TOKEN + MAIL_FROM) as fallback
  3. Console (dev only)

Install wizard grows an Email delivery step: provider (SMTP / Postmark
/ None), from address, and all the fields for the chosen provider.
Settings page mirrors it with "saved ✓" indicators on fields whose
secret is already stored (leave blank to keep, enter to rotate).

The mailer now creates a transporter per-send so rotating credentials
from the UI takes effect on the next email without a restart. Tests
keep injecting a capturing mailer via __setMailerForTests.

.do/app.yaml, docker-compose.prod.yml, ci.yml get SECRETS_ENCRYPTION_KEY
wired in. .env.example rewrites the mail section to explain the UI-first
flow with env as fallback.

56 api / 3 router / 11 sdk tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single-form install grew oppressive. Break it into YOU → PROGRAM
→ EMAIL DELIVERY with a stepper up top, Back/Continue navigation, and
the final step submitting. Validation is per-step (can't advance
without the required fields for that step). Same submit body; only
the UX changed.
README adds a What's implemented subsection grouping by area
(attribution + payouts, auth + personas, configuration, integration
surface, operations). Quickstart leads with the /install wizard and
keeps the curl bootstrap as a headless / CI alternative rather than
the default. ARCHITECTURE gains sections on personas (Admin + Partner,
magic-link auth, revoke semantics) and Settings + secret encryption.
docs/deploy.md now calls out SECRETS_ENCRYPTION_KEY as a required
production env and documents that mail env vars are fallbacks rather
than the primary path.
…race

Three security / lockout fixes from the ultrareview re-pass (PR #9):

1. POST /auth/signin for a revoked partner was sending the
   partner_revoked notice email every time. An attacker could spam
   any known partner's inbox by POSTing their email repeatedly.
   Now: signin for a revoked partner is silent (no email). Revoke
   flow already sends a one-time notification at revoke time with
   the admin-provided reason — that's the only on-the-record
   touchpoint.

2. POST /install's "no admin exists" check was outside the tx.
   Two concurrent installers could both pass the check and both
   create admin rows. Now the check happens inside a transaction
   under a pg_advisory_xact_lock keyed to the install path, so
   concurrent calls serialize and the loser 409s. Also tightened
   the guard to block on ANY admin row (not just activated) so a
   second installer can't slip in while the first magic-link is
   still pending.

3. POST /admins/:id/revoke's "last active admin" guard was outside
   the transaction. Two concurrent revokes of the only two active
   admins both saw count=2, both proceeded, and both committed —
   leaving zero admins and locking everyone out. Now the guard runs
   inside a transaction with SELECT ... FOR UPDATE on the active-
   admins set, so concurrent revokes serialize and the loser 409s
   on cannot_revoke_last_active_admin. Also added revoked-admin
   guard to /admins/:id/invite (can't resend to a revoked account)
   and cleaned up the unused activeAdminCount helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four correctness fixes from ultrareview PR #9:

  - Partner revocation now also flips revokedAt on every ApiKey row
    tied to that partner. Previously sessions were killed but partner-
    scoped API keys lived on, so a revoked partner kept programmatic
    access via the SDK / curl.

  - POST /install is retryable on partial failure. The admin row is
    created inside a tx; the following mail-config save + invite
    email happen in a compensating try/catch that undoes the admin +
    magic-link token on any downstream error. Without this, a Postmark
    outage during first install left the instance permanently 409'd.

  - Install schema now rejects mail.kind='smtp'|'postmark' without a
    from address (plus host/serverToken for the respective transport).
    Previously the install "succeeded" and then the activation email
    silently failed because the mailer had no sender.

  - attribution.ts comment updated to match behavior: revoked-partner
    filtering skips them regardless of event timing, for both live
    event webhooks and backlog replays. Earlier the docstring
    suggested "events after revokedAt" but the code filtered more
    aggressively. Code is what we want; comment was lying.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four validation / UX fixes from ultrareview PR #9:

  - POST /partners pre-checks for a duplicate email and 409s with
    email_taken instead of letting the unique-constraint violation
    surface as a generic 500. A race path (two concurrent inserts
    passing the pre-check) is caught via pg error code 23505 and also
    maps to 409.

  - saveMailSettings now refuses kind='smtp' with an empty host and
    kind='postmark' without a serverToken. Both used to save garbage
    that would blow up at first email. MailSettingsValidationError
    maps to a 400 with a field pointer in the /config/mail route.

  - MagicLanding invalidates the install-status react-query cache on
    successful verify so the first-run admin lands on / after
    clicking their activation link. Before, the cache was keyed
    staleTime:Infinity and still said needsSetup=true from boot —
    / would redirect right back to /install until a hard refresh.

  - admin_accounts migration's down() dropped a non-existent index on
    MagicLinkToken (up() only ever added the index on Session). Dropped
    the stray dropIndex call so rollbacks succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Newly-activated partners used to land on an empty Dashboard with
nothing to do. The card fills that gap — two checklist rows that
auto-complete as the partner finishes each step, and the card
disappears once both are done:

  ☐ Create your first share link → /links
  ☐ Connect Stripe to get paid   → /connect

Live from the partner's existing data: link count via GET
/partners/:id/links, Stripe Connect readiness via /connect/status.
The existing ConnectNudge (shown when there's actually money waiting
to be paid out) suppresses while the checklist is up — one call to
action at a time.

Also removed the dead Network-role redirect in the Dashboard
component (network_creator / network_vendor roles no longer exist
since the Network carve-out).
Adds a Rewardful-style integration path where merchants pass the partner
ref through Stripe Checkout's client_reference_id instead of calling
op.identify() from their app. On checkout.session.completed we stitch
an Identity (cref → customer.id) and emit signup; downstream invoice.paid
and customer.subscription.created resolve through the existing path.

Disambiguator: the existing handleConnectEvent for checkout.session.completed
now skips when client_reference_id is set, so merchant→customer checkouts
can't accidentally clobber the merchant's own subscription record.

resolveUserIdFromCustomer falls back to an Identity-table lookup when
metadata is missing — covers the race where invoice.paid lands before our
metadata backfill propagates on Stripe's side.

SDK: getReferral() helper with the canonical Stripe Checkout usage in
the docstring. Aliases getCref() so existing integrations keep working.

Tests: 5 cases — valid stitch, unknown cref dropped, idempotency on Stripe
event-id retries, invoice.paid resolves via the stitched Identity, and
the merchant-subscription path still fires when no client_reference_id is
set. Stripe customer ops mocked via vi.mock; signature verification real.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stripe's Event destinations UI splits platform-account events
(checkout.*, customer.*, invoice.*) and connected-account events
(account.updated, transfer.*) into separate destinations, each with its
own signing secret. Both destinations point at the same /webhooks/stripe
URL, so we just need to verify against any configured secret.

STRIPE_WEBHOOK_SECRET now accepts either a single secret (existing
behavior) or a comma-separated list. We try each in turn until one
verifies; if none do, return 400 invalid_signature as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One-shot, idempotent script that creates OpenPartner Flex, Network
access, and Revshare products in any Stripe account. Outputs the
STRIPE_FLAT_PRICE_ID value to add to .env.

Run with:
  STRIPE_SECRET_KEY=sk_test_... node apps/api/scripts/setup-stripe.mjs

Re-runs are safe — products are looked up by metadata key before
create, and prices by amount + recurrence kind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the major payments-readiness gaps before deploy.

Refund + reversal handling
  - Map charge.refunded → reverse non-paid Commissions linked to the
    original invoice via metadata.stripeInvoiceId. Already-paid
    Commissions stay paid and the count surfaces on the refund Event for
    admin attention (no automated clawback in v1).
  - Map charge.dispute.created and invoice.payment_failed → corrective
    audit Events; no auto-reversal (disputes can be won; failed invoices
    never fired invoice.paid in the first place).
  - Skip attribution on corrective event types so we don't create phantom
    negative commissions.

Metered usage billing
  - setup-stripe.mjs provisions Stripe Meters (openpartner_attributed_gmv,
    openpartner_network_payouts) and metered Prices for Flex (1.5%),
    Revshare (3%), and Network access (3%).
  - usage-billing.ts aggregates attributed GMV between the last-reported
    high-water mark and now, then reports to Stripe Billing Meter Events.
    Idempotent via Stripe identifier; high-water mark only advances on
    success.
  - POST /billing/report-usage triggers manual reporting (admin scope).
    Cron entry can be wired up later.

V2 Accounts Checkout fix
  - Stripe Accounts V2 in test mode rejects Checkout sessions without a
    pre-created Customer. /billing/checkout now creates (and reuses) a
    Customer on first call, stamps it on Config, then passes it to
    Checkout. Same record is used by Customer Portal after subscription.

Other
  - /billing/checkout supports both flat (base + metered) and revshare
    (metered-only) modes. Line items differ; same Customer reuse logic.
  - Fix setConfig: jsonb column rejects raw strings; serialize through
    JSON.stringify and cast for primitive values.
  - Tests force OPENPARTNER_MODE=selfhost so vitest's auto-loaded .env
    can't bleed in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scheduler.ts: croner-based, runs usage-report (daily 03:15 UTC) and
  runPayouts (Mon 09:00 UTC). Gated behind OPENPARTNER_ENABLE_SCHEDULER=1
  so dev/test/CI don't fire scheduled jobs unexpectedly. protect: true
  prevents overlap when a job runs longer than its interval.
- .do/app.yaml: adds STRIPE_FLAT_USAGE_PRICE_ID, REVSHARE/NETWORK price
  ids, and OPENPARTNER_ENABLE_SCHEDULER=1. Postgres flipped to
  production: true (paid tier, daily backups).
- docs/deploy-production.md: end-to-end runbook for first launch on DO
  App Platform — secrets, DNS, Stripe webhook destinations, smoke checks,
  troubleshooting.

Verified the api Docker image builds and runs cleanly against a fresh
empty Postgres: all 19 migrations apply, /health returns 200, scheduler
correctly logs its disabled state in dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rather than provisioning a dedicated managed Postgres cluster, OpenPartner
points at an existing cluster (e.g., a separate database inside the
Coherence cluster). DATABASE_URL becomes a SECRET env var on api + router
instead of a templated reference to the embedded ${openpartner-db.DATABASE_URL}.

Block is preserved in a comment so re-enabling a dedicated cluster later
is a paste-back rather than a recall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds github source ref to each component (api, router, portal) and
fixes the ingress rules: portal routes / by default, api gets /api,
router gets /r as a path prefix until the dedicated subdomain is wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DO Managed Postgres serves a CA-signed cert that's not in Node's default
trust store. With pg-node, sslmode=require alone fails the chain check
and the migration runner aborts before the api can start.

Adds sslFromConnectionString helper used by both the runtime db factory
and the knex migration runner. Maps:
  sslmode=require | no-verify       → encrypted, rejectUnauthorized=false
  sslmode=verify-ca | verify-full   → encrypted, full chain check
  no sslmode                        → no ssl (local dev)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pg-connection-string >= v2.7 treats sslmode=require as verify-full and
overrides our explicit { rejectUnauthorized: false } config when both
are present (URL parsing happens after our config is set, so it wins).

Strip sslmode from the URL when we manage ssl ourselves. The connection
remains TLS-encrypted; we just opt out of the chain check that would
otherwise fail on managed providers' self-signed CAs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(db): multi-tenant foundation + RLS

Three new migrations and matching type updates lay the groundwork for
multi-tenant deployments while keeping single-tenant self-host working
identically (just with tenantId='default' baked in).

20260507000000_multi_tenant
  - New Tenant table; seeded 'default' tenant.
  - tenantId column on every data table (Partner, Campaign, Link, Click,
    Identity, Event, Attribution, Commission, Payout, ApiKey, Config,
    Admin, MagicLinkToken, Session, WebhookEndpoint, WebhookDelivery).
  - Backfill existing rows to the default tenant.
  - Re-scope unique constraints to be per-tenant: Partner.email,
    Admin.email, Link.linkKey, Config.(key→tenantId,key).

20260507010000_rls_policies
  - PlatformAdmin table (cross-tenant Coherence support staff).
  - RLS ENABLE + FORCE on every tenanted table.
  - Policy: row visible iff tenantId matches `app.tenant_id` GUC OR
    `app.platform_admin` GUC = 'on'.
  - Tenant table: row visible iff its id matches app.tenant_id (or
    platform admin). Same for PlatformAdmin.
  - Policies use COALESCE / current_setting(..., true) so an unset GUC
    returns 0 rows (default deny) instead of erroring.

20260507020000_app_role
  - Provisions a non-superuser openpartner_app role from
    OPENPARTNER_APP_DB_PASSWORD. Postgres bypasses RLS for superusers
    and BYPASSRLS roles regardless of FORCE, so RLS only protects when
    the app connects as a constrained role.
  - Grants DML (no DDL) on every tenanted table.
  - Idempotent: rotates password if the role already exists.
  - Skipped (with notice) when OPENPARTNER_APP_DB_PASSWORD is unset —
    self-host installs that don't need RLS isolation can run the app as
    the same role as migrations.

Migration runner sets `row_security = off` at session start so DDL
runs unrestricted.

Verified: connecting as openpartner_app, queries return 0 rows when
app.tenant_id is unset or mismatched, and only the in-scope tenant's
rows when set correctly. Platform-admin override works.

Types: every Row interface gained `tenantId: string`; new TenantRow,
PlatformAdminRow types and DEFAULT_TENANT_ID constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): tenancy middleware + connection split (admin vs app pools)

Two knex instances now:
  db (admin pool, DATABASE_URL)
    - migrations, signup, platform-admin tooling, jobs that need
      cross-tenant access
    - bypasses RLS (superuser/owner role)

  appDb (app pool, DATABASE_URL_APP if set, else DATABASE_URL)
    - normal request handling. When pointed at the openpartner_app role
      every query is subject to RLS.
    - per-request transaction in tenancy middleware sets
      `app.tenant_id` (and optionally `app.platform_admin = 'on'`)
      so RLS policies match correctly.

OPENPARTNER_TENANCY env (defaults 'single'):
  single  — every request runs as tenantId = DEFAULT_TENANT_ID. Self-host.
  multi   — path-based tenant resolution (/t/<slug>/...). Reserved
            slugs (www, api, app, signup, etc.) reject.

tenantMiddleware:
  - resolves tenantId for the request
  - opens a transaction on appDb
  - stamps req.db, req.tenantId, req.tenantSlug
  - awaits response finish before committing/rolling back so handler
    queries land in the right transaction context.

Routes will switch from `db('Partner')...` to `req.db('Partner')...`
and add `tenantId: req.tenantId` to inserts. That refactor is the next
commit; this one just lays the wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tenancy): add tenantOf(req) helper for route handler ergonomics

* docs(multi-tenant): handoff guide for the in-progress refactor

Architecture decisions, what's committed, file-by-file refactor plan,
test fixup plan, and how to resume. Read this first before continuing
the multi-tenant work on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): route + helper refactor for tenant-scoped req.db

Section A + B + C + E of the multi-tenant refactor: every route handler
now uses tenantOf(req) for a per-request transaction with app.tenant_id
pinned. Helpers (auth-sessions, auth.resolvePrincipal, config, mail-
settings, mailer, attribution, payouts, usage-billing, webhook-dispatcher)
take Knex + tenantId as parameters. tenantMiddleware is mounted in
app.ts; install + metrics stay public above it. Stripe webhook resolves
tenantId from event metadata and runs each event in appDb.transaction
with SET LOCAL app.tenant_id. Scheduler iterates active tenants per
tick. Typecheck passes.

What this leaves: section D (public /signup), F (test seed updates so
35 of 64 currently-failing tests go green), G (multi-tenant isolation
tests), H (env config + ops). Documented in docs/multi-tenant-refactor.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): public /signup + test seed tenantId fixes

Section D + F of the multi-tenant refactor.

D — POST /signup creates a Tenant + first Admin and emails an activation
magic link. Public, IP rate-limited (10/min), gated by slug validation
(/^[a-z0-9-]{3,30}$/, not in RESERVED_SLUGS, not already taken). Mounted
before tenantMiddleware in app.ts and uses the privileged db. Multi-mode
only — single-mode operators use /install.

F — every direct db().insert() in integration.test.ts, regressions.test.ts,
stripe-webhook.test.ts, and webhooks.test.ts now stamps tenantId:
DEFAULT_TENANT_ID. Test setups force OPENPARTNER_TENANCY=single. Cannot
verify against a live Postgres in this session; flagged as DONE BUT NOT
VALIDATED in docs/multi-tenant-refactor.md so the next pass runs the
suite first.

Handoff doc updated with current branch state and remaining work
(sections G, H + test validation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ops): multi-tenant env + docker + DO + docs

Section H of the multi-tenant refactor.

- .env.example: OPENPARTNER_TENANCY, OPENPARTNER_APP_DB_PASSWORD,
  DATABASE_URL_APP with explanatory comments.
- docker-compose.yml: mount docker/initdb so postgres provisions the
  openpartner_app role on first boot. Role is NOLOGIN if no password
  set so RLS isolation can still be exercised via SET ROLE in tests.
- .do/app.yaml: add OPENPARTNER_TENANCY=multi (default for hosted),
  DATABASE_URL_APP + OPENPARTNER_APP_DB_PASSWORD secrets on the api
  component.
- docs/deploy-production.md: rows for the new secrets in the env
  table; new "Multi-tenant rollout" subsection covering URL routing,
  signup, RLS engagement, Stripe webhook tenant resolution, reserved
  slugs, and the migration path from single-tenant.

The route, helper, signup, and stripe-webhook refactors plus this
ops layer make the multi-tenant branch deployable. What's left in
docs/multi-tenant-refactor.md is section G (live-Postgres isolation
tests) — needs a real DB to write meaningfully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tenancy): bypass RLS on privileged db, commit trx pre-response, add isolation tests

Section G of the multi-tenant refactor + two real bugs the existing
suite surfaced once it ran against a real Postgres.

Bug 1: privileged db was subject to FORCE RLS. The migration role
owns the tenanted tables but FORCE RLS still gates the owner unless
row_security is explicitly off or app.tenant_id is set. Without
either, /metrics, /signup, the stripe-webhook tenant resolver, the
scheduler, and every test's direct cleanup query silently saw zero
rows. Fixed by adding bypassRls: true to createDb (sets row_security
= off in afterCreate) and turning it on for the privileged pool. The
appDb (tenant pool) keeps RLS engaged.

Bug 2: tenantMiddleware committed the per-request transaction on
res.on('finish'), which fires AFTER the response is sent. Tests doing
`await request(app).post(...)` then `await db(...).insert(...)` raced
the commit and got FK violations because the route's writes weren't
yet visible. Fixed by patching res.json/send/end so the trx commits
(or rolls back on 5xx) before any byte goes out. Belt-and-suspenders
res.on('close') still rolls back if the patched methods are bypassed.

Section G: apps/api/src/__tests__/multi-tenant.test.ts — 9 tests
that connect as openpartner_app via SET ROLE inside a privileged-pool
transaction (so RLS engages because openpartner_app has neither
BYPASSRLS nor superuser). Covers default deny, per-tenant visibility,
WITH CHECK rejection on cross-tenant inserts, platform_admin override,
session isolation, and the Tenant table self-policy. Suite skips
cleanly with a warning if the openpartner_app role isn't provisioned.

Stripe webhook tenant resolution: customer/invoice/charge events that
don't carry our metadata now fall back to a local Identity → Click
lookup so checkout-stitched customers still route to the right tenant
on subsequent invoice.paid / charge.refunded.

Result: 73/73 tests pass against the docker-compose postgres.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(network): creator self-signup + vendor↔Network protocol

Three pieces, designed so the same code paths cover hosted multi-tenant
and self-host:

1. Public POST /partner-signup (apps/api/src/routes/partner-signup.ts).
   Tenant-scoped, IP rate-limited, creates a Partner row + magic link.
   Honors a per-tenant partner_signup config (auto_approve vs
   require_review, with disabled override). On hosted multi-tenant the
   URL is /t/<slug>/partner-signup; on self-host it's /partner-signup.

2. Vendor-side Network client (apps/api/src/network-client.ts) +
   NetworkOutbox migration. Fire-and-forget POSTs to /partners/upsert
   on creator events (signup, admin invite, revoke); failures persist
   to the outbox and the scheduler drains them every 5 min with
   exponential backoff (~24h max). vendorToken stored AES-GCM
   encrypted in Config (network_membership), never returned by GET
   /config/network. backfillPartners(...) reconciles a vendor's
   existing roster when they enable Network membership later — the
   Network dedups on email and returns alreadyExisted=true for
   creators who joined another vendor first.

3. Network protocol spec (docs/network-protocol.md). Defines the
   /vendors/register, /partners/upsert, /vendors/backfill-partners,
   and /vendors/me/heartbeat surface that openpartner-network
   implements. Spells out the identity model (vendorId,
   vendorPartnerId, networkCreatorId), auth rotation, and the
   late-join reconciliation behavior.

Wired into existing flows:
- POST /partners (admin invite) + /partners/:id/revoke push to Network
  when membership is enabled. autoEnroll gates new-partner upserts;
  revokes mirror unconditionally so a Network-known creator stops
  being matched after the vendor cuts them off.
- Settings router exposes GET/POST /config/network,
  POST /config/network/backfill, and GET/POST /config/partner-signup.
- Scheduler runs network-outbox-drain every 5 min per active tenant.

Tests (apps/api/src/__tests__/network-and-signup.test.ts, 9 cases)
spin up an in-process HTTP receiver to act as the Network and verify:
signup without Network is silent; with Network on stamps
networkCreatorId on Partner.metadata.network; with Network down
enqueues outbox; drain retries succeed; require_review still pushes
status=pending; admin invite + revoke push; late-join backfill flips
preExisting=true for emails the Network already knew; GET
/config/network never leaks the vendor token.

82/82 tests pass against the docker-compose postgres.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(network): vendor-side onboarding integration

Wires the openpartner vendor side to the openpartner-network
self-serve onboarding flow.

network-client.ts: signupWithNetwork() POSTs to /vendors/signup;
completeNetworkConnect() POSTs to /vendors/verify-and-issue-token.
Failures surface immediately to the admin (no outbox queueing — a
failed signup is something the admin retries by hand).

routes/settings.ts: POST /config/network/start-connect mints a fresh
scoped key with NETWORK_FEDERATION_SCOPES, calls signupWithNetwork
with inferred instanceUrl + portalCallbackUrl, stashes partial state
in network_membership Config (enabled=false until verify lands).
POST /config/network/complete-connect consumes the magic-link ntoken,
calls Network /vendors/verify-and-issue-token, saves the returned
vendorToken with enabled=true. Same shape works for hosted multi-
tenant tenants (slug-aware URL inference) and self-host (request host).

routes/signup.ts: hosted multi-tenant signup auto-calls
signupWithNetwork after Tenant/Admin creation when NETWORK_URL env
is set. Best-effort: a Network outage doesn't fail the signup; the
admin can finish later via Settings → Network → Connect button.
Returns network: { status, vendorId } in the signup response so the
portal can show the right next-step UI.

.env.example: NETWORK_URL added with explanatory comment.

82/82 vendor-side tests still pass (no regressions; the new endpoints
are additive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(portal): vendor admin Network UI — connect, offerings, requests

Closes the gap where vendors had backend wiring for Network membership
but no UI to use it. Without this, Network was invisible to vendor
admins on hosted multi-tenant + self-host.

Backend (apps/api):
- network-client.ts: NetworkProxyError + networkProxy.{listOfferings,
  createOffering, updateOffering, deleteOffering, listRequests,
  approveRequest, rejectRequest, whoami}. Decrypts the vendor token
  from network_membership Config and proxies to Network endpoints
  with the right bearer.
- routes/settings.ts: /admin/network/{me,offerings,offerings/:id,
  requests,requests/:id/approve,requests/:id/reject}. Each is a thin
  wrapper around networkProxy.* that turns NetworkProxyError into
  the appropriate HTTP status. Required because the vendorToken is
  a server-side secret — the portal can't hold it.

Portal (apps/portal):
- pages/admin/Network.tsx: connection status, contact-email/display-
  name form for the Connect button, autoEnroll toggle, backfill
  panel for late-join reconciliation.
- pages/admin/NetworkComplete.tsx: handles ?ntoken= callback from
  the Network onboarding email; calls /config/network/complete-connect,
  redirects to /admin/network on success. StrictMode-safe (one-shot
  guard).
- pages/admin/NetworkOfferings.tsx: list + create + publish/unpublish
  + delete. Campaign dropdown pulls from /campaigns. Form fields:
  title, description, productUrl, campaign, commission summary,
  cookie window.
- pages/admin/NetworkRequests.tsx: pending requests list with creator
  bio + pitch; approve dispatches federation (creates Partner +
  Link on this instance); reject + status filter (pending /
  approved / rejected / cancelled).

Wired into App.tsx routes + a new "Network" sidebar section
(Connection, Offerings, Requests).

Typecheck passes; portal builds (318 KB JS, 92 KB gzip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the metering loop on the 3% Network fee. Previously the
openpartner main repo stamped Partner.metadata.network.creatorId
when a partner came through the Network, but never aggregated the
resulting payouts or surfaced them to anyone. The Network's billing
charged the flat $29 only.

usage-billing.ts: aggregateNetworkOriginatedPayouts(db, since, until)
sums Payout.amount where Payout.status='paid' AND
Partner.metadata.network.creatorId is not null AND completedAt is in
window (since, until]. Tenant scope provided by the caller.

network-client.ts: reportNetworkPayoutsToNetwork(db, tenantId)
- Loads network_membership; bails reason='network_not_configured' if
  not enabled.
- Aggregates the period's total via the helper above, keyed off a
  Config high-water mark (CONFIG_KEYS.LastNetworkPayoutsReportedAt).
- POSTs { amountUsd, sinceIso, untilIso } to the Network's
  /vendors/me/report-payouts with the vendor token bearer.
- On 2xx, advances the high-water mark. On Network failure, leaves
  the mark untouched so the next tick re-includes the same payouts;
  the Network's identifier dedups at Stripe.
- On amount=0, advances the mark anyway (don't re-scan empty windows).

scheduler.ts: new cron 'network-payouts-report' at 03:30 UTC daily,
per active tenant via the existing forEachActiveTenant iterator.
Sits alongside usage-report (03:15) so they don't compete for the
same window.

config.ts: CONFIG_KEYS.LastNetworkPayoutsReportedAt added.

Tests (src/__tests__/network-payouts-report.test.ts, 8 cases) spin up
a local HTTP receiver acting as the Network endpoint. Cover:
- aggregateNetworkOriginatedPayouts:
  - sums only paid payouts on Network-flagged Partners
  - excludes pending; excludes payouts on direct partners
  - respects (since, until] bounds
- reportNetworkPayoutsToNetwork:
  - skips when membership not enabled
  - zero amount: skip + advance mark
  - happy path: right total, right bearer, mark advances
  - Network 5xx: NO mark advance (so retry catches it)
  - Subsequent run only includes new payouts (mark works)

90/90 vendor-side tests still pass.

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last gap in the Network billing flow: vendor admins now
have a UI to subscribe / manage / view status. Previously they'd have
needed to curl /api/vendors/me/billing/checkout directly.

Backend (apps/api):
- network-client.ts: extends networkProxy with getBilling,
  createCheckout, openPortal — same proxy pattern as the existing
  Network admin proxies (vendor token decrypted server-side, body
  forwarded through). Returns the typed Billing payload + Stripe
  URL responses.
- routes/settings.ts: GET /admin/network/billing,
  POST /admin/network/billing/checkout (validates successUrl +
  cancelUrl), POST /admin/network/billing/portal (validates
  returnUrl). Each is a thin wrapper around networkProxy.* with
  the standard NetworkProxyError → HTTP status mapping.

Portal (apps/portal):
- pages/admin/NetworkBilling.tsx: branches on bundledWithMainPlan.
  Hosted vendors see a "bundled with Flex/Revshare" panel.
  Self-hosted see a status card (subscription state, current
  period end, $29 + 3% recap) plus EITHER a Subscribe panel
  (opens Stripe Checkout) or a Manage panel (opens Stripe Customer
  Portal). Subscribe flow surfaces specific errors when the Network
  deploy is missing STRIPE_SECRET_KEY / NETWORK_PRICE_ID /
  NETWORK_USAGE_PRICE_ID so the operator (and the vendor) know
  what's not configured.
- App.tsx: route + new "Billing" nav item in the Network section
  (uses the existing CreditCard lucide icon).

Tests (network-billing-proxy.test.ts, 9 cases): mocked HTTP receiver
acting as the Network's billing endpoints. Covers the happy path
for all three routes, body validation, missing-Network-config 503,
admin auth gate, and Network 4xx/5xx surface as 400/503.

99/99 vendor-side tests pass; portal builds clean (322 KB JS, 93 KB
gzip).

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tnerships, etc.)

Creators no longer have a separate Network portal — their home is the
vendor instance they signed up at. This adds the partner-side surfaces
that used to live at network.openpartner.dev directly into the vendor
portal so a creator on Vendor A can browse and apply to programs at
Vendors B/C/D, see all their cross-network partnerships, and edit a
single Network-wide profile.

API
- partnerProxy: server-side helpers that hit Network's existing
  /offerings + /creators/me/* routes using the vendor's encrypted
  vendorToken plus an x-act-as-vendor-partner header carrying the
  partner's vendor-local id. Network resolves the Creator via
  VendorAffiliation(vendorId, vendorPartnerId), so the same handlers
  serve both browser-creator and proxied-from-vendor cases.
- /api/network/{discover,offerings/:id,vendors/:id} (open to anyone signed in)
- /api/network/me/{affiliations,affiliations/:id/earnings,requests,
  requests/:id/cancel,profile} (partner-role only)

Portal
- pages/partner/{Discover,OfferingDetail,VendorDetail,MyAffiliations,
  MyRequests,MyProfile} matching openpartner UI patterns (Page/Card/
  Stat/StatusPill/Button).
- Sidebar gains a Network section for partners with Discover programs,
  My partnerships, My applications, Network profile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… strip

The multi-tenant build was 90% backend — `/install/status` correctly
returned `{ needsSetup: false, reason: 'multi_tenant' }`, but the
portal SPA had no routes to handle it (everything redirected to
/install) and the backend `tenantMiddleware` resolved tenant context
without stripping `/t/<slug>` from req.url, so downstream routers
mounted at root never matched. End result: app.openpartner.dev/install
loop, no way in.

Backend
- tenantMiddleware now strips /t/<slug> (and /api/t/<slug>) from
  req.url after resolution, so the existing partnersRouter.get('/...')
  registrations match cleanly.
- buildMagicLinkUrl(token, tenantSlug?) — sign-in / invite emails drop
  recipients onto /t/<slug>/auth/magic so the SPA's tenant-aware api
  client scopes the verify call. All 5 callers updated to pass slug.

Portal
- Multi-tenant routing in App.tsx based on install-status.reason: '/'
  → LandingPage, '/signup' → SignupPage, '/t/:slug/*' → Shell.
- api.ts derives tenant slug from window.location.pathname and
  prepends /t/<slug> to every fetch — single-tenant unaffected.
- NavItem uses useTenantBase() so absolute paths like '/links' route
  to '/t/<slug>/links' under multi-tenant. Sign-out + Shell login
  redirect also tenant-scoped.
- Brand: real logo-mark-green.svg in Logo() (was a gradient 'O'
  placeholder); favicons + apple-touch-icon + og-default wired in
  index.html.

Operator: still needs to set OPENPARTNER_TENANCY=multi and
DATABASE_URL_APP on App Platform for the deploy to actually run in
multi mode with RLS enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Landing on app.openpartner.dev now offers two paths from day one:

- Vendors → /signup → create a tenant + admin account (existing)
- Creators → /creator/signup → create a Network Creator account

Creators get their own auth (magic-link via Network), their own Shell
at /creator/* with Discover, My partnerships, My applications, Profile.
A creator's identity is platform-wide; when they apply to a vendor's
program and the vendor approves, the existing federation flow creates
a Partner row in that vendor's tenant linked back to the same Creator.

API
- /api/creator-api/* reverse-proxies a fixed allowlist of Network
  endpoints (creator auth, /creators/me/*, /offerings, /vendors/:id,
  /offerings/:id/apply). Cookies pass through both directions, so the
  Network's `op_network_creator_session` cookie ends up scoped to
  app.openpartner.dev (Network sets it without a Domain, so the
  browser scopes it to whoever responded).
- Mounted before tenantMiddleware so it works at the bare host with
  no /t/<slug>/ prefix.

Portal
- pages/creator/{Discover,OfferingDetail,VendorDetail,MyAffiliations,
  MyRequests,MyProfile,Signup,Signin,MagicLanding,Shell}
- creator-api.ts is a thin fetch helper for /api/creator-api/* with
  cookie credentials and structured ApiError.
- Landing gains side-by-side Vendor + Creator CTA cards plus a
  "Creator sign in" link in the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brands is the term we want for the merchant side; "vendor"/"program"
was internal language leaking into the UI. Three call sites:
Landing CtaCard, Signup AuthFrame title, and the creator-signup
cross-link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Landing now mirrors openpartner.dev's tone — brand+creator parity, no
engineering jargon, the same headline ("Launch a partner program. Grow
it with the Network.") and the same value-prop bullets. Two equal-
weight audience cards driving to /signup and /creator/signup.

Sign-in is unified at /signin: one email field, no "are you a brand or
a creator" picker. Backend looks up Admin rows across all tenants and
the Network Creator by email; emails magic links for whichever match.
Always 200 silently to avoid email enumeration.

- POST /api/signin (multi-tenant only) — admin lookup across tenants +
  Network creator forward, both best-effort.
- /signin SPA page with the standard "check your inbox" success state.
- Landing header: "Sign in" link replaces the creator-only signin
  link; "Already have an account? Sign in" CTA below the audience
  cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modern browsers compile the HTML pattern attribute with the v flag,
which rejects bare - between character class atoms. Both signup forms
were using [a-z0-9-]/[a-zA-Z0-9_-] which the v engine flags as
"Invalid character in character class". Escape the hyphen to keep the
intended literal-dash semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related browser-side cookie bugs in the creator-portal proxy:

1. Node fetch comma-joins multiple Set-Cookie headers when you read
   them via .get('set-cookie'), and the browser then can't parse the
   resulting blob. Use getSetCookie() so each cookie ships as its own
   header.

2. Cloudflare in front of network.openpartner.dev injects __cf_bm
   with Domain=network.openpartner.dev. Relaying that to
   app.openpartner.dev landed both Set-Cookie values mashed together
   in one header, which confused the browser enough that the real
   op_network_creator_session cookie wasn't stored at all (next
   request only carried __cf_bm). Filter Cloudflare cookies out and
   strip Domain= on whatever we do forward so it scopes to
   app.openpartner.dev cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Programs (Network offerings) are configured later from admin → Network
→ Offerings. Signup just provisions the brand's tenant + first admin.
Adjust the labels accordingly:

- Subtitle: "Create your brand account…"
- "Program name" → "Brand name"
- Submit: "Create program" → "Create account"
- Success: "Program created." → "Account created."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
keithfawcett and others added 4 commits July 12, 2026 14:38
)

* feat(review): brand approval gate + platform-ops console (anti-spam)

New brands (a Brand is a Tenant) now land in a review queue instead of
going live instantly, closing the self-serve spam/phishing vector.

Data model (migrations + types):
- Tenant.approvalStatus (pending|approved|rejected) + approvalReason,
  reviewedAt, reviewedByEmail. Backfills existing/self-host tenants to
  'approved'; default stays 'approved' so only signup opts into 'pending'.
- PlatformAdminSession, SignupBlocklist, PlatformAuditLog (platform-scoped,
  privileged-pool only, outside per-tenant RLS).

Gating:
- Router serves clicks only for approved brands (no attribution collected
  while pending).
- approval-gate.ts 403s the go-live write actions (invite partner, creator
  self-signup, roster import, marketplace publish, creator-app approval)
  while pending; config + reads stay open so a brand can set up. Runs
  before the trial gate so a pending brand hears "under review", not "pick
  a plan".
- Public signup + authenticated add-brand default to pending (add-brand
  auto-approves when the operator already runs an approved brand). Signup
  blocklist refuses banned emails/domains before a Tenant is created.

Platform-ops console API (before tenantMiddleware, privileged pool):
- Wires up the dormant PlatformAdmin table: magic-link operator auth
  (bootstrap via PLATFORM_ADMIN_EMAILS), sessions, requirePlatformAdmin.
- Brand review: list queue, approve, reject (silent by default; optional
  brand notice + ban email/domain), reinstate. Blocklist + audit CRUD.
  Writes require role='admin'; 'support' is read-only.

Notifications: ops "needs review" + decision emails to PLATFORM_OPS_EMAIL
(platform transport); brand approved/rejected emails (rejection opt-in).
Brand's own portal learns its state via approvalStatus on /branding.

Tests: pure unit (route match + email parse) + DB-backed e2e (signup→
pending, blocklist, operator auth+verify, approve/reject+ban, gate lift,
support read-only). Full suite 241/241 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(portal): platform-ops brand-review console + pending-review banner

Adds the operator-facing UI for the brand approval feature (backend in the
prior commit). All under apps/portal/src/pages/platform/, platform-level so
it uses a raw papi() fetch helper (credentials:'include'), never the
tenant-scoped api().

- /platform/login + /platform/auth: operator magic-link sign-in.
- /platform/* PlatformConsole: guards on /platform-admin/me, top nav
  (Brands / Blocklist / Audit), operator identity + sign out.
- Brands: pending/approved/rejected/all filter; approve, reject, remove
  (retroactive), reinstate. Reject/remove form defaults to a SILENT
  rejection with optional brand notice + ban email/domain, and copy that
  steers spam toward silent-reject-and-ban.
- Blocklist + Audit pages. Read-only 'support' operators see lists without
  write controls.
- Shell(): dismissible "under review" banner for a pending brand's own
  admin, reading approvalStatus off /branding via usePublicBrand().

Portal typechecks + builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#55)

Brand-level info alone doesn't reveal a phishing/spam program — the tell is
Program.destinationUrl (the cloaked/scam landing page). This surfaces each
brand's programs to reviewers and adds a per-program takedown that leaves
the brand on the platform.

- Program.blockedAt / blockedReason / blockedByEmail (migration): reversible
  operator takedown, distinct from the scheduled endsAt wind-down.
- Router: always resolves the Program (even for per-Link deep links) and
  410s a blocked program's links — they stop redirecting immediately,
  independent of the brand's approvalStatus.
- Ops API: GET /platform-admin/brands/:id/programs (destinationUrl +
  link count + block state); POST programs/:id/block {reason} + /unblock;
  audited. Brands list now carries programCount + blockedProgramCount.
- Portal: expandable Programs section per brand card showing the
  destination as plain text (never a live link) with admin-only Block/
  Unblock.

Tests: list + block/unblock + support-read-only. Full API suite 243/243.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rejecting a brand or blocking a program took it dark LOCALLY (tenant
suspended, links stop redirecting) but left its listing live on the
Network marketplace, still taking creator applications. A rejected
phishing brand stayed publicly listed.

Offerings live on the Network — a separate service. The Network already
filters `Offering.published = true AND Vendor.status = 'active'` on both
the marketplace list and the offering detail page; it was simply never
told. Nothing in the reject/block path crossed the federation boundary.

- network-client: adminSuspendVendor / adminReactivateVendor, authed with
  NETWORK_ADMIN_API_KEY (mirrors adminRestoreVendor).
- rejectBrand → suspends the brand's Vendor on the Network, which pulls
  every offering it published off the marketplace at once (and kills its
  vendorToken). approveBrand/reinstate → reactivates it.
- blockProgram → unpublishes that program's offering (published=false);
  unblockProgram → re-lists it only if the brand had shareOnNetwork set.

All propagation is best-effort: a Network outage must not block a local
takedown. Failures are logged and re-running the action retries.

Tests: reject → asserts POST /admin/vendors/:id/suspend (with the admin
bearer + reason) and reinstate → /reactivate; program block → asserts the
offering is PATCHed published=false. Full API suite 245/245.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rm (#57)

Completes the partner-moderation half. Creators live on the Network (one
identity across every brand), so blocking is a NETWORK-level act — distinct
from a brand revoking a partner from its own roster (POST /partners/:id/revoke).
These routes proxy the Network's ADMIN_API_KEY-gated /admin/creators API.

- network-client: adminListCreators / adminBlockCreator / adminUnblockCreator
  (callNetworkAdmin generalized to GET+POST); platformNetworkUrl() since
  creator moderation is cross-tenant and uses the env origin, not a brand's
  membership Config.
- platform-admin: GET /platform-admin/creators, POST /creators/:id/block
  {reason} + /unblock. Audited. Writes require role='admin'. 503 when
  NETWORK_URL is unset, 502 (with detail) when the Network call fails.
- Portal: /platform Creators tab — All / Incomplete / Blocked filters,
  avatar + handle + brands/platforms counts, the "hidden from discovery"
  flag with the exact missing fields (from the Network's completeness gate),
  and admin-only Block/Unblock.

Tests: list + block + unblock assert the Network admin calls fire with the
admin bearer and the operator's email; support operators are read-only.
Full API suite 247/247; portal typechecks + builds.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
keithfawcett and others added 25 commits August 13, 2026 16:50
Node 20 reached end-of-life in March 2026 and no longer gets security
patches. Bump the runtime floor to Node 22 (current LTS) across every pin:
Docker build args (api/portal/router), .nvmrc, root engines (>=22), and the
CI + npm-publish workflow node-version. No dependency or lockfile change; no
source change. Surfaced by Codex during the SSRF hardening review.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
PartnerProgram (formerly PartnerCampaign) was created after the global
RLS/grant migrations and never got its own tenant_isolation policy or
openpartner_app DML grant — every other tenant table created since
(PartnerPostback, etc.) does. The rename migration only assumed a policy
existed. Prod runs the app role (DATABASE_URL_APP + OPENPARTNER_APP_DB_PASSWORD
set, OPENPARTNER_TENANCY=multi), so PartnerProgram queries would fail
permission-denied when the partner↔program feature is exercised; on a
privileged-pool deploy the absent policy means no tenant filter.

Adds enable/force RLS + tenant_isolation policy + idempotent app-role grant,
mirroring 20260613000000_partner_postback.ts. Verified locally: relrowsecurity
+ relforcerowsecurity = t, policy present (ALL, USING+WITH CHECK), grant
present. NOTE: prod migrations are manual — run `pnpm migrate` against prod
after merge.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…gap) (#61)

/coupons/redeem was gated only by requireAuth + grantScope('events:write').
grantScope is a pass-through for any non-scoped principal, so a logged-in
PARTNER (session or partner API key) reached the handler and could forge
conversions — synthesizing Click+Identity+Event and minting commissions
crediting themselves. /clicks had the same shape (grantScope('clicks:write')
with no role gate); lower impact since the handler derives partner/program
from the Link row, but it's still a server-to-server ingest route that must
not accept partner principals.

Both now match the sibling /attribution/events route: requireAuth →
grantScope → requireAdmin. Scoped federation keys (clicks:write /
events:write) still work — grantScope rewrites them to admin before
requireAdmin runs. Added regression tests asserting a partner key gets 403
while admin/scoped reaches the handler.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…64)

* feat(security): SSRF guard for outbound webhooks + partner postbacks

Both server-side fetch sinks took attacker-influenced URLs with only
syntactic (z.string().url()) validation: webhook endpoint URLs (tenant
admin) and partner postback URLs (partner — lower privilege), then fetched
them with no host/IP restriction and default redirect-following. Neither is
fully blind — the webhook test route returns status/error and postbacks
store lastStatus/lastError — so both were usable as internal port-scan /
metadata oracles (169.254.169.254, RFC1918, loopback, …).

Adds a shared block-by-default guard (outbound-guard.ts) applied at both
sinks:
- http/https only, no embedded credentials, port on the policy allowlist.
- Resolve EVERY address; reject unless each is globally-routable public
  unicast (ipaddr.js). Rejects IPv4-mapped IPv6, and octal/hex/decimal IPv4
  encodings (WHATWG URL normalizes them before classification). Special-use
  hostnames (localhost/.local/.internal/.home.arpa/single-label) refused.
- DNS-pin the validated address into a per-request undici dispatcher's
  connect.lookup so DNS can't rebind between check and connect; the original
  URL still supplies Host/SNI/cert identity.
- redirect: 'manual', zero hops — a 3xx is a non-delivery.
Blocked destinations throw OutboundBlockedError with a stable code (safe to
store in partner-visible lastError), recorded as a failed delivery.

Policy is deployment-scoped: hosted locks ports to 80/443; selfhost+single
leaves ports open and honors OPENPARTNER_OUTBOUND_ALLOW_PRIVATE_CIDRS (an
escape hatch that FAILS STARTUP if set in any other mode). Checked prod
first: all 9 configured webhooks are public HTTPS/443 and there are 0
postbacks, so block-by-default breaks nothing live.

Design reviewed adversarially with Codex. Deps: undici + ipaddr.js. Tests:
outbound-guard.test.ts (schemes/ports/creds/IP classes/encodings/hostnames/
escape hatch + a live-server check that a blocked host never gets a socket);
existing webhook + postback integration tests set the selfhost escape hatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): address SSRF review — partner postbacks public-only, fail-fast boot

Two findings from Codex review of the SSRF guard:

1. (HIGH-when-enabled) The private-CIDR escape hatch was shared with
   partner-controlled postbacks, so an operator opening an internal range
   for an admin webhook also handed every partner a scan/request oracle
   across it. safeFetch now takes a trust level: partner postbacks use a
   hardened policy (escape hatch stripped, ports never unrestricted — 80/443
   fallback); admin webhooks keep the full deployment policy. Callers pass
   trust: 'admin' (webhook-dispatcher) / 'partner' (partner-postback).

2. (LOW) "Refuses to boot on bad config" was documented but not enforced —
   the policy built lazily on first delivery. server.ts now calls
   outboundPolicy() before listen(), so a misconfigured OPENPARTNER_OUTBOUND_*
   fails startup.

Tests: new guard case asserting admin honors the escape hatch while partner
does not (and never opens a socket); the postback integration test uses the
new __setOutboundPolicyForTests override for its localhost receiver (the
security split itself is covered in outbound-guard.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(billing): make usage reporting exactly-once (outbox)

The Stripe meter-event identifier was tied to a per-run rangeEnd (new Date()),
while the high-water mark only advances after Stripe accepts. A crash (or
timeout) after Stripe accepted but before the mark advanced meant the next
run aggregated an OVERLAPPING window with a NEW identifier — double-billing
the tenant for the same GMV.

Freeze {rangeStart, rangeEnd, amount, identifier} to a pending Config row
BEFORE the Stripe call; clear it only after the mark advances. On the next
run, a surviving pending row is re-sent VERBATIM (same identifier ⇒ Stripe's
Meter Events API dedupes) rather than re-aggregating. No schema change
(uses the per-tenant Config table); added deleteConfig helper.

Tests: a clean run freezes-then-clears; a simulated crash after the meter
call leaves the pending row, and the retry re-sends the SAME identifier +
amount (ignoring GMV accrued in between), proving exactly-once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(billing): usage reporting — concurrency dedupe + stale-pending guard

Two review findings on the exactly-once outbox:

- (concurrency) The meter-event identifier was keyed on a per-run rangeEnd,
  so two runs racing the same period (scheduler + manual /billing/report-usage,
  or two replicas) each froze a different rangeEnd/identifier and submitted the
  same GMV twice. Key the identifier on the period START (the high-water mark)
  instead — identical for concurrent same-period runs, so Stripe dedupes;
  distinct periods still get distinct keys.

- (stale pending) A pending row left by a multi-week outage could never be
  resent (Stripe rejects meter events past its ~35-day timestamp window),
  wedging the tenant's reporting forever, and past Stripe's ≥24h dedup window a
  resend risks double-billing. If the frozen rangeEnd is >30d old, abandon it:
  advance the mark and ALERT for manual reconciliation (a rare, loud
  under-report beats a silent double-charge or a permanent wedge).

Tests: identifier carries the period-start suffix (not rangeEnd); a 40-day-old
pending row is abandoned (no Stripe call, mark advanced, pending cleared).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…uard, nightly Stripe reconcile (#60)

* fix(billing): catch tenant cancellations — ops alerts, end-customer guard, nightly Stripe reconcile

A hosted tenant cancelled via the Stripe Customer Portal on Jul 17 and we
never noticed: the prod webhook endpoint wasn't subscribed to
customer.subscription.updated/deleted (docs listed neither), so the local
billing mirror, white-label entitlement, and custom-domain routing all
stayed live, and nobody was emailed. Three fixes:

- Ops notifications (PLATFORM_OPS_EMAIL) on tenant billing lifecycle:
  cancellation scheduled/resumed (detected via previous_attributes on
  subscription.updated), subscription ended, and dunning failures on the
  tenant's own invoices. Shared sendOpsEmail extracted from brand-review.

- Guard the webhook's tenant-billing paths on the event's customer being
  the tenant's OWN billing customer. Previously a merchant end-customer's
  subscription.updated/deleted (resolved via the Identity chain) would
  clobber Tenant.stripeSubscriptionId and could disable white-label.

- Nightly billing-subscription-reconcile job (04:25 UTC): polls Stripe for
  every tenant that locally claims a subscription and heals missed-webhook
  drift — ended subs clear the mirror + revoke white-label/custom-domain
  exactly like the deleted webhook, live subs refresh HostedBillingState,
  newly discovered cancel_at_period_end alerts once (Config-keyed dedupe).

Docs: deploy-production.md now lists subscription.updated/deleted as
required events for Destination A and explains why they're load-bearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(billing): harden the cancellation-sync paths per adversarial review

Codex review of PR #60 surfaced five real gaps; all fixed:

- resource_missing NEVER heals: a canceled sub stays retrievable forever,
  so "missing" means the key can't see the id (wrong account / test-mode
  key). Healing on it would let one config mistake mass-revoke every live
  tenant's white-label + custom domain. Now reported as an error instead.

- Stale-subscription safety: the deleted webhook and the nightly reconcile
  clear the pointer with ONE conditional UPDATE keyed on the exact sub id
  the event/poll referred to — a late retry for a since-replaced sub, or a
  resubscribe racing the poll, matches 0 rows and is skipped. The updated
  webhook likewise ignores subs that aren't the tenant's current one and
  never adopts a terminal sub into an empty pointer. Own-billing detection
  gains a sub-id fallback for tenants with no persisted stripeCustomerId.

- Webhook-retry email dedupe: the cancel-notice Config marker now guards
  the webhook path too (not just the reconcile), deleted retries are
  silenced by the conditional clear, and dunning alerts dedupe per
  (invoice, attempt) so redeliveries stay quiet while every new collection
  attempt still notifies.

- cancel_at coverage: cancellations scheduled via a bare cancel_at
  (dashboard date / Subscription Schedules) now notify, not just the
  Customer Portal's cancel_at_period_end.

- Notify-then-mark: sendOpsEmail reports transport success and the
  cancel-notice marker is only written after a successful send, so an SMTP
  outage retries next pass instead of losing the notice forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(billing): close null-pointer resurrection in the subscription.updated guard

Open review found a hole in the PR #60 stale-sub guard: it only rejected
TERMINAL snapshots when the pointer was null, so a late/resent/out-of-order
ACTIVE subscription.updated arriving after subscription.deleted (pointer
already cleared) passed both guards and re-persisted the old sub id,
resurrecting the canceled subscription and re-applying white-label. Stripe
guarantees no event ordering, so this reordering is real.

An updated may now touch billing state only when it refers to the tenant's
CURRENT subscription (pointer non-null AND equal to sub.id). A null pointer
is re-established solely by checkout.session.completed on a real
(re)subscribe, so an updated never legitimately adopts one from null. Added
a test for the active-after-delete ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…in prod (#63)

* fix(commissions): repair auto-approve SQL — was failing for every tenant in prod

The nightly commission-auto-approve job has been throwing in prod for all
tenants: `invalid reference to FROM-clause entry for table "c"`. The
UPDATE...FROM referenced the update target `c` inside a FROM-clause LEFT
JOIN (`pc."partnerId" = c."partnerId"`), which Postgres forbids. Net effect:
matured accrued commissions never auto-approved, so they never progressed to
payout — platform-wide, every night.

Fix: key the PartnerCommission join on `a."partnerId"` (Attribution is
already a legal FROM entry; a commission's partnerId equals its attribution's
partnerId by construction, and c."attributionId" = a.id pairs them). No
behavior change beyond the query now executing.

Adds commission-auto-approve.test.ts (5 cases incl. partner-snapshot-holdback
preference) that runs the real query against Postgres — there was no test
exercising this SQL, which is how it reached prod.

Discovered while checking prod logs (doctl) for an unrelated RLS finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(commissions): defensive partnerId match in auto-approve join

Per review: also require c."partnerId" = a."partnerId" in the auto-approve
UPDATE. It's equal by construction (a commission's partner comes from its
attribution), but the DB doesn't constrain it, so this guards against any
future data-integrity drift approving a mismatched row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…out guard) (#71)

reconcileIntent listed only the first 100 transfers in a batch's
transfer_group. A batch with >100 transfers whose match sits on a later page
looked "absent", so the intent was reset to pending and RE-POSTED — a
duplicate partner transfer. Follow has_more to exhaustion (stop early on
match). Isolated, Codex-tagged safe subfix of the funding-race set (#12);
the flag stays off.

Test: an ambiguous intent whose landed transfer is on page 2 now reconciles
(confirmed, no re-POST) and the listing pages twice.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix: four safe audit findings — entitlement, attribution, storage

Mechanical, low-risk fixes from the audit (Codex-verified as safe-to-fix):

- Enterprise self-assign (billing.ts): POST /billing/plan accepted
  plan='enterprise', which counts as active + enables white-label with no
  Stripe subscription. Restrict the setter schema to flex/revshare (signup
  already excludes enterprise).
- Delinquent keeps white-label (white-label.ts): isWhiteLabelEntitled keyed
  only on a non-null subscription id, so an unpaid/paused/canceled sub kept
  white-label + custom domain live for free. Route it through hasActivePlan,
  which already rejects delinquent statuses (past_due still entitled — Stripe
  is dunning).
- Future-click attribution (attribution.ts): the window check only rejected
  ages > window, so a click that happened AFTER the event (negative age)
  could attribute an earlier conversion during backlog/backdated processing.
  Reject ageMs < 0 too.
- Upload ENOENT (storage.ts): the fs backend created only the storage root,
  so a nested key (tenants/<id>/logos/...) failed on a fresh self-host
  install. mkdir the full parent path.

Tests: delinquent-status white-label cases, a nested-key storage write, the
enterprise-rejection route check, and a negative-age attribution case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(attribution): give the negative-age check a clock-skew grace

CI on this branch was red — 7 tests across compound-rules.integration and
stripe-webhook. Two different causes hid behind one symptom.

The real one: `ageMs < 0` is too strict to be correct. Event.ts comes from
`new Date(event.created * 1000)` and Stripe stamps `created` in whole
SECONDS, so a conversion in the same second as its click truncates to up to
999ms BEFORE it. The clock stamping Click.ts is also not the clock stamping
the event. Both cases are ordinary, and a strict check dropped the
attribution silently — no error, just a partner not getting paid. Rejecting
a later click attributing an earlier conversion is still right; that case is
hours or months off, so a 5-minute grace separates them cleanly.

The other: the compound-rules fixtures seeded the click at NOW against
events backdated to January. That is exactly what the guard targets, so
they were correctly rejected. Fixed the fixtures to put the click just
before the first event — which also means the 60d window is genuinely
exercised there for the first time, since the negative age used to
short-circuit the check.

Added the companion to #66's own negative-age test: an event 2s before its
click must still attribute. Verified it fails when the grace is reverted.

Full API suite green (37 files / 256 tests), typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Metering counted only invoice_paid + subscription_created toward attributed
GMV, so a merchant reporting revenue under a CUSTOM event type accrued partner
commissions but escaped the platform % (revshare 3% / flex 1.5%). Make the
billable set an explicit, documented semantic: default to the two Stripe-
native types, extend via OPENPARTNER_BILLABLE_EVENT_TYPES_EXTRA (comma-
separated) for operator-known custom revenue types. aggregateAttributedGmv
now takes the event-type set (default = configured) so it's explicit at the
call site and testable.

Chosen (with the user) as the safe option: it changes NO existing invoice and
locks in no possibly-wrong auto-semantic — no GMV definition is fully
dodge-proof on self-reported data (percent-base just moves the dodge to fixed
commissions; count-all over-bills mislabeled values). Per-tenant granularity
+ auto-detection are follow-ups.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#67)

* fix(refunds): stopgap — only auto-reverse commissions on a FULL refund

charge.refunded reversed 100% of an invoice's accrued/approved commissions
on ANY refund, and charge.amount_refunded is cumulative — so a $1 refund on
a $100 order wiped the entire commission, and successive partials mis-stated
the reversal. Codex-recommended safe stopgap: only auto-reverse when the
refund is full (amount_refunded >= amount). Partial refunds are still
recorded as a corrective 'refund' event and flagged with
partialRefundReversalSkipped for manual handling.

This is a stopgap, not the end state: proportional clawback (incremental
refund deltas + immutable CommissionAdjustment rows) is the real fix and a
separate ledger change. The stopgap strictly prevents the current
partner-harming over-clawback.

Tests: existing full-refund cases now carry charge.amount (so they remain
full and still reverse); new case asserts a partial refund leaves
commissions accrued and sets the skip flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(refunds): measure "full refund" against amount_captured, not amount

The stopgap compared amount_refunded to charge.amount (the INTENDED amount),
but Stripe's amount_captured is what was actually collected and can be
smaller after a partial capture. A $100 auth captured for $60 then fully
refunded ($60) has amount_refunded=6000 < amount=10000, so it was wrongly
classified partial → commissions left payable on a fully-refunded sale. Base
the full-refund test on amount_captured (fallback to amount when absent).

Test: a partially-captured ($60 of $100) charge, fully refunded, now reverses
commissions (fullRefund=true).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…fused-deputy) (#68)

* fix(security): bind Stripe webhook secrets to their event family (confused-deputy)

/webhooks/stripe verified against any secret in one combined
STRIPE_WEBHOOK_SECRET and trusted openpartner_tenant_id metadata to establish
the tenant — so a holder of ANY configured secret could forge an event from
the OTHER destination (e.g. a Connect secret carrying a fabricated
checkout.session.completed / invoice.paid with attacker-set tenant metadata)
and mint subscriptions, commissions, or funding state for any tenant. Not
outsider-exploitable today (no merchant holds a verifying secret), but a
latent cross-tenant confused-deputy.

Split the signing secrets by Stripe "Event destination":
- STRIPE_WEBHOOK_SECRET_PLATFORM → platform-account events (checkout.*,
  customer.*, invoice.*, charge.*, payment_intent.*)
- STRIPE_WEBHOOK_SECRET_CONNECT  → connected-account events (account.updated,
  transfer.*)
Verification records which family the matching secret belongs to; an event
whose type doesn't match its verifying family is rejected
(secret_family_mismatch) before any funding/tenant processing. Connect events
now resolve the tenant from the connected-account id (authoritative), not
attacker-influenceable object metadata.

Back-compat: the legacy combined STRIPE_WEBHOOK_SECRET still works (verifies
both families, enforcement OFF) with a startup deprecation warning, so
existing prod deploys are unchanged until they adopt the split vars. Docs +
.env.example updated with the migration.

Note: securely supporting merchant-pointed ("Rewardful") webhooks would need
a THIRD, per-merchant secret family scoped to attribution-only events — out
of scope here and still unsupported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): close two review findings in the webhook-trust change

Codex review of the branch found:

1. (CRITICAL) account.updated selected the partner to update from
   account.metadata.openpartner_partner_id — a Standard account can
   influence its own metadata, so partner A setting it to B's id would
   re-point B's stripeConnectAccountId and hijack B's payouts. The
   partner↔account link is always established server-side at /connect/start
   (accounts.create → persist the id), so resolve the target (and the tenant)
   by the connected-account id ONLY and drop the metadata path entirely.

2. (HIGH) transfer.updated/transfer.reversed were classified as
   connected-account (Connect) events, but we create Connect transfers with
   the PLATFORM key, so Stripe fires those on the platform account — they
   arrive on Destination A. As connect-family they'd be rejected
   secret_family_mismatch (breaking funding reversal handling) once the split
   secrets are adopted. Moved them to PLATFORM_EVENT_TYPES; only
   account.updated remains connect. Deploy doc updated (transfer.* under
   Destination A).

Tests: forged-metadata account.updated leaves the victim's account untouched;
transfer.reversed passes on the platform secret and is rejected on the connect
secret.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#12) (#75)

* fix(funding): close the three pipeline races + a live-Stripe backstop (#12)

HOSTED_FUNDING_ENABLED is OFF, so all of this is latent — but every one of
these costs the brand real money the day the flag flips, and a money state
machine is not something to patch piecemeal. This is the coherent pass the
handoff asked for, with the staging matrix extended to match.

1. AMBIGUOUS PaymentIntent CREATE re-created instead of searching.
   A create whose response was lost (timeout, crash) left the batch
   funding_failed with no PI stamped — even though Stripe may well have
   made a real ACH debit. Inside Stripe's ~24h idempotency window the
   frozen key `fbpi:<batchId>` replays harmlessly; PAST it the retry
   created a SECOND PaymentIntent, i.e. debited the brand twice.
   Now: once a batch has attempted at all, the retry SEARCHES by our
   metadata stamp and adopts what it finds (routing on the PI's actual
   status), and only creates when Stripe confirms there is nothing.
   Also, a create that fails *with* an intent attached (declines) now
   records that id, so the retry confirms it instead of making another.

2. RELEASE vs IN-FLIGHT CREATE.
   The PI id is only stamped when create returns. A release landing in
   that window saw no PI, so it skipped terminalization and freed the
   allocations — collecting money for commissions already back in the
   pool. Fixed from both sides:
     - the stamp is now status-predicated; losing that CAS means a
       release took the batch, so the orphaned PI is canceled. If Stripe
       won't cancel it (already processing), the batch is frozen
       recovery_required with a loud alert rather than left silent.
     - release no longer trusts "no id on the row": it asks Stripe before
       freeing anything, and a search that FAILS returns pi_not_terminal.
       Don't know ⇒ don't free.

3. INBOX CLAIMED BEFORE PROCESSING.
   The inbox row was written before the handler ran, so a crash mid-
   handler made every Stripe redelivery a no-op — the transition it
   carried was lost permanently. The claim is now a LEASE: only a stamped
   outcome is terminal, an unfinished claim older than 5 minutes can be
   taken over by a redelivery, and handlers are CAS-based so a takeover
   racing a live worker degrades to a lost CAS. A claim still unfinished
   an hour later (crashed AND never redelivered) is alerted by the daily
   reconcile.

Plus the gap the handoff flagged for confirmation: refunds, disputes and
transfer reversals had NO live-Stripe backstop — the reconcile only looked
at locally-flagged state, which by definition cannot see a lost webhook, so
a missed reversal left a payout recorded paid and its batch unfrozen. The
daily job now sweeps settled money against Stripe (bounded at 50/run, and
it LOGS what it didn't reach rather than looking clean): a refunded or
disputed funding charge freezes the batch exactly as the webhook would, and
a reversed transfer is recorded through the same reversal handler.

19 DB-backed tests with a Stripe mock cover all four, including the two
that can only be seen by interleaving — a release landing mid-create, and a
redelivery arriving after a claim's worker died.

Runbook: docs/payout-funding-staging-runbook.md gains section H with the
eight staging scenarios that prove the Stripe half of these (how to force
each one), a G4 row for the stuck-claim alert, and the four invariants
worth re-reading before anyone touches this pipeline again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): adversarial-review fixes — the inbox fix was incomplete, plus six more

Codex review of #75 refuted all four claims. The most important finding is
that my own inbox fix only moved the failure window rather than closing it.

1. THE INBOX STILL LOST EVENTS. `claimInboxEvent` returned a bare boolean,
   so "already finished" and "another worker holds it" both became
   `inbox_replay` — and the route answered 2xx. A worker that crashed
   mid-handler therefore lost its event whenever Stripe's redelivery
   arrived INSIDE the five-minute lease: the second worker acked it, and
   Stripe never delivered again. Lost forever became lost on a 5-minute
   fuse. The claim now reports claimed/done/held, `held` raises
   InboxEventHeldError, and the webhook route answers 409 so Stripe
   redelivers after the lease expires.

2. THE LEASE HAD NO OWNER. A worker whose lease had been taken over could
   still stamp an outcome onto — or delete — the new owner's claim, since
   both keyed on event id alone. The claim's `processedAt` now doubles as
   an owner token: a takeover swaps it, and stamp/release are scoped to
   the token they were issued.

3. THE REVERSAL SWEEP SKIPPED `partially_reversed` FOREVER. Once a partial
   reversal was recorded, the webhook completing it could be lost and the
   sweep — the only backstop — never looked again. Only a fully `reversed`
   payout is finished.

4. BOTH SWEEP CAPS STARVED. Taking the oldest 50 of an ever-growing list
   meant the same 50 were re-checked nightly while newer batches — the
   only ones that can still change — were never checked at all. Now
   bounded by the 180-day reversal horizon (everything inside it is
   checked) and anything past the cap is REPORTED by id, not just counted.

5. AN ORPHANED PI LEFT ITS ALLOCATIONS FREED. When a release won the race
   and the created PI couldn't be canceled, the batch froze
   recovery_required but its allocations stayed `released` — so the very
   same commissions could be re-batched and the brand charged twice. The
   orphan path now reclaims allocations no newer batch has taken.

6. `release_requested` WAS TERMINAL BY ACCIDENT. A release that stopped on
   a Stripe failure returned 'pi_not_terminal', but no collector state
   matched that status and a second releaseBatch call just lost the CAS —
   so the batch sat forever with its allocations frozen, unalerted. It is
   now a re-entrant source state, the collector resumes it every tick, and
   the daily reconcile alerts on one stuck over a day.

7. PAYMENT-WINS SKIPPED VERIFICATION. Release CASed straight to `funded`
   without stripeChargeId/fundedAt, so the executor froze the batch as
   recovery_required on the next tick. It now goes through
   confirmFundingFromPaymentIntent — the one verified transition — and
   freezes explicitly if verification refuses.

Also: the executor re-reads the batch status before each partner transfer,
so a dispute freezing the batch mid-run actually stops it; and the
CommissionAdjustment clawback insert is serialized under a row lock,
because check-then-insert with no unique constraint could double-record
when a redelivery and the sweep run concurrently.

12 new/updated tests. Runbook gains H9–H12 and the corrected invariants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round-2 review — orphan reclaim was unreachable, plus four more

Second adversarial pass over the fixes. The headline finding is that the
round-1 orphan recovery could not run at all in the case it was written
for.

1. BLOCKER — RECLAIM SAT BEHIND A CALL THAT RAISES. abandonOrphanPaymentIntent
   escalated the batch to recovery_required BEFORE reclaiming its
   allocations. But `funding_disputed`/`recovery_required` are inside the
   "one open batch per tenant/currency" unique index, so moving a released
   batch back into it violates the index whenever a newer batch is already
   open — and casBatch raises rather than returning null. The throw skipped
   the reclaim entirely, leaving a live ACH debit AND freed commissions,
   which is the exact double-charge the path exists to prevent.
   Reclaim now runs first and unconditionally; the escalation is
   best-effort, and the alert names how many allocations a newer batch had
   already taken.

2. THE SAME INDEX BROKE REFUND/DISPUTE HANDLING. `charge.refunded` on a
   settled batch tried to drag it back to funding_disputed and threw the
   webhook (and the sweep) with it. A settled batch has already
   transferred, so freezing buys nothing: non-terminal batches are frozen,
   terminal ones record the clawback and alert. Both the webhook and the
   reconcile sweep now share one function so they cannot drift.

3. THE SWEEP STILL STARVED. Reporting skipped ids made the cap visible but
   didn't change the scheduling — a fixed order plus a fixed prefix
   re-checked the same head every night. Rows are now dealt by a per-day
   hash, so a capped run covers a different slice each day and the whole
   set over time.

4. THE 180-DAY HORIZON WAS WRONG FOR TRANSFERS. Refunds have a deadline;
   transfer reversals do not — Stripe places no age limit on them. The
   horizon now applies only to the charge sweep; the transfer sweep covers
   every confirmed intent, with rotation making that affordable.

5. RESUMING A RELEASE RESET ITS OWN ALERT CLOCK. casBatch bumps updatedAt
   and reconcile decides "stuck" from updatedAt, so a release failing
   every five-minute tick refreshed the 24h alert forever and stayed
   silent. Re-entry no longer rewrites the row.

Also: transfer reversals are paged rather than trusting the ten embedded
in the Transfer object — under-counting them is what decides
partially_reversed vs reversed, and therefore whether the clawback
adjustments get written at all.

5 new tests, including the blocker with a competing newer batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(funding): inject the Stripe client into the reversal pager

The pager I added in f0f7578 reached for the global `requireStripe()`
instead of the client its caller already holds, and carried a `db`
parameter it never used. In tests that means a paged reversal would have
bypassed the injected mock and hit the real client; in production it
happened to be the same object, which is exactly the kind of coincidence
that stops being true later.

`handleTransferReversed` now takes the client explicitly, like every
other Stripe-touching function in this module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round-3 review — the rotation didn't cover, and a clawback could be outrun

Third adversarial pass. The headline is that round-2's "rotation" fix
gave the appearance of coverage without the substance.

1. THE SWEEP STILL DIDN'T COVER EVERYTHING. The per-day hash shuffle
   re-deals independently every day, so it is not a rotation at all: at
   10k rows and 50/run, an individual row had roughly a 40% chance of
   never being checked before ageing out of the horizon. Replaced with a
   sliding WINDOW over a stable ordering — every row is visited within
   ceil(total/limit) days, by construction, with the day number acting as
   the cursor so concurrent workers pick the same slice. The old test
   ("two days differ") passed for the broken version too; the new one
   runs a full cycle and asserts every row was checked.
   Also: `now()` was called per use, so a run spanning a UTC midnight
   could straddle two windows. Captured once.

2. AN OUT-OF-ORDER CLAWBACK COULD BE ACKED, THEN THE BATCH PAID. The
   freeze only accepted payment_processing/funded/transferring. Stripe
   doesn't order webhooks, so a refund arriving while the batch was
   `release_requested` recorded nothing — and confirm.ts explicitly
   allows release_requested → funded, after which the executor pays
   normally. The freeze now accepts every non-terminal state.

3. A TRUNCATED REVERSAL LEDGER WROTE TERMINAL STATE. The 20-page cap
   returned an incomplete list, and payout status was derived from it —
   a fully-reversed transfer could sit at `partially_reversed` forever,
   with every later run re-reading the same truncated prefix. The pager
   now reports completeness and an incomplete read derives NOTHING; it
   alerts for operator reconciliation instead.

4. THE ORPHAN-RECLAIM STUCK STATE WAS INVISIBLE. Reclaiming allocations
   back to `reserved` under a batch that then fails to re-open leaves a
   live allocation owned by a terminal batch: reservation won't re-take
   the commission (live-allocation index) and the invariant loop skips
   released batches, so the partner simply never got paid and nothing
   said so. Reconciliation now scans for live allocations under terminal
   batches and alerts.

5. THE EXECUTOR'S FREEZE CHECK WAS A READ, NOT A GATE. The per-partner
   status re-read added in round 2 is a check; a dispute landing between
   it and the transfer still got paid. The batch status is now verified
   with FOR UPDATE inside the intent-creation transaction, so a frozen
   batch cannot have a new intent committed. (The round-1 test was
   vacuous — it froze the batch before invoking the executor, so the
   query never selected it. Rewritten to freeze mid-run.)

6. A clawback on an already-settled batch was recorded on the row but not
   returned by /billing/funding, so the portal rendered it as a healthy
   "Settled". Surfaced in the API and flagged in the UI.

4 new tests plus two rewritten ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round-4 review — a real cursor, and the executor gate stopped short

Fourth adversarial pass. My sweep-coverage fix failed for the SECOND
time, in a new way, and the round-3 executor gate didn't cover the case
it was written for.

1. HIGH — THE SWEEP WINDOW STARVED UNDER CHURN. `(day % ceil(total/limit))`
   only guarantees coverage for a FROZEN list. Production adds rows daily,
   so `windows` changes, the modular sequence shifts, and specific indices
   are never selected — the review produced a concrete 10k-row schedule
   where a target row is never visited in 180 days and then ages out of
   the horizon. Replaced with a persisted CURSOR: it walks ids in order
   and remembers where it stopped, so churn cannot starve anything and
   rows added behind it are picked up on the wrap. (Two failed attempts at
   this — a hash shuffle, then a count-derived window — both looked like
   rotation without guaranteeing coverage. A cursor is the boring answer.)

2. HIGH — THE EXECUTOR GATE ENDED BEFORE THE TRANSFER. Round 3 put the
   batch check inside the intent-creation transaction, which only runs for
   intents created on that pass: an existing pending/failed/posted intent
   skipped it entirely, and even a new one could have its batch frozen
   between that commit and the POST. There is now a final check
   immediately before `transfers.create`. It cannot be atomic with a
   Stripe call, but it closes the existing-intent hole and narrows the
   rest to the request itself.

3. HIGH — THE TRUNCATION GUARD THREW AWAY GOOD RECORDS. Returning early
   on an incomplete reversal list also discarded the reversals it had
   already fetched — a valid audit trail — and re-fetched the same prefix
   forever. Those rows are now recorded (the unique stripeReversalId makes
   it idempotent); only the DERIVATION of payout status is withheld.

4. LOW — `failureReason` was returned raw to a brand-facing admin UI. It
   carries raw Stripe error text, internal state-machine reasons and
   Stripe object ids (`orphan_payment_intent:pi_…`). The API now returns a
   normalized `needsAttention` flag; operators get detail from the logs.

5. LOW — the orphan-allocation scan pushed one attention id per
   ALLOCATION, so a batch with 100 of them appeared 100 times. Deduped.

Three round-3 tests were named as passing with their fix reverted, and
they did: the freeze test froze the batch BEFORE the executor ran (so the
scan never selected it), and two sweep tests asserted properties the old
broken implementation also had. All three rewritten, plus a new test that
runs the sweep while rows keep arriving — the exact condition the window
version failed.

Runbook: documented the launch gaps this review surfaced that are
deliberately not auto-resolved — no operator recovery transition, the
pre-existing Commission↔Allocation lock-order cycle, and why a batch
frozen while `reserved` means a correlation failure rather than a
routine clawback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round-5 review — the cursor acknowledged work before doing it

Round 5's headline: **Codex could not break the double-charge or
double-pay guarantees** outside the accepted paymentIntents.search
window — the first time a money claim has survived a round. What it did
break was round 4's cursor.

1. HIGH — THE CURSOR ADVANCED BEFORE THE WORK HAPPENED. `sweepSlice`
   persisted the next position at hand-out time, so a crash — or any
   per-row Stripe/DB failure, which is caught and logged — skipped that
   entire slice until the next full wrap. For charges, the row can cross
   the 180-day horizon before the wrap and never be checked again,
   turning a transient failure into a permanently missed clawback. The
   cursor now commits AFTER the slice completes. Re-checking a row after
   a crash is free; skipping one loses money.

2. MEDIUM — THE CURSOR STORE COULD KILL THE WHOLE JOB. `Config.tenantId`
   is an FK to the seeded tenant, so a deployment that deleted that row
   would fail the upsert and take BOTH sweeps down globally. Cursor reads
   and writes now degrade to "sweep from the top" with a log instead of
   throwing — losing the cursor costs repeated work, not correctness.

3. LOW — the dedupe added in round 4 ran only at the final return, so the
   no-Stripe early return still produced one attention id per orphan
   ALLOCATION. Both exits now go through one finalizer.

4. LOW — dropping `failureReason` from /billing/funding was a breaking
   response change that left brand admins with "something is wrong, ask
   someone with log access". Replaced with a normalized `attentionCode`
   from a closed set (refunded / disputed / timed out / authorization
   revoked / needs review), so the UI can say something actionable
   without echoing Stripe object ids or internal state names.

Both round-4 tests that Codex named as surviving their own reversion are
rewritten:

- the churn test asserted only the ORIGINAL rows and passed under the old
  window on most days; it now asserts every row that ever existed;
- the "frozen during the run" test had NO synchronization point (and a
  dead variable hooking nothing). It now seeds two partners and freezes
  from inside the first transfer, so the second one not happening is the
  assertion — a real barrier at the exact production interleaving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(funding): run section H's races against real Stripe test mode

Six of the twelve section-H scenarios now run against the real API —
H1, H5, H6, H7, H8, H9, plus H11 observed incidentally. 21 assertions,
0 failures. H2, H3, H4, H10 and H12 have NOT been run; the runbook says so
rather than implying the section is done.

Adds scripts/staging-funding-races.ts (refuses a live key or a non-local
DATABASE_URL) and documents the one-off ACH fixture: customer + verified
us_bank_account PM + mandate via SetupIntent microdeposits.

The finding worth keeping is about Stripe, not us: paymentIntents.search is
eventually consistent, and a PI is not findable the instant it is created.
The H1/H5 adoption path depends on that search, so a retry firing inside the
indexing window will not find the intent. That makes the frozen idempotency
key the load-bearing defence inside 24h, with search as the fallback past it
— which is what the design already assumed, now confirmed rather than hoped.

H1 itself is sound: the intent was really created, the response really lost,
and the retry adopted the existing PI with create never called again
(asserted by counting calls, not by reading the branch). H5 and H11 turn out
to be one race resolved two ways and both landings were observed across runs.

API suite still green (37 files / 285 tests), typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round 6 — empty search is not absence, reversals stay retryable

Three of the five verified defects; the two sweep ones follow separately.

#75-5. releaseBatch treated an EMPTY paymentIntents.search as proof no PI
exists and freed the allocations. Search is Stripe's Search API and is
explicitly eventually consistent, so a PI created moments earlier is simply
not indexed yet — the release could free commissions while a live PI could
still debit the brand, and those commissions then landed in a new batch and
were charged twice. A thrown lookup already meant "don't know"; an empty one
now means the same and returns pi_not_terminal.

That alone would strand orphan batches, so it is paired with a local fact
that needs no search: the collector's only path to paymentIntents.create is
inside the `invoicing` branch, entered by winning casBatch(reserved →
invoicing). A batch we claim straight out of `reserved` therefore provably
never reached Stripe and releases immediately — no search, no exposure.
Anything past `reserved` holds until a PI is found or a human intervenes.

#75-3. A transfer.reversed arriving before finalization linked the Payout
returned 'transfer_intent_unknown', which the wrapper stamped as terminal
and answered 2xx — so Stripe stopped redelivering and the only notice the
money came back was discarded. It now throws FundingEventNotReadyError,
reusing the existing release-claim-and-rethrow path, so the redelivery finds
a linked payout.

#75-4. Concurrent reversal handlers summed the ledger and wrote the payout
status with no lock, so a late writer with a stale sum could regress
'reversed' to 'partially_reversed' — and 'reversed' is what gates the
clawback adjustments. Now SELECT ... FOR UPDATE before the sum.

On test quality: my first version of the lock test passed with the lock
removed, because the handler still blocks eventually — at its UPDATE.
Blocking was never the property; summing AFTER acquiring is. The test now
has the blocker complete the reversal while holding the lock, so removing
the lock reproduces the exact production symptom (partially_reversed instead
of reversed). All three fixes verified to fail their tests when reverted.

288 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): sweep must not acknowledge failures or order by creation

The last two round-6 defects, both in the reconcile sweep.

#75-1. Per-item Stripe errors are caught and logged inside the loop, then
the cursor committed unconditionally once the loop finished. A failed row
was therefore skipped AND passed over, and on the charge side could age out
of the 180-day horizon before the cursor wrapped back — losing a clawback
silently.

Not fixed by withholding the cursor on failure: one permanently unreadable
Stripe object would pin the cursor and starve everything behind it. Failures
go into a durable retry set in the existing Config sweep state instead, come
back first on the next run, and leave only on success. The cursor is free to
keep moving. The set is capped and overflow is alerted rather than silent.

#75-2. The cursor ordered by id, but ids are assigned at CREATION while rows
join the sweep when they become eligible — a batch when it funds, an intent
when it confirms. A row becoming eligible behind the cursor was only
reachable on a wrap, and with a full slice of newer rows arriving every run
the cursor never wrapped. Now ordered by eligibility time: (fundedAt, id) for
batches, (postedAt, id) for transfers. Both are immutable once set, so a
newly-eligible row is always ahead of the cursor.

On test quality — this took three attempts to get honest. The first version
seeded three rows and passed under BOTH orderings, because with nothing
arriving the cursor wraps and covers everything anyway. The second gave the
late funder a fundedAt in the middle of the seeds, so it was never actually
behind the cursor. Only the third — six rows ahead of it, funding strictly
later, and fresh eligible rows arriving before every run so no wrap can
happen — reproduces the starvation. Both sweep tests verified to fail when
their fix is reverted.

290 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(funding): make section H prove what it claims, and say what it doesn't

Round-6 review of my own script found three ways it could pass while the
property was false.

- H8/H9 called the inbox helpers directly and INFERRED "→409" from our own
  return value. A route regression acknowledging a held event with 2xx would
  have passed while Stripe silently stopped redelivering. It now posts a
  signed event to /webhooks/stripe and asserts the status code.
- H5 treated `requires_payment_method` as terminal. It is not — that intent
  can still be confirmed and take the money — so an implementation that
  freed the allocations while the PI was live could have passed.
- H1 asserted the retry never called `create`. That was true of one run, not
  of the property: the scenario backdates the DB row 25h while the real
  idempotency key is seconds old, so Stripe still replays it and a cold
  search index sends the retry down the re-POST path. One PI still exists,
  which is the property that matters; the route is now reported as a note.
  The genuine post-window path stays uncovered and the runbook says so.

Adds H5b for the round-6 fix itself: with the index deliberately cold, the
release must refuse to free — and the script then confirms the PI it could
not see was genuinely still live, so freeing would have debited the brand
for commissions already back in the pool.

H5's flaky snapshot assertion is replaced by following the batch through to
a terminal state, and a search that never indexes within 180s is reported
loudly rather than quietly passing.

27 assertions, 0 failures against real Stripe test mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(funding): operator disposition for a batch stuck in release_requested

The counterpart to "an empty search is not proof of absence". That change
closed a double-charge, but it also means a batch whose PaymentIntent
genuinely never existed has no automatic way out — it sits release_requested
with allocations reserved until the daily stuck-release alert. A hold with
no release is a leak, so this is the way out.

forceReleaseBatch is the operator asserting what the system deliberately
refuses to infer: that no intent exists or ever will. The runbook gives the
`stripe payment_intents search` command to confirm with first, and says to
let the index settle before believing an empty result.

It REFUSES when the batch has a stamped PI. That case is not stuck — it is
the ordinary release path — and forcing past a live intent is precisely the
double-charge the protocol exists to prevent; the tests pin both the refusal
and that the allocations stay reserved when it refuses.

293 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round 7 — five defects, all in round 6's own fixes

1. The `reserved` fast path decided from the CALLER's snapshot while the CAS
   accepted four source states. A row that moved reserved → invoicing between
   the caller's SELECT and the CAS still won, and the fast path then skipped
   the search and freed the allocations while a PaymentIntent was being
   created for it. The `reserved` claim is now a separate CAS attempted
   first: winning it is what proves the source state.

2. forceReleaseBatch freed the allocations and only THEN attempted its
   closing CAS, so losing that CAS to a concurrent release that had just
   found an orphan PI left a batch heading for `funded` with released,
   re-batchable allocations. CAS first; winning it earns the right to free.

3. The retry set could starve the entire sweep. Retries took the whole
   budget, so `limit` persistently-failing rows meant zero cursor capacity,
   a cursor reset to null every run, and nothing else ever swept — the
   poison-item failure round 6 introduced the retry set to prevent, back in
   a new costume. Retries now get at most half the budget, survivors rotate
   to the back so a failing head cannot hide the tail, and an overflow names
   the ids it is dropping instead of saying "the oldest".

4. `postedAt` is not transfer eligibility time — it is stamped before the
   Stripe call, so an intent whose response was lost confirms a day later
   and lands BEHIND the cursor. Ordering now uses a key that moves forward
   at confirmation, so a row can only ever be re-visited, never skipped.

5. FundingEventNotReadyError deleted the inbox claim on every attempt, so an
   intent whose payoutId is never populated (disputed batch, executor stops
   scanning it) redelivered until Stripe gave up and left NO row for the
   stuck-claim alert. The claim is now retained: the lease still expires for
   takeover, but the row persists and the 1h alert surfaces it.

Two of my round-7 tests were weak and the revert checks caught them: the
forceReleaseBatch test hit the early status guard and never reached the
ordering code (fixed with a seam that stages the race between the read and
the CAS), and the eligibility test let the cursor wrap and pick the row up
by accident (fixed with churn and explicit updatedAt). All five fixes now
fail their tests when reverted.

298 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round 8 — five defects, all in round 7's own fixes

1. forceReleaseBatch CASed on status alone, but a concurrent releaseBatch
   can find an orphan PI and STAMP it while the row stays
   `release_requested`. The status-only CAS still won, freed the
   allocations, and left a batch heading for `funded` whose commissions were
   already back in the pool. casBatch gained an `expect` predicate and the
   CAS now requires stripePaymentIntentId to still be null at the moment it
   wins, not merely when it read.

2. The retry set's "rotation" rotated nothing: `commit` reordered the stored
   set, but selection re-filtered `rows` and re-picked the same head every
   run, so the tail was never retried. Selection now reads the stored order.

3. At limit 1 the retry set took the only slot, `remaining` was 0, and one
   permanently-failing row starved every healthy cursor row forever — the
   poison-item failure for the third time. Retries never take the last slot
   now; at limit 1 they get none and are reached by the cursor instead.

4. The `updatedAt` ordering rested on "it only ever moves forward", which
   was false: every writer used the app's `new Date()`, so a node running a
   minute behind could finalize a row BEHIND the cursor and strand it. All
   HostedFundingTransfer.updatedAt writes now use db.fn.now() — one clock,
   so the premise actually holds.

5. The stuck-claim alert measures age from `processedAt`, which a claim
   takeover REFRESHES, so an event redelivered every few minutes postpones
   its own alert indefinitely. Rather than depend on that timing, reconcile
   now detects the underlying condition directly: an intent posted with no
   linked Payout for over an hour cannot accept a reversal and is reported
   on its own.

On test honesty: the rotation test is labelled coverage, not a mutation
killer. I could not isolate rotation — with a cursor slot free the cursor
reaches the second row on a wrap regardless of selection order, and two
attempts at forcing it both passed with the fix reverted. It proves the
weaker property that neither row is abandoned; the rotation rests on
inspection, and the test says so instead of implying otherwise.

302 tests green; staging matrix 28 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(funding): round 9 — per-object sweep scheduling replaces the cursor

Three round-9 findings shared one root: a global high-water mark makes an
ordering claim that concurrent writers keep falsifying. CURRENT_TIMESTAMP
is transaction-START time, so commit order could still land a row behind
the persisted cursor; the limit-1 retry budget was 0, so under churn a
poison row was never re-attempted; and retry-set overflow trimmed the
untouched head of the list, not the oldest-attempted.

Scheduling state now lives on the swept rows themselves (sweepDueAt /
sweepLeaseAt / sweepLeaseToken / sweepFailCount). Claims are `for update
skip locked` under a per-run lease token, Stripe reads happen outside the
claim statement, and every claimed row is acknowledged under that token.
Success and failure reschedule IDENTICALLY, so selection is pure
least-recently-visited rotation: a poison row costs one slot per rotation
and escalates through sweepFailCount instead of through priority, and
there is no high-water mark for a clock-skewed timestamp to strand a row
behind — a rank, unlike a mark, is recomputed from live rows every run.

The cursor, the retry set, their Config plumbing and their budget
arithmetic are deleted. Every prior coverage test passes unchanged; the
new lease and uniform-reschedule tests were both checked by reverting the
mechanism under them and watching them fail.

Migration adds the four columns to both hosted-only sidecar tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(funding): round 9 — forceReleaseBatch verifies its own premise

The round-8 null-id fence closed "stamp commits before the force CAS",
not "releaseBatch discovered a PI it has not stamped yet": the force
still won its CAS with the id null, freed the allocations, and the
discovered PaymentIntent could then succeed against commissions that
were already re-batchable — the double-charge, with only an alert left.

Two additions close the discovery window:

- A QUIET GATE: the force refuses (too_recent) until the batch has sat
  unchanged in release_requested for an hour. A PaymentIntent can only be
  created while a batch is in the collector's invoicing branch, so an
  hour of quiet means any existing intent predates the stuck state by an
  hour — far past Stripe's search-indexing lag.
- ITS OWN SEARCH, with the Stripe client now REQUIRED: given the gate,
  the search is conclusive. Found → refuse and stamp the id so the
  ordinary release path terminalizes it; search failed → cannot_verify,
  nothing freed. This is also the round-9 §0.2 item — the function no
  longer merely checks that a DB column is null while being described
  as verified.

The search guard was checked by reverting it and watching its test fail.
Funding suites: 94 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(funding): round 10 — one clock for leases, two eyes on every debit

Codex's round-10 pass on the funding rail, all verified before fixing:

- A stalled collector pass could create a PaymentIntent from a STALE
  invoicing snapshot after a release claimed the batch — recoverable only
  through the orphan-cancel compensation, whose cancel can lose to a
  bank debit already processing. The collector re-reads the LIVE status
  immediately before creating; the stale window shrinks from
  "whole pass" to "one Stripe call", which the force-release quiet gate
  then covers entirely.
- A funding PI's only stamp is mutable metadata — cleared, it was
  invisible to forceReleaseBatch's search (the exact failure the
  transfer-side fixes closed with transfer_group, which PIs lack).
  The force now also LISTS the tenant customer's PaymentIntents —
  metadata-independent — and refuses on any non-canceled intent matching
  the batch's exact amount+currency since the batch was created.
  One-open-batch-per-tenant+currency is what makes that match decisive.
- Sweep leases now use the DATABASE clock on both write and comparison:
  a clock-fast worker could strand a row behind a future lease, a
  clock-slow one could be reclaimed mid-flight. And a never-swept row's
  eligibility hint is clamped with least(..., now()) so a future-skewed
  timestamp means "due now", not "behind every rescheduled row forever".
- The force-search test fixture now carries the metadata stamp the
  production query actually matches on — it previously fed the mock a PI
  the real search could never return.

Both new guards were checked by reverting them and watching their tests
fail. Full API suite: 37 files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-pay (#10) (#73)

* fix(payouts): durable transfer intent — stop the direct-Connect double-pay (#10)

runPayouts called stripe.transfers.create INSIDE the caller's tenant
transaction, keyed on a payout id minted in that same transaction. Two
double-pay paths followed:

  1. the transfer succeeds and the COMMIT then fails — the Payout row and
     the paid commissions roll back, the money is gone, and the next run
     regroups under a NEW payout id, hence a NEW idempotency key, hence a
     second transfer;
  2. an ambiguous Stripe error (timeout — the transfer may or may not
     exist) marked the payout failed, left the commissions approved, and
     the next run retried under a new key.

This is a live path: self-host Connect payouts and the deliberate
OPENPARTNER_ALLOW_UNFUNDED_CONNECT_PAYOUTS=1 override. It is not behind
the funding flag.

A deterministic key over the commission set does NOT fix it — if any
commission is approved between attempts the set changes, the key changes,
and the second transfer pays the overlap again.

So mirror funding/executor.ts, which already had the right shape:

- payouts.ts is now a PLANNER. It writes the Payout row as an intent
  (metadata.transferState='intent', frozen amountMinor + destination) and
  freezes its commission set by claiming the rows — Commission.payoutId
  stamped while status stays 'approved'. Every planner lookup filters
  payoutId is null, so claimed commissions can never be regrouped into a
  second, larger transfer. No Stripe call happens inside the transaction.
- payout-transfers.ts is the EXECUTOR. Outside any transaction, on the
  privileged pool: preflight (set unchanged? partner still ready?), CAS
  intent→posted, POST with the durable key payout_<payoutId>, finalize in
  a short transaction, webhooks strictly after the commit.
- Ambiguous outcomes stay 'posted': inside Stripe's ~24h window a retry
  replays the frozen key; past it the intent goes reconcile_required and
  is resolved by paging transfers.list({transfer_group}) for our metadata
  stamp — never a blind re-POST. Proven absent → re-armed as 'intent'.
- Definite 4xx fails the payout and releases the claims so the next run
  regroups. A transfer that comes back reversed is never recorded paid.

Also: the amount now comes from the rows actually claimed rather than a
separately-snapshotted SUM, and withTenantTransaction (tenancy.ts) is the
one way to open a tenant-scoped transaction — the scheduler and the admin
POST /payouts/run both use it, the latter committing its intents before
any money moves instead of borrowing the request transaction.

New scheduler job payout-transfers (*/15) retries and reconciles intents
left open by a crash, a timeout, or an unready partner.

16 DB-backed tests with a Stripe mock cover every path above, including
the commit-fails-after-transfer and set-change scenarios. Operator doc:
docs/direct-connect-payouts.md (state machine, recovery SQL, and the
staging checklist to run in Stripe test mode before this pays anyone).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): adversarial-review fixes — 409 double-pay, stale replay, missing interlock

Codex review of #73 refuted both claims the PR made. Three real defects,
all of them ways the "cannot double-pay" property failed.

1. A 409 IDEMPOTENCY CONFLICT WAS TREATED AS PROOF OF ABSENCE. Every 4xx
   classified as definite → the intent was failed and its commissions
   released. But 409 `idempotency_key_in_use` means another request is
   using this key RIGHT NOW and may well succeed; releasing lets the
   planner regroup those commissions under a NEW key while the first
   transfer lands. That is exactly the double-pay this PR exists to stop.
   429 had the same problem (no transfer, but the intent was destroyed).
   Both are now ambiguous: the intent stays posted and the retry replays
   the frozen key or reconciles by listing.
   The funding executor never released on these; the divergence was mine.

2. TWO WORKERS COULD RE-POST ONE INTENT CONCURRENTLY. A `posted` intent
   past the 60s cooldown fell through to transfers.create with no claim,
   and the admin route, the weekly payouts job and the 15-minute executor
   job hold DIFFERENT locks, so they can overlap. The retry now takes a
   lease by swapping the exact `postedAt` it read — `posted → posted`
   matches for everyone, but only one worker can swap a given timestamp.

3. A RETRIED KEY REPLAYS A STALE OBJECT. Stripe answers a repeated
   idempotency key with the response it stored at creation, so `reversed`
   in that body is false even if the transfer has since been clawed back
   — and finalizing on it overwrote a reversal webhook's `failed` with
   `paid`, marking commissions paid on money that came back. Any attempt
   past the first now re-reads the transfer from Stripe before believing
   it, and a re-read that fails records nothing.

Also: the frozen commission set had no protection from the OTHER side.
`interlockCommissionReversal` only knew about funding allocations, so the
admin reverse endpoint and the refund clawback would flip a commission
claimed by a posted intent — Stripe still gets the frozen amount while
fewer commissions are marked paid. It now holds commissions claimed by an
open Payout intent too, which gives every reversal path the guard for
free. (Preflight only helps before the first POST.)

The PR also claimed money can never be stranded, which was overstated: a
reversed transfer deliberately leaves its commissions claimed with no
automatic path back. That case now has a documented operator disposition
rather than an implied one.

7 new tests: 409 holds the claim, 429 holds the claim, two workers retry
→ one POST, stale replay is re-read and never resurrects a reversal,
interlock holds a claimed commission, and a canceled intent frees it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): split the lease clock from the idempotency-window clock

Self-caught regression in the previous commit. The retry lease swapped
`postedAt` — which is ALSO what Stripe's ~24h key-retention window is
measured from. A steadily-retried intent therefore refreshed its own
window on every attempt, never reached `reconcile_required`, and once
Stripe pruned the key a re-POST would have created a SECOND transfer.
The fix for one double-pay reintroduced another.

Two clocks now, with distinct jobs:
  postedAt — when the frozen key was FIRST used. Never moves. Anchors
             the idempotency window.
  leaseAt  — when the last attempt claimed the intent. Moves on every
             retry; it's the compare-and-swap token that keeps two
             workers off one key, and the cooldown reads it.

Re-arming after a proven-absent reconcile clears both.

Two regression tests: an intent posted 25h ago but retried 2 minutes ago
still reconciles by listing instead of re-POSTing, and the cooldown reads
the lease clock rather than the first post.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-2 review — lease vs expiry race, interlock check/use gap, error type

Second adversarial pass over the fixes themselves. Six findings, all
verified against the code.

1. THE EXPIRY PATH COULD STEAL A WARM LEASE. The window check ran before
   the cooldown and CASed on transferState alone, so a worker that had
   just leased and was inside transfers.create could have its intent
   moved to reconcile_required, listed and finalized by another worker —
   and then its own POST landed on a key Stripe had pruned, creating a
   SECOND transfer. Cooldown now runs FIRST: a warm intent is left alone
   whatever its age.

2. CHECKING THE AGE THEN POSTING IS A TOCTOU AGAINST STRIPE'S RETENTION.
   A worker that measured "23h59m, fine" can have its request arrive
   after the key was pruned. The key is now treated as spent an hour
   early (KEY_SAFETY_MARGIN_MS), so every POST we authorize lands with
   room to spare instead of racing the expiry we just measured.

3. THE REVERSAL INTERLOCK HAD A CHECK/USE GAP. interlockCommissionReversal
   is a read; the status flip is a separate write. The planner can commit
   an intent and claim the commission in between — after which the
   transfer goes out for the frozen amount and the commission is reversed,
   never paid. Both reversal paths (admin route, refund clawback) now
   re-assert the guard INSIDE the UPDATE, so whichever statement lands
   second sees the other's work.

4. THE IDEMPOTENCY-ERROR EXCLUSION CHECKED THE WRONG PROPERTY.
   stripe-node puts the wrapper class on `type` ('StripeIdempotencyError')
   and the API's string on `rawType` ('idempotency_error'). Matching
   `type` against the API string never fired, so a 400-level idempotency
   error (a key reused with different parameters) was still classified
   definite and released the claims.

5. 500 STUCK INTENTS COULD STARVE EVERY OTHER TENANT. The scan is global,
   capped at 500 and ordered by createdAt, so a wedged backlog monopolized
   every pass. Ordered by least-recently-attempted now: each attempt bumps
   leaseAt, so a stuck intent falls to the back on its own.

6. Smaller: a transfer we can see but cannot re-read now persists its
   error and escalates to an ALERT after 5 attempts instead of retrying
   silently forever; an interlock whose Payout row can't be resolved fails
   CLOSED (left join, not inner) since Commission.payoutId has no FK; and
   a hand-repaired `leaseAt: null` takes the IS NULL branch instead of
   `= NULL`, which never matches and would have wedged the intent.

Not applicable from the review: it flagged an upgrade hazard for rows
written by the intermediate commit a38732e. That commit only ever existed
on this unmerged branch — no deployment has ever written a `postedAt`
under those semantics, and `main` has no transferState at all.

6 new tests, including the warm-lease and check/use races.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-3 review — ABA on the expiry CAS, and the key was never fresh

Third adversarial pass. Two of these are the deepest defects found in this
branch so far, and both were introduced by earlier fixes.

1. CRITICAL — ABA ON THE EXPIRY TRANSITION. The posted → reconcile_required
   CAS checked only `transferState`. A worker holding a stale scan
   snapshot could arrive after the intent had been reconciled, re-armed
   and posted afresh by someone else, see `posted` again, and steal that
   NEW generation out from under a live transfers.create. The victim's
   finalize then lost its CAS, so a real transfer went unrecorded — and a
   later POST could pay it again. The transition is now scoped to the
   exact `postedAt` the worker observed, so a stale reader loses.

2. CRITICAL — "A GENUINE FIRST ATTEMPT" WAS NEVER TRUE. After listing
   proved no transfer existed, the re-arm cleared our own clocks and
   re-posted `payout_<payoutId>` — the SAME key. Stripe's retention is
   anchored to when that key was FIRST used, not to our local timestamps,
   so a re-armed POST inside the retention replays the stored outcome
   (including a stored failure) rather than doing anything, and the local
   window restarts — with no ceiling. Proving absence is exactly the
   evidence needed to make a NEW key safe, so the re-arm now bumps a
   `keyGeneration` and posts under `payout_<id>_g<N>`. Generation 0 keeps
   the original key so anything already posted under it still replays.

3. HIGH — THE REFUND PATH SILENTLY DROPPED LATE-HELD COMMISSIONS. The
   guarded UPDATE added in round 2 can refuse a commission claimed after
   the interlock read, but the return still reported only the interlock's
   own held count. Those commissions were reported as neither reversed
   nor held: the refund looked fully handled while a commission for
   refunded revenue stayed payable. They are now counted and alerted.

4. MEDIUM — THE SCAN ORDER MIXED TWO TIMESTAMP FORMATS. `"createdAt"::text`
   renders `2026-08-09 20:04:15.501+00` while the metadata clocks are ISO
   `...T...Z`, and a space sorts before `T` — so every never-attempted row
   jumped ahead of every attempted row sharing a date, regardless of time.
   With a 500-row cap that is queue starvation. All three keys are now
   rendered in the same ISO shape, pinned to UTC.

5. MEDIUM — an unknown `transferState` (rolling deploy, hand-edited row)
   was treated as "not open" and released the reversal hold. The guard now
   keys on the TERMINAL states, so anything unrecognised fails closed.

6. LOW — a failed transfer re-read could write its stale error onto a row
   another worker had already finalized; that write is now predicated on
   still holding the intent.

Not duplicated here: the review also flagged the funding executor's
un-paginated 100-transfer reconcile listing. That is real, and it is
exactly what PR #71 fixes — both need to merge.

5 new tests, including the ABA staged directly against the CAS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-4 review — make keyGeneration a real epoch, not just a key suffix

Round 3 introduced generations to stop a re-armed intent reusing a key
Stripe still retained. It fixed that, and opened a new dimension that
nothing was fenced on. Three critical consequences, all verified:

1. THE RE-ARM WASN'T FENCED. `reconcileIntent` computed `next = read + 1`
   from its own snapshot and CASed on state alone. A reconciler resuming
   from a stale read could therefore hand generation N+1 back out AFTER
   another worker had already posted under it — so a live generation
   became reusable, and once Stripe pruned that key the next POST created
   a second transfer. The re-arm now CASes with the generation it read.

2. GENERATIONS WERE INDISTINGUISHABLE AT STRIPE, AND DUPLICATES WERE
   SILENTLY COLLAPSED. transfer_group stayed the payout id (correct — one
   listing should see every generation) but the metadata carried no
   generation and reconcile took the FIRST match. Stripe lists
   newest-first, so a late transfer from generation N arriving after N+1
   succeeded would have been hidden behind the newer one: two real
   payments, one recorded, nothing detecting it. Transfers now carry
   `openpartner_key_generation`, and reconcile collects ALL matches
   across all pages: zero → fenced re-arm, one → finalize, MORE THAN ONE
   → write no ledger, alert, and leave it for a human.

3. FINALIZATION WASN'T FENCED EITHER. A worker holding a transfer from
   generation N could finalize it against an intent that had since moved
   to N+1 — recording the wrong transfer while N+1's own transfer stayed
   live. Every post-Stripe mutation (finalize, reversed-transfer close,
   definite failure, ambiguous error, retrieve failure) is now scoped to
   the generation it belongs to.

Also:

- A malformed `postedAt` produced NaN, and every comparison with NaN is
  false — so a corrupt timestamp sailed past BOTH safety checks into the
  re-POST path, the one place we must never go on a guess. Unknown age
  now reconciles by listing, which is always safe.
- The refund path attributed every missed row to "a payout intent claimed
  it", so a commission concurrently paid or reversed by another actor
  produced a false alert AND was double-counted against alreadyPaid. The
  missed rows are now re-read and classified.
- The reversal UPDATE guard covered the direct rail but not funding: a
  reservation could insert an allocation after the interlock read, and
  the funded executor would then overwrite `reversed` with `paid`. It now
  excludes live allocations too.

4 new tests, including duplicate detection and a superseded-epoch
finalize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-5 review — the lease was shorter than Stripe's own request budget

Fifth adversarial pass. Three criticals, all in round-4's epoch wiring,
plus one that has been latent since the lease was introduced.

1. CRITICAL — THE LEASE COULD EXPIRE WHILE THE POST WAS STILL IN FLIGHT.
   `POST_COOLDOWN_MS` was 60s, but our Stripe client passes no options and
   stripe-node defaults to an 80s timeout with 2 network retries — so a
   single transfers.create can occupy ~4 MINUTES. Another worker would
   declare the lease cold, reconcile, prove absence and re-arm, and then
   the original transfer landed alongside the new generation's. Two
   transfers, one recorded, and the row leaves the scanned states so
   duplicate detection never runs on it.
   The money call is now explicitly bounded (20s, 1 retry) and the lease
   (180s) is required to dominate that budget — with a test asserting the
   relationship rather than the constants.

2. CRITICAL — TRANSFERS WERE STAMPED WITH THEIR GENERATION AND THE STAMP
   WAS IGNORED. Reconcile matched on payout id only and finalized with the
   ROW's current generation, so a generation-0 transfer discovered while
   the row was on generation 1 passed the generation-1 fence and was
   recorded as the current epoch's — the exact invariant the stamp was
   added for. It now reads the stamp: a transfer from a superseded epoch
   means an abandoned attempt DID move money, which is the duplicate case
   in slow motion, and it freezes instead of finalizing.

3. CRITICAL — `meta` STAYED THE PRE-CAS SNAPSHOT. Round 4's own conclusion
   was "after a CAS, use the returned row"; I only reassigned `payout`.
   Everything downstream — the idempotency key, the Stripe stamp, every
   fence — kept deriving from the stale object, so a worker could POST
   generation N's key while the database said N+1. `meta` is refreshed
   from every CAS now, and the `intent → posted` transition is fenced on
   the observed epoch too.

4. HIGH — the fence used `("metadata"->>'keyGeneration')::int`, and a cast
   RAISES on a non-numeric value where `coalesce` cannot catch it. One
   malformed metadata blob would make every fenced CAS throw and wedge the
   intent permanently — possibly after money moved. Now a text comparison,
   with the decoded generation validated; an unreadable epoch refuses to
   act and alerts rather than guessing.

5. HIGH — duplicate detection was a permanent hot loop: it left the intent
   in `reconcile_required`, which is scanned every 15 minutes, so Stripe
   was re-listed and the alert repeated forever — and 500 such rows would
   consume the entire scan cap and starve other tenants. Duplicates now
   park in a `duplicate_review` state the executor does not scan, with the
   disposition documented.

6. HIGH — the transfer webhooks ignored the epoch entirely, applying by
   payout id alone: a `transfer.reversed` from a superseded generation
   could mark a legitimately paid payout failed. They now apply only to
   the transfer the payout actually recorded.

7. MEDIUM — the refund path dropped a commission that became `paid`
   between its two reads from BOTH totals, reporting a cleanly-handled
   refund when money had just moved; and it labelled every held row as
   payout-claimed when it could equally be allocation-held.

Codex confirmed generation-0 compatibility holds throughout (absent key
matches every fence), and that `missedIds` is correctly bounded.

4 new tests. The round-4 seam test that gave false confidence here — it
handed finalize the old generation explicitly, which the real reconcile
path never did — is replaced by one that goes through the executor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): run the direct-Connect matrix against real Stripe test mode

The staging checklist in this doc was the stated gate for this PR and had
never been run — neither money path had ever touched Stripe. All six
scenarios now pass: 37 assertions, 0 failures.

Adds scripts/staging-direct-connect.ts so the matrix is a command rather
than a manual checklist. It refuses a live key or a non-local DATABASE_URL,
since it truncates tables and moves money.

Three things only a real run could establish:

- Replaying the frozen idempotency key after a lost response returns the
  SAME transfer. That is the assumption the whole intent design rests on,
  and it was previously only asserted against our own mock.
- Past the 24h window the executor calls transfers.list and never
  transfers.create — verified by counting calls on a wrapped client, not by
  reading the branch.
- An unready destination produces a real Stripe 400, classified definite,
  releasing the claims. The mock's 400 was our own invention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 6 — ambiguity freezes instead of re-arming

Three verified defects, two of them able to pay a partner twice.

1. The safety argument rested on POST_COOLDOWN_MS exceeding the Stripe
   request budget. That was false: stripe-node implements `timeout` as
   req.setTimeout, a socket-INACTIVITY timeout that resets after each
   request stage — its own source says so, contrasting Node with fetch.
   A slow-but-progressing POST has no wall-clock bound and can outlive any
   cooldown, get declared cold, be reconciled, re-armed at a new
   generation, and land as a second transfer.

   No constant fixes this, and neither does a local deadline: aborting the
   await cannot retract a request Stripe already received. So the executor
   no longer re-arms at all. An empty transfers.list means UNKNOWN, never
   ABSENT — the intent is HELD on its generation with commissions frozen,
   later ticks keep listing (a straggler still finalizes normally), and
   only an operator may authorise a fresh key. POST_COOLDOWN_MS survives
   as scheduling and is documented as carrying no correctness weight.

   Adds releaseIntentForRetry and disposeIntent, because a hold with no
   release is a leak. Both are generation-fenced.

2. finalizeTransfer re-read the live transfer only when attempts > 1, so a
   first-attempt transfer reversed between the create response and the DB
   write was recorded paid from a stale body — and the reversal webhook
   could not repair it, because it matched on a stripeTransferId that is
   not stamped until finalization. Now it always re-reads, and the webhook
   matches an unstamped payout on the transfer's immutable metadata
   (openpartner_payout_id + openpartner_key_generation), terminalizing the
   intent so the stale finalizer loses its CAS.

3. markDuplicateReview CASed on state alone, so a reconciler resuming from
   a stale snapshot could quarantine a live newer generation — failing a
   payout whose single transfer had legitimately moved money. Fenced on
   the generation the caller observed.

The Stripe mock had no `retrieve` at all, which is what let defect 2 hide:
it modelled an API whose live object cannot be read. Added, with a seam for
"reversed after the create response".

Both new tests verified to FAIL when their fix is reverted. 298 tests green,
typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): make the matrix prove concurrency and the new hold

Two honest problems with the script as written, both found by the round-6
review of my own work:

- Scenario 6 reported SUCCESS when the planner returned no payout at all,
  so a misconfiguration that never reached Stripe was indistinguishable
  from Stripe definitively rejecting the transfer. That is now a failure.
- The header claimed the script covered round 5's lease-vs-request-budget
  property. It never did — the injected failures happen after the real
  request has fully returned — and that property turned out to be
  unprovable anyway. The header now says what the script does NOT prove.

Adds the two scenarios the rework needs, run against real Stripe:

- 7: two genuinely concurrent executors over one intent → exactly one
  transfer, one recorder, one paid commission. First concurrency coverage
  this matrix has had; every earlier run was single-threaded, which is
  precisely why the CAS and lease went unexercised.
- 8: past the window with nothing at Stripe → three ticks must not post,
  the intent stays reconcile_required on generation 0 with commissions
  frozen, and only releaseIntentForRetry lets it post, exactly once.

50 assertions, 0 failures. Doc updated: the state machine no longer claims
listing proves absence, and the manual-SQL release is replaced by the two
fenced operator calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): delete the cooldown-arithmetic test — it guarded nothing

Both round-6 reviews independently called for this one's removal, and I had
cited it to the user as the guard on POST_COOLDOWN_MS.

It parsed three constants out of the source and asserted
`cooldown > timeout * (retries + 1)`. That is worthless twice over:

- It asserted a guarantee that does not exist. stripe-node implements
  `timeout` as req.setTimeout — a socket-inactivity timeout that resets
  after each request stage — so no arithmetic over those constants bounds
  the wall-clock life of a POST.
- It was not a mutation killer even for the fix it claimed to protect.
  Restoring the old 60s cooldown still passes `60 > 40`, and nothing
  checked the request options ever reached Stripe.

The invariant that does hold is now covered instead: the executor never
re-arms on elapsed time, so a cold lease can never authorise a new key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 7 — four defects, two of them in round 6's own fixes

1. The metadata fallback I added in round 6 had the race it was closing.
   The exact-id match and the fallback are two statements, and the executor
   commits finalization in its own transaction — so it could stamp
   stripeTransferId BETWEEN them: the first missed (id null), the fallback
   missed (it requires id null), and the event fell through to "unmatched"
   and was acknowledged 2xx. Reversed transfer, payout paid, no second
   chance. The transfer case now takes the payout row lock first, so we see
   either the pre- or the post-finalization state and never the gap.

2. disposeIntent accepted duplicate_review and released the claims — a
   straight double-pay with no concurrency needed. A duplicate is disposed
   of by reversing the SURPLUS and keeping one transfer, so those
   commissions have been paid; releasing them let the planner pay them
   again. That state now has its own function, resolveDuplicateReview,
   which takes the operator's actual disposition: a kept transfer records
   the ledger against it and marks the commissions PAID, all-reversed
   returns them to the pool.

3. releaseIntentForRetry could re-arm while a reconciler held a transfer it
   had just found. The generation fence does not cover that — the
   reconciler has written nothing yet, so it is still on the observed
   generation. It now lists the transfer group itself and refuses on
   positive evidence. That is not proof of absence (nothing is), but the
   transfer a reconciler found is by definition visible to a listing.

4. A reversed transfer left confirmed+failed with commissions frozen "for
   operator disposition" and no function accepting that state. disposeIntent
   now takes it, guarded by a new notStatus predicate so a payout whose
   money reached the partner can never be released back into the pool.

Every guard verified to fail its test when reverted. 303 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): pick partner2's payout explicitly — [0] was order-dependent

CI caught what my local run did not. The dispose earlier in the test returns
the first partner's commission to the payable pool, so the following plan()
produces a payout for BOTH partners and their order is not guaranteed.
Indexing [0] happened to pick the right one locally and the wrong one in CI.

Selects by partnerId instead. Ran three times locally to confirm it is
stable rather than lucky.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): exercise the round-7 listing guard against real Stripe

releaseIntentForRetry now refuses to re-arm when a transfer is visible in
the group. The staging matrix called it without a Stripe client, so that
guard was skipped in the only place it runs for real. Passes the client.

50 assertions, 0 failures against test mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 8 — operator actions must verify their own premise

Round 6 removed "an empty search proves absence" from the automatic paths.
Round 7 then built operator paths that act on an unverified human assertion,
which is strictly weaker than what had just been removed — and a human typo
is silent where a Stripe read is not. This applies that rule.

1. disposeIntent released on `status !== 'paid'`, but finalizeTransfer
   records confirmed+failed for ANY non-zero amount_reversed — including a
   PARTIAL clawback. A $10 reversal on a $50 transfer leaves $40 with the
   partner, and releasing those commissions let the planner pay the full $50
   again: $90 out for $50 owed. It now retrieves the stamped transfer and
   refuses ('money_with_partner') unless it is fully reversed.

2. resolveDuplicateReview validated nothing. `{allReversed}` while transfers
   were live released the commissions and double-paid; a typo'd
   `{keptTransferId}` recorded the payout paid against a transfer that does
   not exist AND wedged the row where no operator function accepted it. Both
   dispositions are now checked against the transfer group: a kept transfer
   must be present and unreversed, and all-reversed is refused while any
   transfer still holds money. My own test had encoded the typo case as
   success — it now asserts the refusal.

3. The listing guard on releaseIntentForRetry only ran `if (stripe)`, and
   the documented operator call in the runbook omitted it — so the guard was
   absent in the one usage operators would copy. The parameter is required
   now. An optional security parameter is one someone forgets.

4. That guard also treated exhausting its 20-page budget as absence. Running
   out of pages is not the same as finding nothing; it returns
   'cannot_verify' instead, as does any failed read.

The staging matrix only ever tested that a re-arm was ACCEPTED, so deleting
the guard produced the same result. It now also proves the refusal against
real Stripe once a transfer exists: 51 assertions, 0 failures.

Runbook updated — every operator call now shows the required client and what
each refusal means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 9 — the reversal webhook can now always find the payout

The guarantee the finalizer relies on — "the reversal webhook can always
find this payout" — had two holes, and both are closed by making the
IMMUTABLE identifiers load-bearing instead of the mutable ones:

- duplicate_review was invisible to the webhook: its fallback accepted
  only posted/reconcile_required, so a reversal landing while an operator
  resolved a duplicate was dropped as unmatched — and the stale
  resolution then recorded the reversed transfer as the kept one, paid.
  The webhook now RECORDS reversal activity on a parked review (without
  claiming or terminalizing — siblings may still hold money) and moves a
  duplicateReviewNonce; resolveDuplicateReview fences both commits on the
  nonce it observed, loses to newer activity, and answers review_moved.
  Partial reversals count, and the generation fence deliberately does not
  apply — a duplicate group spans generations by construction.

- metadata.openpartner_payout_id is mutable at Stripe, and every guard
  filtered on it: a cleared-metadata transfer vanished from
  releaseIntentForRetry (re-arm posts a second transfer), from
  {allReversed} (vacuously satisfied by an empty group), and from the
  webhook's identification itself. Membership is now the transfer_group —
  set at creation, immutable, stamped with the payout ULID — everywhere.

Also from round 9's findings, in the functions option B keeps as its
apply-step validation:

- disposeIntent with NO stamped transfer (the common ambiguous case)
  released claims with no Stripe read at all. It now lists the whole
  group and refuses while any member holds money; empty is the operator's
  documented risk decision, not proof.
- A kept transfer must match the intent's FROZEN amount, currency and
  destination — present-and-unreversed alone let a one-cent transfer mark
  a $50 payout fully paid — and every sibling must be fully reversed.
- stripe is REQUIRED in both signatures; the runbook said it was while
  the types said otherwise, and its duplicate_review section told
  operators to release the claims — the exact double-pay
  resolveDuplicateReview exists to prevent. Both corrected.

The nonce fence and the lease guard were checked by reverting them and
watching their tests fail. Full API suite: 316 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payouts): round 9 — a ghost transfer stamp no longer wedges disposal

A confirmed/failed payout whose stamped stripeTransferId answers
resource_missing — a hand repair, or a payout wedged by the pre-round-8
typo path — returned cannot_verify forever: fail-closed with no exit but
raw SQL. A stamp that cannot be dereferenced proves nothing either way,
so disposeIntent now falls back to the same whole-group verification as
the unstamped case: a live member still refuses (the ghost stamp does not
excuse ignoring real money), an empty group opens the operator's
documented risk decision. Any other retrieve error still refuses.

The group pagination is now one shared helper (listTransferGroup) used by
disposeIntent and resolveDuplicateReview, rather than two copies that
could drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payouts): round 10 — authenticate what the group asserts, fence what it cannot

Codex's round-10 pass refuted six of the seven claims put to it. The
fixable set, all verified before fixing:

- The review nonce was the Stripe EVENT id, and Connect events return
  before the event-id dedupe — so a redelivered old event wrote its id
  back and a stale resolution fenced on it committed: an ABA. The nonce
  is a fresh ulid per write now; no observed value can ever return.
- Group membership is unauthenticated: anything on this Stripe account
  can set transfer_group to our payout ULID, and dropping the metadata
  filter (round 9) let reconcileIntent FINALIZE such a member.
  Reconcile now authenticates against the frozen intent — amount,
  currency, destination — plus an immutable time bound: transfer.created
  cannot predate the current generation's postedAt, which also defeats a
  FORGED generation stamp. Failures park for a human, never finalize.
- Identity resolution preferred the mutable metadata stamp over the
  immutable group, so a forged openpartner_payout_id could redirect a
  reversal into unresolved-tenant 2xx. transfer_group wins now.
- A partial reversal (transfer.updated, reversed:false,
  amount_reversed>0) re-asserted `paid` — flipping even a failed payout
  back. Status is never overwritten on a partial; it alerts instead.
- disposeIntent skipped group verification when the stamped transfer was
  fully reversed — a late duplicate holding the full payment was
  invisible. The group is verified ALWAYS.
- listTransferGroup treated an empty page with has_more as a complete
  group; it now refuses (cannot_verify).
- frozenAmountMinor: Number() coercion accepted amountMinor:true as 1
  cent; only an actual finite number counts now.
- NEW transfer.created handler (detector only, no state writes): the
  alarm for the honest prove-absence limit — a POST landing after a
  disposition raises transfer_created_orphan instead of passing silently.
  Register transfer.created on the webhook destination.

Deliberately NOT closed here (documented in the handoff): an unbounded
in-flight POST landing between a verified action's listing and its CAS —
that is the prove-absence limit itself; the detector above is its alarm
and option B's request ledger is its tombstone.

The ABA test replays the SAME event id after a newer event; the
authenticity tests stage a one-cent foreign member and a backdated
forged-generation member. Full API suite: 325 green twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ump, real PKs (#8) (#74)

* fix(export): complete the portability promise — missing tables, SQL dump, real PKs (#8)

CLAUDE.md and the README promise every table exports to CSV + JSON + SQL
with a re-importable round-trip. Three ways it wasn't true:

1. The exported set omitted PartnerProgram, PartnerCommission and Coupon.
   A restored instance had the partner roster but no program grants, no
   record of what any partnership was agreed at, and no coupon →
   (partner, program) mapping — so coupon-driven conversions could not be
   re-attributed. Added, in FK-safe positions.
2. No SQL dump existed at all, though the contract names it.
3. Import hardcoded `id` as the conflict target, but PartnerCommission is
   keyed on partnerId — adding it naively would have thrown on every
   re-import.

Each table now carries its real primary key in EXPORT_TABLES, and the
list doubles as the import order (parents first).

The round-trip test found two more, both live bugs:

- A JS array coming out of `select *` is ambiguous: Program.commissionRule
  is a jsonb array, Program.categories is a text[]. The driver renders a
  bare array as a Postgres array literal, so importing ANY program with
  compound commission rules failed with "invalid input syntax for type
  json". Both paths now read the live column types and render each
  correctly.
- Same ambiguity in the SQL dump: a text[] column got `::jsonb`.

New: GET /export.sql (full bundle) and GET /export/<Table>.sql. The dump
is portable by default — rows are written under a psql variable, so one
file restores anywhere:

    psql "$DATABASE_URL" -v tenant_id=default -f openpartner-export.sql

It opens a transaction, sets app.tenant_id so it works on the RLS-scoped
app role as well as the privileged one, and every statement is ON CONFLICT
DO NOTHING so a restore is idempotent and resumable. `?tenantId=<id>`
bakes a literal instead, for clients that don't run psql meta-commands.

schemaVersion 1 → 2. POST /import still accepts v1 bundles: an export
sitting on someone's disk must not become worthless because we added
tables. SUPPORTED_IMPORT_VERSIONS only ever grows.

Tests (export-roundtrip.test.ts, 11): seed one row in every exportable
table → export → wipe → restore → compare, once through JSON and once
through the SQL dump actually executed against Postgres (including the
portable form put through psql's own substitution). Plus idempotency on
both paths, v1 acceptance, PK/order invariants, and escaping of quotes,
jsonb arrays, text[] and nulls.

Also: the portal export page listed 9 of the 14 tables and offered no
SQL; docs/data-portability.md is the format contract (bundle shape, table
list with keys, restore procedures, and how to add a table).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(export): adversarial-review fixes — dump injection, wrong restore id, string mode

Codex review of #74 found three real defects. All three are in the part
of the change that generates a file someone else later executes, which is
exactly where I should have been most careful.

1. COMMAND INJECTION via `?tenantId=`. The parameter was accepted
   verbatim and interpolated into a `--` header comment. psql
   meta-commands are line-oriented, so a value containing a newline and
   `\! <shell command>` produced a dump that runs that command on the
   machine doing the restore — the documented workflow. The values were
   escaped; the comment was not.
   The id is now VALIDATED, not escaped (`[A-Za-z0-9_-]{1,64}`, refused
   with a 400), header comments strip newlines regardless, and
   buildSqlDump asserts the same rule so no future caller can reintroduce
   it. I wrote exactly this guard for Stripe ids on another branch and
   missed it here.

2. THE DOCUMENTED RESTORE COULD NEVER HAVE WORKED. The fallback was
   `\set tenant_id 'default'` and the docs said `-v tenant_id=default` —
   but `default` is the tenant SLUG. `tenantId` is a foreign key to
   `Tenant.id`, whose seeded value is `01J0000000DEFAULTTENANT0000`, so
   the first row failed the FK and rolled the whole restore back. The
   fallback is now DEFAULT_TENANT_ID, and the docs/README/portal say
   "destination tenant id", not a slug.
   The old test hid this by substituting the real id before executing;
   there is now a test that runs the dump the way `psql -f` with NO -v
   does, using the file's own fallback.

3. The escaping assumed `standard_conforming_strings` is on (the default
   since PG 9.1) but never said so; with it off, a backslash in exported
   data escapes the quote doubling and breaks out of the literal. The
   dump now pins the setting in its preamble.

Also from the same review: normalizeRow revived ANY ISO-shaped string as
a Date, so a partner named "2026-08-09T00:00:00" was corrupted on import
and a lookalike that isn't a valid date could fail it. Now that the
importer reads column types, the coercion is gated on the column actually
being a timestamp.

Tests: no -v restore path, string-mode pin, tenant-id refusal (incl. the
`\!` payload), header-comment containment, backslash/quote round-trip
through the dump, and timestamp-lookalike text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(export): round-2 review — one stale example, two overstated comments

Second review pass found no defect in the four fixes; the runtime
behaviour holds. Three accuracy items:

- The SqlDumpOptions doc block still showed `-v tenant_id=default`, the
  slug that cannot work. Every other copy was corrected in 2f4df82; this
  one was missed.
- Two comments claimed the `?tenantId=` value reaches a psql `\set`. It
  doesn't: literal mode emits no meta-commands at all. The real exposure
  is the header comment in a file psql executes, which is why the value
  is validated rather than escaped — now stated accurately.
- Documented that the portable form needs psql 10+ (`\if`), and that
  `?tenantId=` is the escape hatch for older clients, since an old psql
  would skip the conditional and apply the default over a supplied -v.

Also switched the empty-bundle test off the slug so no test models a
restore command that would fail.

Reviewer confirmed against the source: stripping CR/LF is sufficient for
psql's lexer (only CR and LF end a line — U+0085/U+2028/U+2029 and form
feed stay inside the comment); `SET LOCAL` is correctly placed after
BEGIN and applies to the literals parsed after it; and knex's columnInfo
returns `timestamp with time zone`, which `isTimestampType` matches, so
no timestamp column stopped being revived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(export): round-3 review — 400 on a malformed tenantId, and honest gap docs

Third pass found no defect in the restore mechanics; both remaining items
are contract accuracy.

1. `?tenantId=a&tenantId=b` parses as an ARRAY, and "present but not a
   string" was treated as absent — so a malformed request quietly got a
   portable 200 dump instead of the promised 400. Present-and-not-a-scalar
   is now a 400, like every other malformed value. Covered by the first
   route-level test this endpoint has had; previously either route's
   validation could have been deleted without failing the suite.

2. DOCUMENTED PROMISES THAT THE CODE DOESN'T KEEP. The review found
   `docs/payout-funding.md` §4 claiming the hosted funding sidecars are
   exportable, and `docs/white-label-custom-domains.md` claiming
   `Tenant.whiteLabel` exports losslessly — neither table is in the
   export set. `ARCHITECTURE.md` also still said every table imports with
   `onConflict('id')`, which stopped being true when PartnerCommission
   (keyed on partnerId) was added.
   Rather than bolt six tables on, `docs/data-portability.md` now states
   plainly what is NOT exported and why. `PayoutReversal` is called out as
   the material one: payout status is DERIVED from it, so a restored
   database can hold a `reversed` payout with no ledger behind it.
   Exporting those needs an FK decision first —
   `HostedFundingAuthorization.adminId` references `Admin`, which is
   deliberately never exported — and that is its own PR.

A documented gap is recoverable; a promise the code doesn't keep is what
makes an export untrustworthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(export): round-4 review — make every export claim in the repo true

No runtime defect found; the whole finding was that the contract still
contradicted itself, including in the file I corrected last round.

- `data-portability.md` opened with "every table exports" and then listed
  the exclusions. It now says what it means: everything IN THE BUNDLE
  exports, and here is what isn't in it.
- It also called the bundle a complete "commission/payout ledger" three
  lines after admitting `PayoutReversal` is missing — and a reversal
  ledger is precisely part of the payout ledger. Now scoped honestly: a
  complete record of attribution, commissions and payouts, but NOT of
  payout corrections.
- The never-exported list was incomplete (`Admin`, `SignupBlocklist`,
  `PlatformAuditLog`, `PlatformSession*`) and `WebhookEndpoint` — the
  customer's own outbound config, as opposed to its delivery log — was
  missing from the gap list entirely.
- `payout-funding.md`, `white-label-custom-domains.md` and the
  HostedFunding type comments all still asserted these tables export
  today. They describe a design intent; they now say so and point at the
  gap list.
- The portal promised "Download everything, any time."

Tests: the valid-tenantId assertion checked only a 200 and `BEGIN;`,
which would also pass if the parameter were ignored — it now asserts the
id is actually baked in and no psql variable remains. Added the missing
per-table route case and an assertion on the error body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): escape the backslash in the psql-variable assertion

Lint caught a useless-escape in the assertion added by the previous
commit: '\set' in a TS string is just 'set', so the test was asserting
the dump doesn't contain "set tenant_id" rather than "\set tenant_id".
It passed either way, which is why it slipped through.

My verification chain also let it slip: `pnpm lint | tail && git commit`
takes the pipeline's exit status from `tail`, so a failing lint didn't
stop the commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(export): round-5 review — the last stale export claims, and a real cross-tenant test

No runtime defect again; the review confirmed the table categorisation is
exhaustive (38 in TABLES = 14 exported + 13 never + 11 known gaps) and
that round 4 closed all three holes it had found.

What was left was language I hadn't touched, in the places furthest from
the code I was editing:

- `README.md` still said "export everything" and "every table"
- `ARCHITECTURE.md` said the migration is "the six raw + derived tables"
- `export.ts`'s own header said ANY export re-imports — but CSV is a view,
  not a restore format, which the doc already said
- the hosted-funding and white-label MIGRATION comments, and the
  `TenantRow.whiteLabel` type comment, all describe design intent in the
  present tense

`CLAUDE.md` §2 is deliberately left alone: "every table must be
exportable" is the project's stated commitment, not a claim about today's
implementation. Weakening the principle to match the code would be the
wrong direction — docs/data-portability.md records the delta instead.

Also added the cross-tenant restore test the review kept flagging as
missing. Every existing round-trip exports and imports under the SAME
tenant, so all of them would pass with the tenantId rewrite deleted —
the one behaviour that makes hosted → self-host work. Writing it surfaced
a property worth recording: primary keys are global, not per-tenant, so a
bundle cannot be restored alongside its source in one database
(`onConflict(pk).ignore()` sees the originals). The real scenario is a
different database, which the test now models.

Raised the timeout on the DB-heavy round-trip tests: several were running
within a second of vitest's 5s default, and when one timed out it left
rows behind that failed the next test. Flaky money-adjacent tests are
worse than slow ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(export): scalars are JSON too, and scoping no longer rests on RLS alone

Two round-6 findings, both verified against a live database.

Scalar and null JSON did not round-trip. Both serializers re-serialized a
json/jsonb value only when `typeof v === 'object'`, but pg parses json/jsonb
through JSON.parse — so a column holding '"hello"' hands back the JS string
`hello` and one holding '42' hands back a number. Those were emitted as the
SQL literals 'hello' and 42, which the column rejects, so a perfectly valid
stored value broke the restore outright. Any non-null JSON value is now
re-serialized regardless of its JS type. Confirmed empirically first:
jsonb_typeof reports `string`/`null` for these, and a NOT NULL column
accepts both.

Export scoping was the one path that broke the codebase's own safety net.
`exportTable` was an unfiltered `select *` relying entirely on RLS, but
appDb falls back to the privileged DATABASE_URL when DATABASE_URL_APP is
unset — which db.ts documents as supported precisely because "app-level
tenantId filtering still applies". It did not apply here. exportTable now
takes an explicit tenantId; every exportable table carries one, so the
filter is complete. Prod is unaffected (DATABASE_URL_APP is set there); the
exposure was multi-tenant self-host without the app role.

Also strengthens the v1-bundle test, which called importBundle directly and
supplied no schemaVersion — so it would have stayed green if v1 support were
dropped from the HTTP route, which is where the version gate actually lives.
It now imports through the route and checks an unsupported version is still
refused.

All three fixes verified to fail their tests when reverted (the JSON ones
with "invalid input syntax for type json"). Residual documented: JSON null
and SQL NULL are indistinguishable after `select *`.

270 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): bind an API key to its tenant, not to RLS

Round 7 on the export work found that the tenant FILTER I added does not
fix what I implied it fixed. requireAuth matched a key on prefix + hash with
no tenant predicate and leaned on RLS to keep the lookup inside the tenant.
That holds on the app role — but appDb falls back to the privileged
DATABASE_URL when DATABASE_URL_APP is unset, a configuration db.ts documents
as supported, and there RLS is bypassed. Tenant A's admin key then
authenticated against /t/tenant-b/..., and every tenant-scoped query below
it — including my newly-filtered export — faithfully served B's data.

ApiKey carries tenantId, so the lookup is now bound to the request's tenant
exactly. This is broader than export: it applies to every authenticated
route in that configuration. Prod is unaffected (DATABASE_URL_APP is set).
Verified by reverting: the foreign key authenticates and receives a 200 with
another tenant's full export.

Also corrects two claims of mine:

- docs said JSON-null-vs-SQL-NULL was the ONLY residual. It is not: pg
  parses jsonb through JSON.parse, so 9007199254740993 restores rounded and
  1e400 becomes Infinity, which JSON.stringify writes as null. Silent, unlike
  the null case. Both residuals are now documented and covered by tests that
  assert the CURRENT lossy behaviour, so making the parse lossless later
  fails those tests rather than leaving the doc stale.
- the JSONB-number import test passed with its fix reverted: pg prepares a
  JS number as the text "42", which is already valid JSON input, so the old
  guard was never exercised. Switched to a string, which is prepared bare
  and rejected.

273 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): bind the session cookie to its tenant too — round 7 fixed half

Round 7 bound the API-key path and I called the hole closed. It was not:
resolveSession matched on {prefix, tokenHash} with no tenant predicate and
leaned on the same RLS that the privileged-pool fallback bypasses. So on a
deployment with DATABASE_URL_APP unset, tenant A's admin cookie authenticated
against /t/tenant-b/... and resolvePrincipal handed back an admin principal
for B — the identical hole, in the other credential path.

resolveSession now takes `expectTenantId` as a REQUIRED parameter. Required
rather than optional deliberately: the failure mode here is a security
predicate someone forgets to pass, and an optional one invites exactly that.
`/session/home` passes an explicit null because it is deliberately
tenant-discovering and never grants a principal from the result.

The Admin/Partner revocation lookups are scoped to the session's tenant as
well — on the privileged pool a bare principalId would match another
tenant's row.

resolvePrincipal now throws when req.tenantId is missing rather than
defaulting to null, since null is resolveSession's any-tenant escape hatch.

Verified by reverting: the foreign session authenticates and receives a 200
with another tenant's full export.

273 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): signout must revoke the token it was given — round 8 broke logout

Round 8 routed signout through resolveSession, which it had just bound to
the request tenant. That silently broke logout in a real flow: `op_session`
is host-wide at path '/', so entering a second workspace overwrites it, and
a signout from a stale tab carries tenant B's token to tenant A's URL. The
tenant-filtered lookup found nothing, the route cleared the browser cookie
and returned 200 — and B's session stayed valid server-side. A client that
kept the token could keep using it.

"Logged out" that leaves a live token is worse than the cross-tenant read
the binding was added to prevent.

Signout now uses revokeSessionByToken: an exact-token revocation that
resolves no principal and applies no tenant predicate. Revoking a token the
caller already holds grants nothing and leaks nothing, so it needs no
binding — it is strictly destruction of the bearer's own credential. It is a
separate function rather than a flag on resolveSession precisely because the
two operations have different safety requirements, and one function serving
both is how the binding got applied where it did not belong.

Verified by reverting: the session survives "logging out".

Unrelated observation while running this: the API suite is mildly flaky
under parallel files sharing one Postgres — two runs failed different files
(export-roundtrip once, compound-rules once) and two consecutive runs then
passed 276/276. Pre-existing, not introduced here, but worth knowing before
trusting a single red run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: handoff brief for the remaining payment-audit work (#10, #12, #8)

Self-contained brief for a fresh agent to pick up the three deferred items:
the direct-Connect transfer double-pay (durable-intent restructure, with the
funding executor as the reference and the deterministic-key shortcut warning),
the rest of the funding-pipeline races, and export portability. Includes the
ground truth (prod facts, manual-payout context, commands, branch conventions)
and the 12 shipped audit PRs + post-merge actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: the three remaining audit items are shipped (#73, #74, #75)

Turns the handoff brief into a status record: what each item was, what
actually shipped, and — the part that is still open — the staging
exercises that have to pass before either money path is trusted.

Item A (#10, PR #73): planner/executor split with a durable payout intent
and a frozen commission set. Item B (#12, PR #75): the three funding
races plus a live-Stripe backstop for missed refund/reversal webhooks.
Item C (#8, PR #74): the three missing tables, per-table primary keys, a
portable SQL dump, and two array round-trip bugs the test found.

No code left on any of the three; the remaining work is the staging
checklists in docs/direct-connect-payouts.md and section H of the funding
staging runbook, plus the two post-merge prod actions for #62 and #63.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: bring the handoff up to date with five rounds of review

The doc still said "all three items are implemented, what's left is
staging" — written before any adversarial review ran. Since then each
branch has gained six review commits, two of the three original
implementations turned out to contain real double-pay paths, and several
of the FIXES did too.

Rewritten around what a fresh agent actually needs:

- the three PRs, what each contains now, and that nothing is merged;
- the pattern, stated up front: every round found defects in the previous
  round's fix, two coverage mechanisms looked like rotation without being
  it, and three tests passed with their own fix reverted. In this code a
  fix that looks like the property is not the property;
- what must happen before merge — #74 after #62 (PartnerProgram has no RLS
  on main and exports rely on it), #73 alongside #71, and the two staging
  matrices that are the actual gate;
- the load-bearing invariants, so they don't get casually broken: the
  epoch fencing, the two clocks, the lease-vs-request-budget relationship,
  the ambiguous-error classification, the commit-after-work cursor, the
  inbox lease answering 409;
- the known gaps as decisions with reasons, not an apology list.

Also records the operational trap that cost me a bad commit: piping lint
into `tail` takes the exit status from `tail`, so a failing lint does not
stop the commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the #71 pairing and record what actually blocks staging

Re-verified the handoff against the branches rather than trusting it.

The merge-order constraint "#73 needs #71 alongside it" was misattributed.
#71 fixes funding/executor.ts — the hosted-funding rail, which is #75's
file, not #73's. #73 never touches it (only funding/interlocks.ts) and its
own reconcile at payout-transfers.ts:609 already paginates. #75 still
carries main's unpaginated limit:100 at funding/executor.ts:458, so #75
without #71 ships the duplicate-transfer hole #75 exists to close.

Verified by extracting the merged blob and reading it — a clean merge-tree
proves no textual conflict, not that the fix survived. It does survive, in
either order. All file-sharing pairs merge clean.

Also records the thing the doc called "the actual gate" but never scoped:
there is no openpartner staging app. Building one isn't required — the
runbook allows `stripe listen`, and local tooling is ready — so the gate is
a Stripe test-mode key plus the runbook fixtures, not infrastructure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: #66 was red, merging is blocked by an unsatisfiable rule, migrations do auto-run

Findings from re-verifying the handoff against the repo rather than reading it.

#66 had been failing typecheck+test since 08-08 behind a real defect: the
negative-age guard was `ageMs < 0`, but Event.ts comes from Stripe's
whole-SECOND `created`, so a same-second conversion truncates to before its
click and was dropped silently. Fixed on the branch with a 5-minute skew
grace plus a test verified to fail when the grace is reverted.

main requires an approving code-owner review that a sole maintainer can
never satisfy — the likeliest reason 16 PRs have sat open. enforce_admins is
off, so --admin works.

Each PR is MERGEABLE against main yet two collide with the batch (#66x#61/#64
on integration.test.ts, #70x#69 on .env.example). Recorded with resolutions.
Full 11-PR combination validated: typecheck + lint clean, 292 tests green.

Corrected two load-bearing facts: prod migrations DO run on api boot (the
entrypoint has done so since April, and the July incidents were code/schema
mismatches, not drift), and the Stripe test key already exists in the
repo-root .env.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the staging gate is no longer hypothetical — both matrices have run

#73's six scenarios: 37 assertions, 0 failures. #75's H1/H5/H6/H7/H8/H9
(+H11 incidentally): 21 assertions, 0 failures. H2, H3, H4, H10 and H12 are
still unrun and the doc says so rather than implying the section is done.

No staging server was needed. "Staging" here means an instance talking to
test-mode Stripe, not a deployed environment, so both ran locally against
Postgres in Docker. Records the reusable test-mode fixtures (platform
account, onboarded and un-onboarded Connect destinations, the ACH customer
+ mandate, and how to top up platform balance) so nobody rebuilds them.

The finding that justifies having run this at all is about Stripe rather
than us: paymentIntents.search is eventually consistent, so a retry firing
inside the indexing window will not find an intent that exists. The frozen
idempotency key is the load-bearing defence inside 24h and search is the
fallback past it — assumed by the design, now confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: round 6 — ten defects fixed, and the assumption round 5 rested on

Round 5's fix assumed POST_COOLDOWN_MS could bound a Stripe request. It
cannot: stripe-node's timeout is socket-inactivity, resetting after each
request stage, so a slow-but-progressing POST has no wall-clock bound. Both
money rails now treat "I did not see it" as UNKNOWN rather than ABSENT —
the transfer executor no longer re-arms on elapsed time, and an empty
paymentIntents.search no longer frees allocations.

That trades liveness for safety, so both rails gained operator disposition.
The doc previously called operator tooling the most valuable follow-up; it
is now a prerequisite, because a hold with no release is a leak.

Records that four sweep-coverage mechanisms have now looked like rotation
without being it, and adds the lesson the round-6 revert checks taught:
three of my OWN tests passed with their fix reverted — a lock test where
blocking was not the property, a coverage test with no churn, and a staging
scenario that passed when it never reached Stripe. Running the suite is not
the check; reverting the fix and watching the test fail is the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: round 7 — twelve more, nine of them in round 6's fixes

Round 6 predicted that its changes were "mostly deletions, so less new
surface to breed a round 7". That was wrong in a checkable direction and the
doc now says so: the deletions produced no findings at all, and every
serious round-7 finding is in something round 6 ADDED — the operator
functions, the webhook fallback, the retry set.

The transferable lesson is that the dangerous change here is the one that
adds a mechanism, and "I removed unsound logic" is not grounds to expect a
clean next round.

Also extends the weak-test checklist with the four shapes round 7 caught:
an early guard short-circuiting before the code under test, a cursor that
wraps and covers everything by accident, a value that is already valid so
the guard is never exercised, and array indexing where order is not
guaranteed. Plus the one from round 7's own fixes — a lock test that asserts
"it blocked" proves nothing, because it will block eventually at some later
write; assert the value the lock protects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: rounds 8 and 9, the ten OPEN findings, and the decision that gates them

Round 8 is fixed and pushed. Round 9 is NOT: ten of its eleven findings are
open, and the Codex output they came from lived in a session scratchpad that
no longer exists — so they are reproduced in full in a new §0 at the top,
which is where a new agent should start.

The most important part is a correction to my own reasoning. I claimed the
verify-then-write gap between Stripe and Postgres meant those operations
could not be made safe, and I acted on that. It is wrong: the target is
CONVERGENCE, not atomicity, and this codebase already works that way — the
finalizer's own comment says the guarantee is that the reversal webhook can
always find the payout. resolveDuplicateReview simply is not wired into that
mechanism: the webhook fallback accepts only 'posted' and
'reconcile_required', so a reversal during duplicate_review is dropped and
the payout is recorded paid with the money gone.

That makes the highest-value outstanding change a one-predicate widening,
not a rewrite.

Also records the open product question that determines the rest — when a
payout freezes, must someone unfreeze it quickly, or can it wait for an
engineer — and Codex's answer that this, not a technical fact, is what
decides between deleting the operator surface and rebuilding it as durable
intents. Plus its round-10 estimates and the line worth carrying: review
count is not the same metric as operational safety. Fewer findings from
deleted code is not safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: round 10 — the decision is B, all ten round-9 findings closed

Keith answered §0.4: fast unfreeze, so B (durable operator-recovery
requests). Round 10 fixed all ten round-9 findings across #73/#75, then a
same-day Codex pass refuted six of seven claims about those fixes; its
thirteen fixable findings are fixed and pushed, and the two that remain
are the prove-absence limit — now with a transfer.created alarm and B's
request-ledger tombstone as the close. §0 rewritten: what happened, the
two findings that remain by design, the ordered next steps (merge first —
the PRs land squashed, so B stacks on main, not on them), and B's full
design ready to execute. #75 now adds a migration; the ground truth and
PR notes say so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: the batch is merged — all sixteen PRs on main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…t §0.4) + round-11 fixes (#76)

* feat(recovery): durable operator-recovery requests — decision B (audit handoff §0.4)

Operators insert an append-only OperatorRecoveryRequest; the existing
scheduler machinery applies it by calling the four operator functions
under their own fences. The request row is the tombstone for the
prove-absence limit: who accepted the risk, when, on what evidence.

- OperatorRecoveryRequest table (RLS'd operational sidecar, listed with
  the export gaps), TABLES entry + row types + app-role grant
- applyRecoveryRequests: skip-locked lease claim (DB clock both sides,
  token-fenced writes), tenant boundary check before anything else,
  outcome mapping (success → applied; definitive → refused; retryable
  trio stays pending, paced at 15m, capped at 10 attempts → failed +
  alert); wired at the top of the payout-transfers job and the funding
  collector tick so a released intent executes on the same tick
- §0.2 prove-absence close: applied direct-Connect requests get a
  24h transfer-group recheck (detector only) that alarms on a live
  transfer the payout no longer expects, even if the transfer.created
  webhook was missed
- Admin API: POST /payouts/:id/recovery, POST /funding/batches/:id/recovery
  (durable insert + one inline apply, synchronous verdict),
  GET /recovery-requests
- transfer.created added to the platform family map (registered on
  Destination A 2026-08-14; was unconstrained)

507 tests green (48 files), typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record §0.3 status — webhook cutover done, B built, auto-approve still unverified

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recovery): round-11 hardening — takeover truthfulness, poison-proof claim, append-only grant, fenced alarms

Codex round-11 refutation pass (4 of 6 findings confirmed, 2 accepted as
outside the trust boundary / by-design with an alert added):

- A claim that dies between its action committing and its settle no
  longer yields a lying 'refused' row: a takeover of an EXPIRED lease
  treats definitive refusals with suspicion, re-reads the target for
  THIS request's operator marker (req_<id> in lastError/failureReason),
  and settles applied:by_interrupted_attempt when found — else refuses
  annotated as ambiguous, with an alert. Claim is now select-then-update
  in one transaction so the old leaseAt (the takeover signal) survives.
- A hand-poisoned attempts counter at int4 max made the claim STATEMENT
  raise on every pass — at the top of the job that moves money. The
  increment is clamped, and claim/recheck failures are caught so the
  rail always runs.
- The app-role grant narrows to SELECT + INSERT: every update runs on
  the privileged pool, so append-only becomes a database property, not
  a convention. ensure-app-role revokes the wider grant on provision.
- Recheck alarms now emit only AFTER the token-fenced write wins, so a
  stale pass that lost its lease mid-listing cannot re-raise a settled
  alarm; recheckOne is side-effect-free.
- Pending requests on an instance with no Stripe client now alert every
  pass instead of holding silently.

510 tests green (3 new), typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…sion B)

The raw function calls stay documented as the break-glass path — the API
is the supported one: durable insert, inline verdict, scheduler retries,
audit tombstone, 24h group re-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… — all clear

H2/H3/H4/H10/H12 (23 assertions, 0 failures, 2026-08-14):
- H2: past the window the retry resolves purely by search-adopt, with
  create FORBIDDEN (test clocks cannot move key retention — documented)
- H3: a create failing WITH an intent stamps the id from the error and
  the retry confirms that PI (fbpc: key), never a second create
- H4: release vs in-flight create as two genuinely interleaved flows —
  orphan canceled, held on empty-is-not-absent, full release on resume
- H10: release halted on search-down holds safely; the resume landed on
  the payment-wins path with the charge id stamped (H11 verified again)
- H12: a REAL refund through the signed webhook route froze the batch
  mid-executor-run; exactly one transfer left, executor stopped

Adds STAGING_SCENARIOS=<subset> filtering. Runbook + handoff doc updated;
only a true multi-process lease race remains unexercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge the applied verdict

The audit marker and the operator-controlled reason land in the same
lastError/failureReason string, so a substring match let request B smuggle
req_<A> inside its reason and settle request A 'applied' on takeover. The
check now matches the per-kind stamped prefix from the START of the
string; everything before the marker is authentication-derived, so the
reason tail cannot reach it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c grant swap, repaired counters, discriminating staging assertions

Codex round-12 refutation pass over everything its round-11 pass never
saw (the round-11 fixes, the marker anchoring, the staging scenarios).
Five findings; four fixed, one accepted + documented:

- requestedBy could embed another request's marker prefix (schema-level
  text, app-role insertable): the operator string now sanitizes / and :
  out of the name segment at stamp time, so the first /req_ in any stamp
  is the stamping request's own delimiter. Tested through the REAL apply
  path — a malicious request genuinely applies, the victim must refuse.
- ensure-app-role's revoke-then-grant now runs in one transaction — a
  boot killed between the statements can no longer strand the app role
  with no privileges.
- Negative poisoned counters (least() kept them negative → cap false for
  ~2^31 claims) are repaired with greatest(...,0)+1 on both counters.
- Accepted: a crash between the recheck's fenced commit and its log line
  loses the LINE, not the finding — recheckOutcome durably records the
  orphan and transfer.created stays the primary alarm; documented in
  place rather than building an alarm outbox.
- All five staging scenarios sharpened so their named regressions fail:
  H2/H3 assert batch PROGRESS, H4 follows through to a decisive landing,
  H10 fails if stuck with a terminal PI after a full window of ticks
  (single-tick probes are invalid — search is not monotonic per request,
  observed live), H12 gains a control batch proving both partners are
  paid un-frozen. Re-run against real Stripe: 29 assertions, 0 failures.

513 unit tests green, typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New harness (staging-two-process-races.ts) spawns genuinely concurrent OS
processes with separate DB connections against real Stripe test mode:

- A: two executors race one FRESH intent end-to-end — exactly one
  transfer at Stripe, payout paid once, commission claimed once
- A2: reconcile+finalize duel over a held intent whose transfer already
  exists — finalized once against the found transfer, never re-posted
- B: 40 inbox events claimed under real contention (19/21 split) — no
  event claimed by both processes, all stamped processed
- C: 24 pending recovery requests split across two apply loops (4/20) —
  every request settled applied with attempts === 1

17 assertions, 0 failures. Also: auto-approve backlog VERIFIED cleared by
a read-only query against openpartner_prod (zero mature-unswept accrued
commissions) — §0.3 item 2 closed with direct evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gitimate-landing acceptance

Codex round-13 pass over the round-12 fixes (5 findings; 4 fixed, 1
accepted with evidence):

- HIGH fixed: request ids are schema-unconstrained, so id = <A's id>+'X'
  produced a stamp of which A's suffix-less marker (rearm / kept /
  all_reversed) was a strict prefix — those kinds now match by EXACT
  equality (their full stamp is known and never truncated); the
  reason-carrying kinds keep the prefix form, whose ':' after the id
  already terminates it. Test drives the full interleaving via the real
  apply paths.
- MEDIUM accepted: pre-sanitizer stamps would mismatch — but prod has
  ZERO recovery-request rows ever (verified), and the mismatch lands on
  the safe annotated-refusal branch. Documented; no legacy fallback on
  purpose (it would re-open the round-12 forgery).
- 3 LOW fixed in the staging script: H2 accepts funding_failed when the
  adopted PI is already canceled (release protocol owns it); H3 accepts
  a real confirm rejection with the attempt recorded; H4/H10 replace
  timing-sensitive checks with a DETERMINISTICALLY WARM search probe — a
  client whose search always returns the PI — so a live resume must act
  on that tick, separating cold search from a dead resume without racing
  Stripe's non-monotonic index.

Staging re-run (H2/H3/H4/H10): 20 assertions, 0 failures. 514 unit tests
green, typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs, worker-mode guards, rendezvous barrier

Codex round-14 pass over the two unreviewed commits (the two-process
harness + the round-13 fixes). Ten findings; nine fixed, one documented:

- HIGH: non-ULID request ids are refused at the door (invalid_request:id)
  — closes the whole id-shaped marker-forgery class (+X extension, :B
  colon injection, anything varchar(32) admits); exact/prefix matching
  stays as defense-in-depth.
- HIGH: a takeover-ANNOTATED refusal now schedules the §0.2 group
  recheck exactly like an applied request — a successful rearm's marker
  is legitimately cleared when the executor finalizes, so the takeover
  cannot tell 'never acted' from 'acted and completed', and the
  superseded generation's in-flight POST is precisely the late-lander
  the recheck exists for. Ambiguity earns the backstop.
- HIGH: the harness guards (test key, local DB) now run in WORKER mode
  too — RACE_WORKER=intent against a prod URL refused nothing before.
- MEDIUM: warmSearchStripe honors the query (blind stubs mask
  query-scoping regressions); H3 retrieves the PI to distinguish a
  genuine confirm rejection from confirm-ok-but-CAS-failed; reset()
  deletes Identity before Click (non-cascading FK made a half-wipe
  possible).
- LOW: workers rendezvous on ready-files (cold-start skew cannot hand
  one process the whole workload); Race A accepts the safely-posted
  slow-Stripe landing; balance-aware top-up + scratch-file cleanup.
- Documented: the harness's honest scope — money/claim exactly-once
  under real concurrency; internal fence discrimination is owned by the
  unit suites with staged seams.

Re-runs: two-process 17/17, H3/H4/H10 15/15, unit suite 515 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uery probes, deterministic Race A completion

Codex round-15 pass on the round-14 fix commit (3 findings, all fixed):

- The tenant-boundary check (and its ALERT) now runs before the id-shape
  gate: a hand-insert that was both malformed AND cross-tenant used to
  settle quietly as invalid_request:id, suppressing the loud
  tenant_mismatch alert. Boundary problems refuse loudly; shape problems
  refuse quietly. The id gate still precedes every operator-function
  call and marker use.
- warmSearchStripe matches the EXACT production query (metadata key +
  batch id, the shape findFundingPaymentIntent builds) — a substring
  check still certified a wrong-metadata-key regression.
- Race A no longer accepts the safely-posted landing as terminal: it
  completes the intent through the real finalizer with the listed
  transfer and holds the full paid-once assertions either way, so a
  broken live finalization can no longer false-pass.

Re-runs: two-process 17/17, H4/H10 7/7, unit suite 516 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants