Skip to content

Risk register

59 risks · 39 closed · 10 partial · 3 accepted · 7 open · 2 open at High or Critical · Risk board

Start-up (2)
Billing (16)
Security (20)
Migration (18)
Operations (3)
closedpartialacceptedopen
Open, High or CriticalAreaOwnerRating
FIN-11 Grace/cascade edge cases → indefinite team access / stranded memberBillingTechnicalHigh
SEC-2 Admin cookie key = SHA-512(JWT_SECRET); JWT_SECRET=dev is the compose defaultSecurityTechnicalCritical

Each risk has one owner. The plain-language version, with what blocks cutover, is the Risk board.

Full record: docs/RISK-ASSESSMENT.md

FMS Risk Assessment ​

Date: 2026-08-10 · Commit assessed: c8a74b2 (working tree: 21 modified + 1 deleted + 4 untracked including this document, nothing staged) · Assessor: engineering review, three parallel static audits (billing correctness, migration/cutover readiness, security & compliance). No exploitation was performed and no live Stripe/Mailchimp writes were made.

Re-assessed: 2026-08-14 at e5103c9; statuses re-synced 2026-08-26 against c7b6df3 (see progress/2026-08-25.md). Statuses updated in place with dated markers; the 2026-08-10 narrative text is retained as the historical record.

Security follow-up 2026-09-22: archive, FMS and WordPress review recorded below (SEC-11–16; SEC-7–9 re-checked). The archive bypass and malformed-path crash were reproduced against a local server in that review; the hand-off design was also found to expose an overly powerful token in the URL. Archive fixes are closed in code; rollout remains FMS → archive → plugin. Other findings remain open for the choices in DECISIONS §10.

AI connector 2026-10-03: the new Claude connector (DECISIONS #43) is SEC-17; a password leak found while building it is SEC-18, closed the same day. See progress/2026-10-03.md. A fourth review of the connector on 2026-10-04 found no high-severity flaw; its fixes are in SEC-17 (progress/2026-10-04.md).

Opn Payments 2026-10-13: the second card provider (Opn / Omise Thailand, DECISIONS #48), built as a test integration, adds FIN-14 (a charge whose answer is lost), FIN-15 (renewals on a 3-D-Secure-mandatory account), SEC-19 (the Omise keys and webhook secret) and OPS-3 (two providers to reconcile). None is on the PMPro cutover path: no live Omise key is connected. See progress/2026-10-13.md. 2026-10-14: Opn support answered in writing. Dynamic 3-D Secure (a charge with return_uri is challenged, without it is not) resolves FIN-15 for the test account and adds FIN-16 (no 3-D Secure on a renewal ⇒ the merchant carries a cardholder dispute; live needs Opn's risk-team approval); ref_id duplicate protection lowers FIN-14; "no webhook retries" makes the poll OPS-3's safety net. See progress/2026-10-14.md Opn's answers (WP6).

Segment-by-segment migration 2026-10-15: the move from PMPro now runs a segment at a time (DECISIONS #52): imported members are held until staff hand them over. It adds MIG-17 (PMPro's own cron and Mailchimp add-on still act on handed-over members until PMPro is off) and MIG-18 (outstanding PMPro gift and discount codes, audit #15), and re-words MIG-12's rollback, which is now per segment. See progress/2026-10-15-segment-migration.md and the audit progress/2026-10-15-migration-audit.md.

All-at-once plan 2026-10-27: the Business owner reversed the segment schedule: everyone is handed over on one morning (Hand over everyone), PMPro stays active for a soak of two to three days, then Phase C (DECISIONS #52 note 2026-10-27). No code changed. MIG-17's exposure is now the soak's days, not weeks of segments; the segment hand-back stays as the rollback. See progress/2026-10-27-all-at-once-cutover.md. The same day the owner answered two items: MIG-18 is closed (no outstanding PMPro gift or discount codes exist), and DECISIONS #21 is decided B, so FIN-3 now waits only on its code (dunning grace for a Stripe renewal gone past due). Later the same day both were built: FIN-3 is closed (a failed Stripe renewal enters dunning grace, progress/2026-10-27-dunning-grace.md), and MIG-12's outage half is built (DECISIONS #20 = C, 24 h: Member Gate 0.57.0 keeps each member's last answer through an FMS outage, progress/2026-10-27-outage-last-answer.md; rig check owed), so MIG-12 is closed with that check outstanding. MIG-13 gains a note: a persistent object cache may evict a member's kept answer early (it then fails closed).

Scope: financial/billing correctness, PMPro→FMS migration/cutover, and security & compliance. Operational/infra risk is covered only where it intersects those three.

Who owns a risk (2026-10-01). Every row in the scan tables names an owner, with the same roles as DECISIONS.md: Business owner, Marketing, Finance, Legal / compliance, and Technical (Thomas). The owner tracks it and says what it costs. Accepting a risk instead of fixing it is signed in writing by the Business owner, on the owner's advice. The plain-language version for colleagues is RISK-BOARD.md.


1. Executive summary ​

Update 2026-10-01 (status re-sync against the code and changelog). Ten scan-table statuses had fallen behind. Closed: MIG-6 (since 2026-08-10), MIG-9 (the per-account importer binds the real account) and MIG-11 (churn and acquisition built 2026-09-02). Narrowed, still Partial: FIN-9, FIN-12, OPS-1, SEC-8, MIG-4, MIG-12 and MIG-13; FIN-6's alert wording corrected. Nothing marked closed was found open. Each row now names an owner, and §5 gains SEC-13, MIG-12 and MIG-13 (member comms). Dated Re-synced 2026-10-01 notes sit under each changed entry.

Update 2026-08-10 (post-assessment). Both Tier 0 blockers and the config-lockdown gates are closed: the migration renumber is committed and guarded by a test plus CI; one cutover runbook is authoritative; APP_ENV=production now refuses to start a deployment carrying dev seeds, a placeholder JWT_SECRET, unsigned webhooks, a missing APP_ENCRYPTION_KEY or an insecure cookie; and .env.example is corrected. One operator action remains on those gates — set APP_ENV=production per deployment. SEC-3 was downgraded from Critical to Low and accepted: the keys concerned are restricted read-only live keys on the developer workstation, never committed, and cannot move money — an earlier summary that said otherwise was wrong. The verdict below still stands overall: the remaining gates are MIG-1 (the 172 posts), MIG-5 (category restrictions), MIG-9 (Stripe attribution), the cutover-day gaps, and the three billing decisions.

Update 2026-08-14 (re-assessment at e5103c9). A large slice of the cutover-day cluster has since shipped: one-off donations (/donate, brand-routed), gift memberships (/gift, full lifecycle) and discount codes (once/forever) landed 2026-08-10 (a7a340f) and were live-fire verified against the real TH+SG sandbox accounts 2026-08-13 — MIG-3's revenue surfaces are closed. Purchase-time 3DS/SCA completion landed 2026-08-13 (4908bb6) on all three card surfaces; renewal-time SCA closed 2026-08-15 (FIN-13). A live-mode Stripe write brake (OPS-2, 2026-08-12) and a nightly backup/DR pipeline (OPS-1, 2026-08-12, Sprint 8 closed) are in place. Gift newsletters are committed (391534a + d4be250), so the stash-loses-the-boot-fix hazard described under FIN-12 and §6 is gone. The prose below is the 2026-08-10 record; the register statuses and dated markers are current.

Update 2026-08-26 (re-sync at c7b6df3, committed 2026-08-25). The three billing decisions in §5 have moved: FIN-1 is closed — a plan's local price can no longer be edited once any account has a mapped Stripe price (assert_price_editable), and Consolidation now compares local vs mapped amount/currency; FIN-2 is Partial — all four discarded-cancel sites are fixed (admin cancel refuses instead of half-cancelling; the worker, unwind and both checkout cleanups enqueue a durable EnsureStripeCancel outbox order), with an integration test still owed; FIN-4 is Partial — a dead-letter list/payload/replay surface exists (Health → Dead letters), the alert masking does not yet re-page. SEC-8's raw-error-body item and MIG-10 are done. The container runs non-root with Postgres and the app port bound to loopback. Details in progress/2026-08-25.md.

FMS is architecturally sound in the places that are hardest to get right — webhook signature verification, per-account key isolation, SQL parameterization, member/team authorization, PCI tokenization (no card data ever touches FMS), and the WordPress plugin's nonce/authorization discipline are all correctly implemented. The system is not, however, ready to replace PMPro on the live site, for reasons that fall into three buckets.

Two hard blockers stop everything.

  1. The code at HEAD does not boot. Two migrations share version number 0025, and PostgreSQL rejects the second, aborting startup. The fix (renumber to 0026) exists only as an uncommitted working-tree change; it is clean for fresh databases and for databases that aborted at HEAD, but any database migrated from the pre-rename working tree (in practice, local dev) needs its _sqlx_migrations row reconciled by hand. This was found independently by two of the three audits. Until the fix is committed, no deploy of HEAD can start.
  2. There are two contradictory cutover procedures, and the documentation map points at the one that silently loses data. The "official" runbook drives a shell toolkit that hard-codes end-dated comp memberships to never expire; the newer parallel-run runbook forbids that toolkit for real cutovers. An operator following the docs as written will corrupt comp-membership expiry and forgo the safe rollback.

A cluster of things break on cutover day even after the blockers are fixed. The moment PMPro is deactivated: members receive no email of any kind (no receipts, no cancellation notice, no expiry warning — PMPro sent all of these); donations, gift levels, and discount codes stop working with no FMS replacement; PMPro category-level content restrictions silently become public; and Mailchimp tag/level maintenance freezes because no runbook step runs the bulk CRM push after import. Separately, an unresolved decision from July — 172 Community-readable posts becoming paid-only — sits on the critical path with no owner.

The security exposure is concentrated in deployment configuration, not application logic. The shipped docker-compose.yml defaults JWT_SECRET=dev and SEED_DEV_DATA=true; the second turns token-minting into an unauthenticated "give me a session for any email" endpoint, and the first lets anyone forge an admin cookie (the admin cookie key is derived from JWT_SECRET). Deployed hosts have been running with these plus ALLOW_UNVERIFIED_WEBHOOKS=true. None of these is a code bug — every one is a config value that must be locked down before and verified at cutover, and nothing in the startup path enforces it. (Since corrected: startup now refuses these under APP_ENV=production. The separate .env finding, SEC-3, was downgraded and accepted — see its entry.)

Underneath, several billing-correctness hazards are latent — most can't fire until a specific sequence occurs, but their blast radius is money moved wrongly with no detector. The sharpest: editing a plan's price in the admin console updates the pricing page and the upgrade/downgrade math but never updates Stripe, so new subscribers are charged the old amount indefinitely and reconciliation reports everything in sync.

Verdict: not go-live ready. The two blockers are non-negotiable. The cutover-day cluster and the three security defaults must close before PMPro is disabled. The billing hazards need an explicit decision each (fix now vs. accept-and-monitor). This document scores and itemizes all of it, and lists the exact subset that gates cutover in §5.


2. Methodology & scoring ​

Three independent static reviews swept the Rust service (src/), the schema (migrations/), the admin SPA (frontend/), the WordPress plugin (wordpress-plugin/), the specs and runbooks (docs/), and git state. Findings were merged and de-duplicated; where two audits found the same issue it is noted (this is the strongest signal). Every risk carries a file/line or document reference so it can be independently checked. A subsequent adversarial verification pass (2026-08-10) re-checked every register entry against the working tree; its corrections and additions are folded into the entries below.

Likelihood (L) and Impact (I) are each scored 1–5.

LikelihoodImpact (tuned for a payments system)
5Almost certain / already occurringSevere: unbounded wrong-money movement, indefinite unbilled access, or PII breach
4Likely under normal operationMajor: money moved wrongly in a bounded case, mass access loss, or key data-integrity drift
3Possible; needs a specific but ordinary sequenceModerate: reversible access/CRM drift, support burden, no money lost
2Unlikely; needs an uncommon combinationMinor: narrow, self-correcting, or cosmetic
1RareNegligible

Severity band = f(L, I): Critical if L×I ≥ 20 or (I = 5 and L ≥ 3); High if L×I ≥ 12 or (I = 5 and L = 2) or (I = 4 and L ≥ 3); Medium if L×I ≥ 6; Low otherwise. Override floor: any risk that enables unbounded wrong-money movement, indefinite unbilled access, or a PII breach is rated no lower than High regardless of likelihood.

Status is one of: Open (no mitigation), Partial (some mitigation, gaps remain), Accepted (understood and deliberately deferred), or Closed (with a dated marker). (The original 2026-08-10 pass closed nothing; since then B0/B1 closed 2026-08-10, SEC-3 was accepted 2026-08-10, MIG-3's revenue surfaces and purchase-time SCA closed 2026-08-13, OPS-2 closed 2026-08-12, MIG-14 closed 2026-08-14, on 2026-08-25 FIN-1 and MIG-10 closed with FIN-2, FIN-4 and SEC-8 moved to Partial, and on 2026-08-26 SEC-5, SEC-6 and FIN-5 closed.)


3. Risk register ​

Risk IDs: Blocker · FIN (billing) · SEC (security/compliance) · MIG (migration/cutover) · OPS (operational, added 2026-08-14). Each category has a scan table followed by detail entries with evidence, consequence, current mitigation, and required action.

3.0 Tier 0 — Deploy blockers ​

IDRiskLISevStatusOwner
B0Duplicate migration version 0025 → app will not start55CriticalClosed 2026-08-10Technical
B1Two conflicting cutover procedures; doc map points at the lossy one35CriticalClosed 2026-08-10Technical

Both Tier 0 blockers are closed. B0: the renumber to 0026 is committed, all 26 migrations verified to apply cleanly to an empty database, and a duplicate-version guard now runs in the lib test suite (src/db.rs::migration_versions_are_unique) plus CI. Detection and remediation for an already-migrated database are in migration-runbooks/sqlx-0025-fixup.md. B1: migration-runbooks/README.md is now declared authoritative, docs/README.md points at it, and the 2026-07-14 runbook carries a superseded notice scoping it to PMPro deactivation plus the rehearsal record. The 02_subscriptions.sh comp-expiry defect that made it lossy is fixed — PMPro's enddate is imported into current_period_end and the step reports how many grants carry one. The detail entries below are the original findings, kept as the record.

B0 — Duplicate migration version 0025. git ls-files migrations shows both migrations/0025_email_provenance.sql and migrations/0025_newsletter_delivery.sql committed in c8a74b2. sqlx 0.8.6 does not detect duplicate versions and _sqlx_migrations.version is a primary key, so applying the second version-25 row violates the PK and migration aborts at src/db.rs (sqlx::migrate!, called on every boot from src/main.rs). The working-tree rename to 0026_newsletter_delivery.sql is byte-identical to HEAD's 0025_newsletter_delivery.sql (verified: same SHA-256). Blast radius, precisely: both 0025 files landed in the same commit (c8a74b2), so no committed state ever had newsletter_delivery as the sole version 25. A database migrated at HEAD applied 0025_email_provenance and aborted on the duplicate — the renumber applies cleanly there. The only database that can have newsletter_delivery recorded as version 25 is one migrated from the pre-rename working tree — in practice the local dev DB — and there sqlx will see a checksum VersionMismatch on 0025 and attempt CREATE TABLE newsletter_delivery again under 0026. Found by both the billing and cutover audits.

  • Consequence: HEAD does not deploy at all; the local dev DB (and any environment migrated from the pre-rename tree) needs a hand reconcile.
  • Mitigation: none in place.
  • Required action: commit the renumber to 0026; for any environment that ran the working tree's old 0025_newsletter_delivery, hand-write a _sqlx_migrations fixup (delete the stale row / correct the checksum) as part of the deploy. Add a CI check that fails on duplicate migration prefixes (see MIG-13).

B1 — Two contradictory cutover procedures. docs/README.md line 13 links docs/superpowers/specs/2026-07-14-cutover-runbook.md as "the live PMPro → FMS cutover procedure," and lists docs/migration-runbooks/ only under "test rigs." But the migration-runbooks README (newer, 2026-07-24/29) explicitly demotes the shell toolkit the older runbook depends on: migration/02_subscriptions.sh hard-codes current_period_end = NULL for comp rows, so end-dated comp memberships silently lose their expiry and never lapse. The two docs also disagree on the rollback primitive (re-point webhooks + paste page content back, vs. untick a master switch).

  • Consequence: following the doc map corrupts comp-expiry data and uses an unsafe rollback.
  • Mitigation: the correct procedure exists (parallel-run), it is simply not the one the map points to.
  • Required action: pick one runbook as authoritative, update docs/README.md to point at it, and archive or clearly mark the other. Reconcile the rollback story into one procedure.

3.1 Financial / billing correctness (FIN) ​

IDRiskLISevStatusOwner
FIN-1Admin plan-price edits never reach Stripe35CriticalClosed 2026-08-25Technical
FIN-2Discarded Stripe cancel_subscription results → double-bill / charge-without-access35CriticalClosed 2026-10-02Technical
FIN-3past_due access-set split demotes paying members; CRM paths disagree43HighClosed 2026-10-27 (#21 B, dunning grace built)Business owner · also Finance
FIN-4Dead-letter queue is one-way and alert-masked34HighClosed 2026-10-02Technical
FIN-5Stable Stripe idempotency keys on checkout34HighClosed 2026-08-26Technical
FIN-6hold_on_backup = unbounded manual-reconcile hold, unbilled access25HighClosed 2026-10-02 (link instead of hold)Technical · also Finance
FIN-7No Stripe HTTP test seam → every money-moving write uncovered34HighClosed 2026-10-02Technical
FIN-8invoice.payment_failed on an untracked sub dropped, no reroute34HighClosed 2026-10-02Technical
FIN-9Unhandled webhook families permanently unrecoverable34HighClosed 2026-10-02Technical
FIN-10Reconciliation blind spots (amount, refunds, disputes, MC reverse)34HighClosed 2026-10-02Technical · also Finance
FIN-11Grace/cascade edge cases → indefinite team access / stranded member25HighOpenTechnical
FIN-12Gift newsletters (committed 2026-08-10): CRM N+1 at scale, token retention, opt-out asymmetry33MediumPartial 2026-10-01 — only the shared-inbox opt-out is left (DECISIONS #23)Legal / compliance · also Marketing
FIN-13Renewal-time SCA: invoice.payment_action_required unregistered → SCA-blocked renewal looks like a plain decline33MediumClosed 2026-08-15 (purchase-time Closed 2026-08-13)Technical
FIN-14An Opn (Omise) charge is made but FMS never hears the answer: a timed-out POST /charges may have charged24MediumPartial 2026-10-14 (attempt ledger + reconcile poll built; ref_id sent on every charge; re-send not used until live-fire opn_ref_id_dedupe passes on Omise)Technical
FIN-15A 3-D-Secure-mandatory Omise account cannot renew a saved card unattended: every Opn renewal waits for the member14LowResolved for test 2026-10-14 (Opn: dynamic 3-D Secure — no return_uri, no challenge; renewals send none). Live: needs Opn's risk-team approval (FIN-16)Business owner · also Technical
FIN-16Opn renewals run without 3-D Secure, so a cardholder dispute on one is the merchant's loss; dynamic 3-D Secure on the live account needs Opn's risk-team approval23MediumOpen 2026-10-14 (to accept or not before Opn is primary for memberships)Business owner · also Finance
FIN-1 — Admin plan-price edits never reach Stripe. update_plan_full
(src/repository/plans.rs:99) writes plans.unit_amount but never
touches plan_prices; ensure_price (src/services/plans.rs)
short-circuits on the cached plan_prices row. So an admin editing a plan's price changes the pricing
page, previews, and the upgrade/downgrade arithmetic (effective_amount,
src/services/membership.rs:244) while **every new subscriber is
charged the old Stripe price, indefinitely.** Reconciliation reports in_sync because it never
compares amounts (FIN-10). The obvious operator rescues are all no-ops: sync_account_products is
deliberately idempotent via plan_prices, so "re-sync" does nothing; the manual map-price endpoint
records a newly mapped price as an alias without displacing the canonical row
(src/admin/api.rs:1127); and create_price uses the fixed idempotency
key plan-{id}-acct-{id}-price, so even a cleared cache row replays the old price inside Stripe's
24 h window. Secondary: upgrade = target.unit_amount > current_amount
(membership.rs:593, duplicated at
:822) can invert, sending an "upgrade" without
pending_if_incomplete so access is granted before payment. Team plans have an escape hatch
(rebuild_team_pricing); individual plans have none.
  • Required action: make update_plan_full re-run ensure_price (create a new Stripe price and repoint plan_prices — with a fresh idempotency key), or block price edits in the UI and route them through a create-new-price flow. Add amount/currency to reconciliation (FIN-10).
  • Closed 2026-08-25 (c7b6df3): the second remedy was built. services::plans::assert_price_editable refuses a local amount/currency change once any account holds a mapped price for the plan (admin API and edit form alike; non-price fields and unmapped plans unaffected), pointing the operator at create-in-Stripe → map on Connections → edit local to match. reconcile_plan_prices + the Consolidation Plan prices ↔ Stripe panel compare local vs mapped amount/currency (aliases excluded), closing the FIN-10 blind spot that hid this. tests/plan_price_guard.rs. Prices are still not propagated to Stripe — a price change is a new Stripe price.
  • Follow-up (owner, 2026-08-29): the guard is an interim control. Owner wants an FMS price edit to update Stripe as well (create-new-price + repoint on every mapped account, archive the old one, existing subscribers unaffected). Scheduled in docs/ROADMAP.md "Where we are → Next" item 4.
  • Guard replaced by propagation 2026-10-16 (docs/progress/2026-10-16-production-env.md Part 2): an individual plan's price edit saves and, in the same transaction, queues one PlanPricePropagate outbox event per Stripe account holding a canonical price (services::plans::update_plan). The dispatcher (services::price_propagation, serial lane) creates a new price on the plan's product (idempotency key per event, naming the amount), then in one transaction keeps the old price as a plan_price_aliases row at its old amount, repoints plan_prices, drops the plan's forever discount prices on that account and queues one ArchiveStripePrice per dropped price. No subscription is touched. The live brake and a read-only connection refuse it; the event dead-letters and is replayed from Health → Dead letters. ensure_price and ensure_discount_price keys now name currency + amount (the 24 h replay above is closed). Still refused: a mapped team rung (the seat ladder owns it; Rebuild team pricing) and an open team-invoice commitment. Consolidation's panel counts propagations waiting and dead. tests/plan_price_guard.rs, tests/plan_price_propagation.rs.

FIN-2 — Discarded or bypassed Stripe cancel results. Four call sites: the reroute worker (src/reroute/worker.rs:191, let _ = on both dunning-suppress and cancel), checkout sync-failure cleanup (src/services/subscriptions.rs:459, let _ =), the backup-recreate unwind path (src/reroute/worker.rs:595, let _ = — arguably the worst, since a failure there is exactly the two-accounts-billing case), and admin cancel (src/services/members.rs:218 — here the cancel result is propagated; the defect is the enclosing if let Ok(client), which skips the entire Stripe block when client_for errors on a key-decrypt failure, then revokes access anyway). post/delete Stripe calls have no retry. If the origin cancel fails on the reroute path, the member is recreated on a backup account while the original keeps billing — charged on two accounts, one local row; nothing detects it (the duplicate check reads local rows only). On the admin path, the mirror case: access revoked while Stripe keeps charging.

  • Required action: stop discarding these results — on cancel failure, do not proceed to recreate / revoke; surface an alert and leave state reconcilable. Add retry to post/delete. Add a cross-account "two live subs for one member" detector to reconciliation.
  • Update 2026-08-12: the live-mode write brake (OPS-2, a967940) now gates every mutating Stripe call against a mode='live' account behind ALLOW_LIVE_STRIPE_WRITES=true — it bounds the accidental-live-write face of this family but does not fix the discarded-result logic above.
  • Partial 2026-08-25 (c7b6df3): all four sites fixed. Admin cancel refuses when no client can be built (local row untouched; tests/live_mode_brake.rs asserts it) and treats already-gone (reroute::cancel_already_done) as success. The worker origin cancel, the backup-recreate unwind and both checkout cleanups (sync failure, finalize) enqueue a durable EnsureStripeCancel outbox order on failure (reroute::ensure_cancel_event → RerouteDispatcher::ensure_stripe_cancel: retry with backoff, dead-letter on exhaustion, replayable from the FIN-4 screen). Remaining: no integration test drives ensure_stripe_cancel; the cross-account two-live-subs detector is not built.
  • Test 2026-10-02: tests/ensure_cancel.rs drives the durable order through RerouteDispatcher::dispatch against the in-process Stripe mock (tests/common/stripe_mock.rs, over the new StripeRegistry::with_base_url seam — FIN-7): void then cancel, both on the origin account's own key; 404 is done; a 5xx retries; a failed void never blocks the cancel; a live account with the brake unarmed refuses before any request and dead-letters. The detector is still to land.
  • Sites tested 2026-10-15: the 2026-10-02 suite drove only the dispatcher; nothing proved that a site writes the order. tests/ensure_cancel.rs now covers all four enqueue sites through their real entry points against the mock (once fails a single DELETE /v1/subscriptions/{id} with a 500): the structural reroute (reroute_structural), the backup-recreate unwind (reroute_unwind), the checkout decline cleanup via orchestrate_stripe_subscription (checkout_sync_failure) and finalize_pending_checkout (checkout_finalize_failure). Each asserts the site's own outcome (the member is rescued / in grace / gets the card error, never the cancel error), exactly one pending EnsureStripeCancel row with the right account, subscription, invoice and source, and that the real worker (outbox::worker::drain_lane) drains it into a cancel on the origin account's key once Stripe answers. A fifth test proves a live-brake dead letter replayed with requeue_dead after arming voids and cancels on the live key. No product bug. Noted: AppError::BadRequest is not permanent, so a malformed order (impossible from the typed ensure_cancel_event) would retry out its attempts before dead-lettering, as every other dispatcher's does. docs/progress/2026-10-15-cutover-code-owed.md.
  • Closed 2026-10-02: the cross-account detector is the reverse pass of reconcile_stripe, which already lists every account's live subscriptions: grouped by lowercased customer email (customers are per account; the address is the only join), a member is double_billed_in_stripe when Stripe bills two or more live individual memberships (an unmapped price counts as one), or the same team plan on two accounts. Two different teams, or a personal plan beside a team, are never flagged. Every subscription involved is a drift row with a detail listing them all; double_billed_members heads the report, the digest and the export. No automatic fix — Consolidation shows a red banner, Billed twice rows with Cancel one in Stripe instead of Fix, and Fix all drifting skips them. Pure rule reconciliation::double_billed (unit-tested); tests/reconciliation_outbox.rs (a_member_billed_on_both_accounts_is_reported, against the mock).

FIN-3 — past_due access-set split. The "member is live" predicates include past_due (src/repository/subscriptions.rs:351,436) but access_rows / active_plan_codes exclude it (:663,:692). On any full resync (MemberTagResync, consolidation apply-fix, email/newsletter change), desired_state_for_address sees no active codes → every tag off and the member is demoted to Community in Mailchimp (level_for_codes([]) → COMMUNITY_LEVEL, src/services/pmp_level.rs:145). The webhook delta path (src/crm/worker.rs:193) keeps the same member's tags on — the two paths disagree, last writer wins. The contradiction is visible within a single push: the same delta handler computes PMPLEVELID from active_plan_codes (past_due excluded, crm/worker.rs:218), so one Mailchimp request writes Community level while switching the paid tags on — a self-inconsistent contact in one call. FMS deliberately leaves transient declines to Stripe Smart Retries (which usually recover in days), so this fires on ordinary retryable declines, and an operator clicking the reconciliation "tag fix" strips a paying member's newsletters.

  • Required action: decide whether past_due confers access + tags (it should, during the retry window) and make the two CRM paths agree on that single definition. (Decision owned by the Business owner: DECISIONS.md #21, 2026-10-01.)
  • Decided 2026-10-27 (#21 B, Business owner): a failed Stripe renewal keeps access and tags for up to 30 days while Stripe retries. Code owed before the morning (~1 day): the webhook that reports a renewal past_due (ordinary dunning, not an SCA hold or a structural reroute) moves the row into dunning grace (status = grace, grace_source = 'dunning', grace_until = later of paid-through and now + GRACE_PERIOD_DAYS, the shape the cutover import and Opn renewals already use); a successful retry returns it to active, Stripe's final failure ends it. Grace rows are live in access_rows / active_plan_codes, so both CRM paths then agree and the split closes.
  • Closed 2026-10-27 (built the same day, progress/2026-10-27-dunning-grace.md): the Stripe webhook maps a renewal past_due on a billing_source = stripe row with no live SCA hold to grace / grace_source = 'dunning' in the same transaction (services::grace::webhook_local_status, enter_dunning_grace); the deadline is the later of the period start (Stripe advances the period on a failed renewal) and now + GRACE_PERIOD_DAYS; a successful retry returns it to active, Stripe's cancel or unpaid ends it, the grace sweep's local lapse is the backstop. The reroute worker still handles a dunning-grace row; a Stripe dunning-grace row blocks a second checkout; adoption and Consolidation's Stripe fixes store past_due the same way. Grace rows are in the access set, so the delta and full CRM paths agree. Suites webhooks, grace, reroute, migration_segments, consolidation and others green. Follow-ups (not blocking): the cutover importer's deadline still uses the period end (bounded by Stripe's own cancel), and the console member page does not show the grace end date.

FIN-4 — Dead-letter queue is one-way and alert-masked. mark_dead (src/repository/outbox.rs:142) sets status='dead'; there is no endpoint anywhere to list, read, or requeue a dead event (only counts are read, e.g. src/services/health.rs:111). The health alert for dead > 0 is critical: false and reconcile_alerts only emails on a status transition (health.rs:113), so the first dead letter masks every subsequent one — and the alert email is itself an outbox event, so a broken dispatcher can dead-letter its own alarm. Time-to-dead-letter is ~2.4 h (12 attempts, exponential backoff), so a Mailchimp/SMTP outage longer than that permanently destroys queued events. A dead TeamSeatChangePaid means a team paid a prorated invoice and never received its seats, unrecoverable without hand-written SQL.

  • Required action: build a dead-letter admin surface (list + payload view + requeue); make dead-letter count a critical, re-alerting signal; consider raising max_attempts / dead-letter TTL for CRM events.
  • Partial 2026-08-25 (c7b6df3): the admin surface exists — outbox::{list_dead, requeue_dead, requeue_all_dead}, GET /admin/api/outbox/dead, POST …/dead/{id}/replay, POST …/dead/replay-all, and the SPA's Health → Dead letters tab with full payloads (Dashboard metric links to it). A replay resets attempts and keeps last_error. tests/reconciliation_outbox.rs. Remaining: the dead > 0 alert is still non-critical and transition-only (first dead letter masks the next); max_attempts / time-to-dead unchanged.
  • Closed 2026-10-02: the alert re-fires whenever the dead count grows — an open alert stores the count it fired on (system_alerts.level, migration 0066; health::alert_changed), so the first dead letter can no longer hide the next; a falling count updates silently. A dead event on the money lane turns outbox_queue down (red), Mailchimp- only dead letters degraded; the detail names what died (outbox::dead_counts_by_type: "3 dead-lettered event(s): TeamSeatChangePaid ×1, AdminEmail ×2 — replay them on Health → Dead letters"). Deliberately not critical in the sense of the public /health/status 503: one refused address must not page every uptime monitor; the alert email plus HEALTH_ALERT_WEBHOOK_URL carry it (the webhook is the out-of-band path if the email dispatcher is the thing dying). max_attempts is unchanged — with every dead letter alerting and Replay all one press away, a longer Mailchimp outage costs a replay, not data. tests/reconciliation_outbox.rs (every_new_dead_letter_alerts_again, crm_only_dead_letters_are_amber), unit tests in services::health.
  • Code review 2026-10-03: still Closed. Dead AdminEmail events are listed but no longer raise the alert level or the colour, so a broken mail relay cannot feed its own alert every tick; a failed breakdown query keeps the row red instead of flapping. docs/progress/2026-10-03.md.
  • Re-audit 2026-10-15: still Closed; the 2026-10-03 line overstated one case. Dead AdminEmail events never raise the level and never make the row red, but on their own they turn it amber at level 0 and alert once — kept on purpose, since nothing else reports a relay refusing admin mail (member_email watches only the member queue) and the webhook is the one channel that still works. system_alerts.level is the count last recorded (every tick), not the one last alerted on: after a single replay the next dead letter is measured from the lower count. New tests/reconciliation_outbox.rs::the_alert_webhook_fires_on_every_re_alert proves HEALTH_ALERT_WEBHOOK_URL is POSTed for every alert the email goes out for (amber → red, each growth, growth after a fall, the recovery after Replay all, the next dead letter after it); dead_alert_emails_do_not_feed_their_own_alert now asserts the amber-alone case. Residual, not fixed: a replay and an unrelated new death inside one monitor interval (HEALTH_CHECK_INTERVAL_SECONDS, 30 s) leave the count unchanged and send nothing — closing it needs the newest dead letter's time stored beside the count (a migration).
  • Follow-up 2026-10-06 (stale replay): a replay re-runs the stored payload verbatim, and a PaymentFailedReroute is the one payload where that could move money on old news. The live-write brake dead-letters every structural reroute on purpose ("replay once armed"); if the member paid in the meantime, the row was active again, passed the worker's status gate, and the structural path cancelled the paid Stripe subscription and rerouted it or put the member in grace. The worker now re-reads the invoice from Stripe on every run (reroute::stale_invoice_state): paid ⇒ an audit row stale_invoice_paid (classification NULL, also the idempotency anchor) and nothing else; an unreadable invoice is a retryable error, never a guess. Void, uncollectible and missing invoices still proceed: the structural path voids the invoice itself before its fallible steps, so its own retry must not read that void as a settlement, and Stripe writes an invoice off exactly when the unpaid rescue is due. The payload now carries stripe_event_id / stripe_event_created so a dead letter shows its age. The other two event families were already order-safe: subscription status through the last_stripe_event_at watermark, invoice rows through the no-regress upsert. tests/reroute_link.rs (a_stale_failure_for_a_paid_invoice_changes_nothing, a_retry_after_a_partial_reroute_still_finishes, an_uncollectible_invoice_is_still_rescued, a_voided_invoice_is_still_rescued, a_missing_invoice_is_still_rescued, an_unreadable_invoice_is_retried_not_guessed). docs/progress/2026-10-06.md.

FIN-5 — Stable Stripe idempotency keys on checkout.src/services/subscriptions.rs:251 builds orch-{user}-{account} and derives …-sub-{plan.id} for the create call, whose body carries the per-attempt default_payment_method. A member whose first card declines and who retries with a different card (same plan/account, within Stripe's 24 h idempotency window) re-sends a key first used with different parameters → Stripe rejects it → locked out of checkout for 24 h. Retrying with the same card replays the cached response, and FMS may persist a local row for a subscription that handle_sync_failure already canceled → access granted, nothing charging. Note the divergence from tier-amend's random suffix (membership.rs:1055) is a pair of opposite deliberate trade-offs, not an oversight: the checkout key's stability is load-bearing double-charge protection, and tier-amend documents why it went the other way (membership.rs:1046-1054, a stable key there 409'd on A→B→A flips). Blindly copying the random suffix to checkout would reintroduce the double-charge risk the stable key exists to prevent.

  • Required action: resolve the retry-with-a-different-card case explicitly — e.g. include the payment-method id in the key so a different card gets a fresh key while an identical retry still replays — and reconcile the replayed-response-after-handle_sync_failure-cancel window rather than persisting the cached response blindly.
  • Closed 2026-08-26: services::subscriptions::checkout_idempotency_key folds everything that shapes the request body — price id, payment-method id, quantity, billing-cycle anchor — into the key as a hashed suffix on the stable orch-{user}-{account}-sub-{plan} prefix. An identical retry still replays (the 3DS park path depends on it); a different card, seat count, forever price or gift anchor is a fresh request instead of a 24 h idempotency_error lock-out. Unit-tested (checkout_key_*). The second concern was re-traced and does not fire: a replayed response returns the cached body, so a subscription that came back incomplete and was canceled replays as incomplete again and goes back through handle_sync_failure (already-gone = done) — it is never persisted as live. An HTTP-level assertion of the header still waits on the R3 Stripe mock (FIN-7).

FIN-6 — hold_on_backup unbounded manual-reconcile hold. record_hold_with_event (src/repository/reroute.rs:166) writes status='grace' with grace_until = NULL and clears the Stripe id. The sweep predicate is grace_until < now() (src/services/grace.rs:54) — NULL never matches — and grace is in every access set. To be clear, the sweep-proof NULL is documented design, not a bug: reroute.rs:153-155 chooses it deliberately (money may be moving that FMS can't see; auto-lapsing would revoke a paying member), is_backup_hold() (src/domain.rs:180) is built on the shape, and admin cancel refuses such rows. The risk is what surrounds the design: the hold waits on a manual reconcile with no deadline and one weak alert, and its trigger customer_has_live_subscription (src/stripe.rs:727) is per-customer, not per-plan, and counts past_due / unpaid / paused / incomplete as "live." So a member whose backup subscription is itself unpaid or for a different product gets open-ended, sweep-proof access with members-only tags, no local Stripe id, and nobody charging them. The only signal is one admin email (2026-10-01: durable via the outbox, see the re-sync note).

  • Required action: make the liveness probe plan/price-specific; give backup holds a bounded review deadline the sweep can see; make the hold alert durable.
  • Re-synced 2026-10-01: still Open. One correction to the text above: the hold alert is already a durable outbox email, enqueued with the hold (src/reroute/worker.rs:497-531), so it is not lost when SMTP is unset. The unbounded hold and the per-customer liveness probe are unchanged (src/repository/reroute.rs:185, src/stripe.rs:1022-1046).
  • Closed 2026-10-02 (Thomas: "link instead of hold"): the worker lists the customer's backup subscriptions (StripeClient::customer_subscriptions, a read) instead of asking "anything live?". Any non-terminal one still blocks a recreate. One that pays for this plan — active > trialing > past_due, price mapped to the same plan code — is linked: reroute::record_link_with_event repoints the row (account, customer, Stripe id, status, seats, period; grace cleared; same row id so a team stays attached) in one transaction with the audit (linked_on_backup), a reworded AdminEmail ("linked, nothing to do") and a CRM resync; from there Stripe's webhooks own it. If another local row already tracks that subscription, this stale row is closed instead. Nothing that pays for this plan (unpaid, paused, another product) → the ordinary dated grace the sweep can close, with backup_has_other_subscription in the outreach email. hold_on_backup and record_hold_with_event are gone; is_backup_hold() and the cancel refusal stay as a guard for legacy rows (list them: SELECT id FROM subscriptions WHERE billing_source='stripe' AND status='grace' AND grace_until IS NULL AND stripe_subscription_id IS NULL). tests/reroute_link.rs (worker end to end on the mock: linked, other plan, unpaid, already tracked), tests/reroute_hold.rs (the link primitive, outreach).
  • Code review 2026-10-03: still Closed, four gaps fixed. A link onto a row canceled meanwhile commits nothing and instead adopts the backup subscription with an actionable email (never "nothing to do"); every backup is checked for a paying subscription before any create, so a third account is never charged on top; only the mapped customer's subscriptions match, through canonical, alias and forever-discount prices, retired plans included; a linked team moves to the backup account. docs/progress/2026-10-03.md.

FIN-7 — No Stripe HTTP test seam. tests/common/mod.rs:1 states it: money paths are "deferred (they need a mockable Stripe seam) and are asserted structurally." There is no HTTP mock in the tree and base_url is private, so create-subscription, seat amend, tier amend, cancel, portal session, ensure_price, and reroute-recreate have zero automated coverage; a one-token change (quantity, price id, account id, proration flag) ships silently. The live-fire rehearsal exercises only checkout-create paths Update 2026-08-14: the 2026-08-13 live-fire session (docs/progress/2026-08-13.md) exercised seat amends 3→5→4 in place (real +$490/−$238 prorations), cancel, both discount injections, donations, and the full gift lifecycle against the real TH+SG sandbox accounts — portal sessions are now the only Stripe write path never exercised from in-repo code. A with_base_url test seam exists since 2026-08-12 (progress/2026-08-12), so the mock is buildable. And 42678fe (2026-08-13) is the strongest argument yet for the R3 mock seam: one-off PaymentIntents were rejected outright by Stripe (automatic_payment_methods[enabled]=false with no payment_method_types → 400) — /donate and /gift had never worked against a real account, and the integration suites could not see it. Automated coverage of the money-moving request bodies is still zero, so this stays Partial.

  • Required action: introduce a Stripe base-URL override or client trait so a mock server can assert request bodies; cover the amend/cancel/portal paths at minimum.
  • Closed 2026-10-02: StripeRegistry::with_base_url (test-only; main never sets it) points every per-account client at tests/common/stripe_mock.rs, an in-process Stripe that records method, path, query, bearer, Idempotency-Key and the decoded form (duplicates kept), answers scripts (per path, per account key, one-shot) before Stripe-shaped defaults. tests/stripe_requests.rs asserts the request bodies of every money path: checkout create (customer, card attach, product, price, subscription — fields, keys, TH's bearer), AMEX routed to SG with nothing written to TH, an AMEX card collected on TH refused before any write, ensure_price once then reused, the admin cancel (void then cancel; 404 done; 5xx refuses with no half-cancel), the billing portal (the subscription's account and regional customer, a fresh key per click — the last write path that had never run from in-repo code), the seat amend (the existing item, ladder price, quantity, always_invoice, pending_if_incomplete only on an upgrade, the documented key), the tier amend (quantity 1, unique key per attempt), the structural reroute (void + cancel on TH, recreate on SG with error_if_incomplete, proration_behavior=none, no free-cycle anchor), and the one-off PaymentIntents for donate and gift (automatic_payment_methods[enabled]=false withpayment_method_types[]=card — the 42678fe regression — confirm, use_stripe_sdk, no customer for a guest; a 3DS donation parked, not paid). The same mock now also carries tests/ensure_cancel.rs, tests/reroute_link.rs, tests/reconciliation_money.rs and tests/integration_health.rs.

FIN-8 — Untracked-subscription payment failure dropped. invoice.payment_failed for a subscription FMS doesn't track is warn-and-dropped with no PaymentFailedReroute enqueued (src/api/handlers/webhooks.rs:550). Dashboard-created, rescue-script-created, or adoption-pending subscriptions therefore never enter grace or the outreach queue — Stripe dunning drops them unwatched. Likelihood is elevated during cutover, when adoption backlogs are common. Two partial mitigations narrow (but don't close) the gap: the invoice row is still written before the early return, and the sibling customer.subscription.updated event does queue adoption — so the subscription is likely adopted eventually, but the reroute for this decline is permanently lost.

  • Required action: enqueue adoption + a deferred reroute check for payment failures on unknown subs, rather than dropping them.
  • Closed 2026-10-02: capture_payment_failed now queues, in the webhook's transaction, the adoption (source: invoice.payment_failed; idempotent beside the sibling subscription.updated's) and a system PaymentFailedReroute flagged after_adoption. The worker returns a retryable error while the row is missing — the outbox backs off, and a subscription never adopted dead-letters where FIN-4's alert sees it — then applies the checks the webhook makes for tracked rows (SEC-5 account pinning, the 3DS-trail filter) before the normal path. tests/webhooks.rs (an_untracked_payment_failure_queues_adoption_and_a_deferred_reroute), tests/reroute_link.rs (a_deferred_reroute_waits_for_its_adoption).
  • Code review 2026-10-03: still Closed, false alarms removed. A declined signup's first invoice queues only the adoption (checkout cancels it and makes no row), and a deferred reroute whose subscription is canceled, incomplete or deleted on Stripe is consumed instead of dead-lettering and alerting. docs/progress/2026-10-03.md.

FIN-9 — Unhandled webhook families permanently unrecoverable. (Re-statused Partial 2026-08-13.) The processed_webhooks idempotency marker still commits in the same transaction as the (no-op) apply (webhooks.rs:92), so once an unhandled event id is recorded, adding a handler later cannot replay it. Since the original assessment, two of the three dropped families closed: dispute events are registered and handled (charge.dispute.created / closed / funds_withdrawn, src/stripe.rs), and purchase-time SCA completes end-to-end (4908bb6, 2026-08-13: /checkout, /donate and /gift park rows that grant nothing, the finalize endpoints re-read the verdict from Stripe, the finalize/webhook race is idempotent, tests/sca.rs covers 12 cases, live-verified through real challenge iframes), and renewal-time SCA closed 2026-08-15 — invoice.payment_action_required is registered and handled (FIN-13). Still dropped: partial refunds (charge.refunded marks only full refunds; partial refunds leave status and amount untouched, webhooks.rs:620).

  • Required action: record the idempotency marker only for events actually applied, or keep raw event archives so future handlers can backfill. Handle partial refunds before cutover; disputes and both halves of SCA are done. Note the marker problem is why registering a new event is time-sensitive on its own: anything an endpoint receives and drops today cannot be replayed once a handler exists.
  • Re-synced 2026-10-01: the partial-refund condition is met — charge.refunded queues SyncRefunds for full and partial refunds (2026-09-12). What remains is the design: the processed marker is written for every event and no raw events are archived, so a family FMS ignores today cannot be replayed later.
  • Closed 2026-10-02: every verified event is kept whole in webhook_events_raw (migration 0067), written in the same transaction as its mark; listened says whether FMS subscribed to the type then. Kept WEBHOOK_RAW_RETENTION_DAYS (90), pruned by services::retention on both pools; size on the outbox_queue health metrics. An owner replays one type over a period from Health → Dead letters → Replay received Stripe events (POST /admin/api/webhooks/replay, count first; webhooks::replay_raw_events runs each through the live apply_event in its own transaction, stamps replayed_at, then the team cascade; capped at 5 000 a run). Payloads hold emails and card last four — never returned by any route. tests/webhooks.rs (every_verified_event_is_archived_once, a_replay_applies_archived_events), tests/retention.rs.
  • Code review 2026-10-03: two corrections. A replay takes only events not replayed yet and says when more wait, so a repeat run moves on instead of replaying the same 5,000. An invoice.payment_action_required opens an SCA access window only while the stored invoice is still action_required, for replays and late live deliveries alike (DECISIONS #42). Counting events is owner-only in the console, as on the server. docs/progress/2026-10-03.md.
  • 2026-10-06: a replayed invoice.payment_failed queues a fresh PaymentFailedReroute; the worker now re-reads that invoice from Stripe and does nothing if it has been paid since (see FIN-4's follow-up). docs/progress/2026-10-06.md.

FIN-10 — Reconciliation blind spots. Confirmed present today: status drift (both directions), plan/quantity drift, Stripe webhook-endpoint existence + stale-event-list checks, Mailchimp tag / PMPro level drift, payment-health, and local double-billing. Missing: any amount/currency comparison (this is what hid FIN-1 — plan-level amount/currency vs mapped price added 2026-08-25, reconcile_plan_prices; invoice-level amounts still unchecked); refund reconciliation (a dropped charge.refunded is unrecoverable); dispute reconciliation; invoice-total cross-checks; and Mailchimp→FMS reverse sync (a contact carrying members-only tags with no FMS member — the original bug class — is invisible). The reverse Stripe pass is capped at 50 pages (~5 000 subs) per account; truncation and unreadable accounts are surfaced in the report and UI (remote_truncated, unreadable_accounts), but nothing makes either fail the run — an operator can read a truncated, partially-skipped run as clean.

  • Note (correction): plan/quantity drift and webhook-endpoint checks are implemented — prior project notes listing them as gaps are stale.
  • Required action: add amount/currency and refund reconciliation before cutover; add a Mailchimp reverse sweep; make truncation and skipped-account outcomes fail the run's "success" status.
  • Closed 2026-10-02: all four.
    • Incomplete runs are not clean. StripeReconciliationReport.complete / incomplete_reasons (unreadable account, page cap, an error row); the reverse cap is now 400 pages (40k subscriptions). The console shows a red Check incomplete — do not read this as clean, the digest subject says INCOMPLETE, the export notes it, and Email report no longer says "all systems in sync" for such a run (ReportSummary.complete).
    • Money ↔ Stripe (services::reconciliation_money, GET /admin/api/reconciliation/money, 90-day window): every paid invoice, refund and dispute Stripe created in the window, read through StripeClient::list_created_since_page, against member_invoices and the payment_settlements ledger — invoice_missing, invoice_amount_drift, invoice_status_drift, renewal_off_price (a renewal not at the plan's mapped price × seats, discounted invoices excepted), refund_not_recorded, dispute_not_recorded, dispute_reversed; per account and currency, Stripe's paid totals beside FMS's for the same invoices; open disputes listed. Fixes (POST …/money/fix, finance + owners): re-read refunds (queues SyncRefunds), mark disputed / settled (re-reads the charge's disputes from Stripe first). Pure comparators unit-tested; tests/reconciliation_money.rs against the mock.
    • Mailchimp → FMS (services::mailchimp_reverse): pages the audience with tags and flags contacts holding members-only tags that no FMS member delivers to (newsletter_delivery::contributors_for_address empty); Strip all re-sweeps and queues the ordinary update-only AddressTagResync, whose desired state for such an address is every tag off. Gated by connections::mailchimp_fix_refusal (MIG-4). tests/reconciliation_mailchimp_reverse.rs against the rehearsal stub.
    • Both new checks are in the consolidated report, the digest and the CSV/HTML export.
  • Code review 2026-10-03: two corrections. The renewal check prices an invoice by its own line price (canonical, alias or forever-discount), compares before tax, and only says "another plan" when the price belongs to one; it no longer tells Finance to remove promised discounts. The Mailchimp strip removes only members-only tags; free newsletters stay (DECISIONS #41). docs/progress/2026-10-03.md.
  • Code review 2026-10-03: also: the Mailchimp → FMS sweep reports complete and error; an unreadable connection or a failure partway is INCOMPLETE in the console, the digest and the export instead of "nobody outside FMS holds a paid tag"; the detail tables of an incomplete Stripe or money run no longer say "in sync"; a truncated dispute listing is an incomplete reason. docs/progress/2026-10-03.md.

FIN-11 — Grace/cascade edge cases. Three low-likelihood, high-impact races: a team-cascade failure after the sweep commit leaves a lapsed team with indefinite access and a still-seatable owner (grace.rs:110); a grace-entry race returns Ok on failure, stranding a member in the outreach queue with no access and no grace window (reroute/worker.rs:324); a backup-lookup error routes a paying member to dated grace, swept and canceled 30 days later (reroute/worker.rs:511).

  • Required action: make the team cascade transactional with the billed-row lapse; on grace-entry failure, alert rather than returning Ok; on backup-lookup error, hold rather than dating grace.

FIN-12 — Gift newsletters. — Partial 2026-08-10 (landed; three sub-risks survive). The feature (a member redirects newsletter delivery to another address, behind an emailed consent link) landed as commit 391534a (2026-08-10, spec 06 FR-7a); the migration renumber landed separately as d4be250. Nothing is uncommitted — the original "it causes B0 / a git stash re-introduces the boot failure / it mixes three concerns" framing is obsolete. It touches CRM, not money. The surviving sub-risks: it adds an N+1 fan-out on the consolidation hot path over an unindexable predicate (lower(COALESCE(NULLIF(d.email,''), u.email)), src/repository/newsletter_delivery.rs:62) → a full users scan per contributor, ~23k scans per consolidation run, which is the primary post-cutover safety net — and because the expression spans a COALESCE over two joined tables, no single-column index can serve it (the 0026 migration's newsletter_delivery(lower(email)) index does not help); a redeemed confirm token is retained (single-use is enforced via pending_email IS NOT NULL, so replay is blocked — the residual issue is that a leaked redeemed link still discloses the member's delivery address); and shared-address opt-out is asymmetric (a member cannot unsubscribe from a newsletter delivered to a shared inbox → CAN-SPAM/GDPR exposure).

  • Required action: restructure the lookup so an index can serve it (an index alone cannot — e.g. split the delivery-email and user-email probes into two indexable queries) before relying on consolidation at scale; decide the shared-inbox opt-out semantics against compliance (DECISIONS.md #23, Legal / compliance); invalidate confirm tokens on redemption. (The "split the commit" action is done — 391534a + d4be250.)
  • Re-synced 2026-10-01: the slow lookup was already split into two indexable probes in 9f00910 (2026-08-10, migration 0027; src/repository/newsletter_delivery.rs:62-89); it still runs once per subscriber. Keeping a redeemed confirm token is deliberate — mail scanners pre-open links, and replay is blocked (:138-142). The one open item is the shared-inbox opt-out, now DECISIONS.md #23 for Legal / compliance.

FIN-13 — Renewal-time SCA unhandled (split from FIN-9; added 2026-08-14). Purchase-time 3DS/SCA is closed as of 2026-08-13 (4908bb6): all three card surfaces (/checkout, /donate, /gift) complete challenges end-to-end — parked rows grant nothing, the finalize endpoints re-read the verdict from Stripe rather than trusting the client, the finalize/webhook race is idempotent, tests/sca.rs covers 12 cases, and the flows were live-verified through real challenge iframes. Renewal-time closed 2026-08-15. invoice.payment_action_required is registered and handled: the invoice is captured in member_invoices at action_required with the hosted_invoice_url that is the only thing able to clear it, the member gets a "Confirm payment" banner on their account page, and staff get a shared renewal_sca health alert. It is deliberately not routed to the reroute worker — reroute_verdict classes it transient and steps aside, which is precisely what made it invisible before. The captured row self-clears when any terminal invoice event lands, so nothing needs unwinding. Coverage: tests/webhooks.rs (capture, self-clear, retry-does-not-demote, checkout-challenge exclusion, idempotency), tests/health_sca.rs (alert shape + anti-storm), tests/membership.rs (the member's completion link), tests/sca.rs (the mirror of a_real_renewal_failure_still_reaches_the_reroute_worker).

  • Residual, now explicit: an SCA-held member is put into past_due by Stripe, which grants nothing. A bounded subscriptions.sca_hold_until (default 14 days, migration 0033) keeps them reading while they authenticate, scoped to this path so ordinary dunning members are unaffected. Past that window they lapse like anyone else.
  • Not owned here: the push notification. FMS's member email does not cover payments, so Stripe's own "a payment requires authentication" customer email is the thing that reaches a member who doesn't visit their account page — a per-account dashboard setting to confirm at cutover (02-payment-routing §mitigations 1).
  • Still to verify live: the anchored event ordering and billing_reason on a real test-clock renewal — no in-repo test can reach the Stripe call.

FIN-14 — An Opn renewal or purchase is charged but FMS never hears the answer (added 2026-10-13, DECISIONS #48). Omise documents no idempotency key (api-reference §1), and FMS's HTTP client gives up after OPN_HTTP_TIMEOUT_SECS (60). A POST /charges that times out, or whose answer is lost on the way back, may still have charged the card. Replaying it could charge twice; ignoring it leaves money with no membership.

  • Mitigation (built): opn_charges::charge_once commits an opn_charge_attempts row (requested) before the request, puts its id in the charge metadata (fms_attempt, with fms_kind, fms_ref), and never retries the write; the ledger's UNIQUE (kind, ref_id, period_end, attempt) refuses a second request for the same try, and the renewal sweep takes rows FOR UPDATE SKIP LOCKED (tests/opn_renewals.rs running_two_sweeps_at_once_charges_once). Every OPN_EVENT_POLL_SECONDS (900) services::opn_reconcile reads GET /events from a per-account cursor and, for an attempt still requested after an hour, searches GET /charges by metadata.fms_attempt: found → settled through the one opn_charges::apply; not found → lost, its unsettled row retired (tests/opn_reconcile.rs, six cases). A success that arrives for a row already ended is recorded on the ledger and staff are emailed to refund or grant (opn_memberships::paid_without_membership); a verdict whose amount or currency differs from the attempt is refused and emailed.
  • 2026-10-14, Opn support: POST /charges does have duplicate-charge protection — ref_id in the body; the same ref_id returns the existing charge with its current status. charge_once now sends the attempt row's id as ref_id (tests/opn_donations.rsthe_same_ref_id_sent_again_answers_the_first_charge against the mock's rule). Opn advised keeping our own protection, so the ledger and the never-retry rule stay. Omise also never retries webhooks (OPS-3), so the poll is the only way a missed answer is found.
  • Still open: nothing has run against a real Omise account, so the GET /charges window, list lag and metadata round-trip are the mock's; a charge Omise records later than the one-hour search is caught only by its webhook or the events poll (then paid_without_membership). The reconcile still marks an unfound attempt lost rather than re-sending it with its ref_id: that switch waits for the live-fire scenario opn_ref_id_dedupe to pass on the real test account.

FIN-15 — A 3-D-Secure-mandatory Omise account cannot renew unattended (added 2026-10-13, DECISIONS #48; resolved for test 2026-10-14). Omise's docs say that with 3-D Secure enabled "merchants will not be able to process automatic/recurring payments" (api-reference §4); whether a merchant-initiated charge skipped the challenge was UNVERIFIED. If every renewal needed the member at their bank's page, most would not come, and paying members would lose access.

  • Answered 2026-10-14 (Opn support): our test account has dynamic 3-D Secure — a POST /chargeswith return_uri is challenged, without it is not. FMS's renewals send no return_uri (opn_renewals, pinned by tests/opn_renewals.rs, which asserts no return_uri on the renewal), so they are not challenged; first charges from the card form send one and are. The live-fire scenario opn_renewal_no_3ds checks both halves on the real test account. Resolved for test; Low.
  • Residual: dynamic 3-D Secure on a live account needs Opn's risk-team approval (RUNBOOK §10.3 step 3) — without it the live account is challenged on every charge (back to this risk) or on none; and a renewal without 3-D Secure is the merchant's loss in a dispute (FIN-16).
  • Behaviour kept: a renewal answered pending + authorize_uri anyway is still held for the member (member_invoices at action_required with the authorize_uri, hold_for_sca 14 days, the Confirm payment banner and the renewal_sca alert; a_3ds_renewal_is_held_and_the_webhook_settles_it) and, since 2026-10-14, logged as an error and emailed to staff once (Opn asked for 3-D Secure on a renewal), because it means Opn changed the rule. No email carries the link: Opn said an authorize_uri works once.

FIN-16 — Opn renewals carry the dispute risk (added 2026-10-14, DECISIONS #48 addendum). Opn support: for a charge without 3-D Secure the merchant is liable in a customer dispute. Every Opn renewal is such a charge (that is what lets it run unattended, FIN-15), so a member who disputes a renewal with their bank can take the money back and Frontier also loses Omise's fee (disputes return no fee, api-reference §8). Dynamic 3-D Secure on the live account needs Opn's risk-team approval.

  • Mitigation: the first charge is authenticated (with return_uri) and every renewal names it (first_charge, recurring_reason=subscription, transaction_indicator=MIT), the recognised stored-credential pattern; disputes reach staff by email (opn_events, Opn dispute …); the member's renewal emails (ends soon) give notice before the charge.
  • Also (2026-10-15, DECISIONS #51): once a THB Opn price exists the member page says USD while Omise charges THB, and a renewal charges the live price of the day — the statement-versus-page mismatch a dispute is built on. #51's stored renewal terms and the price shown before Pay (posted as a binding quote the server checks) turn the page into evidence for us instead.
  • Built 2026-10-15 (DECISIONS #51 B, stage 2): the checkout, gift and donate pages show the price the chosen card type pays before Pay (baht with the rate and the USD list price beside it), the form posts it as a binding quote the server checks before anything is charged, and the shown amount is stored on the attempt (opn_charge_attempts.quoted_currency / quoted_amount, migration 0087) beside what was charged and the row's renewal terms — the evidence a dispute is answered with. The billing page says a baht price is re-priced at each renewal at the rate of the day. The renewal itself is still unauthenticated; the risk stays open until the owner accepts it.
  • Decision needed: the Business owner accepts this before Opn is made primary for memberships (Decision board card 48), and asks Opn's risk team for dynamic 3-D Secure on the live account (RUNBOOK §10.3). Stripe's off-session renewals are in a similar position; the difference is how many renewals Opn would carry. Medium.

3.2 Security & compliance (SEC) ​

IDRiskLISevStatusOwner
SEC-1SEED_DEV_DATA=true → unauthenticated JWT minting for any email45CriticalClosed 2026-10-02 (deploy.sh refuses without APP_ENV=production)Technical
SEC-2Admin cookie key = SHA-512(JWT_SECRET); JWT_SECRET=dev is the compose default45CriticalFixed 2026-10-24 one session life after ADMIN_SESSION_SECRET is set (separate staff cookie key, no sign-out, old-key window bounded by ADMIN_SESSION_SECRET_SINCE; payload was already encrypted)Technical
SEC-3Restricted read-only live keys + APP_ENCRYPTION_KEY in a local .env22LowAccepted 2026-08-10 (by Thomas; the Business owner's signature is asked on the Risk board's sign-off sheet, 2026-10-03)Technical
SEC-4No startup guard on ALLOW_UNVERIFIED_WEBHOOKS; unknown-account → global secret35CriticalClosed 2026-10-02Technical
SEC-5Webhook apply not scoped to path account → cross-account writes25HighClosed 2026-08-26Technical
SEC-6Mailchimp/CRM not environment-gated; mailchimp/wipe ungated34HighClosed 2026-08-26Technical
SEC-7POST /checkout free path: unauthenticated record creation + CRM injection43HighClosed 2026-10-02 (DECISIONS #10 B = 1)Technical · also Marketing
SEC-8Admin login: no rate-limit/lockout · COOKIE_SECURE off · no session revocation · no CSRF · raw DB errors in 500s · no MFA33MediumClosed 2026-10-24 (rate limit, dummy hash, two-step sign-in; session revocation and a same-site check on every console write added)Technical
SEC-9PII exports + .wpress in tree; WP secrets plaintext in wp_options34HighPartial 2026-10-02 (service key wp-config only; the data copies remain)Technical
SEC-10No data-deletion capability; unencrypted DB export; PCI SAQ A-EP scope33MediumOpenLegal / compliance · also Technical
SEC-11Archive paywall bypass via percent-encoded paths; malformed escapes crash the server54CriticalClosed 2026-09-22Technical
SEC-12Archive hand-off puts a replayable full member JWT in the URL34HighClosed 2026-09-22Technical
SEC-13Compose's default admin password is not a production startup violation34HighClosed 2026-10-02Technical
SEC-14FMS has no security-headers layer or checkout CSP33MediumPartial 2026-10-02 (headers done; checkout script CSP in November)Technical
SEC-15Unthrottled donate/gift submits; cleartext SMTP credentials and HTTP R2 endpoints allowed33MediumClosed 2026-10-02Technical
SEC-16Plugin contact-mail throttle, uninstall cleanup, portal URL validation and migration-export storage gaps33MediumOpenTechnical
SEC-17Claude reads FMS: a colleague can be tricked into approving someone else's Claude, and what Claude reads leaves FMS24HighAccepted 2026-10-03 with controls (by Thomas; the Business owner signs by confirming DECISIONS #43)Business owner · also Legal / compliance, Technical
SEC-18GET /admin/api/environments returned each environment's Postgres connection string with its password to every console role34HighClosed 2026-10-03Technical
SEC-19The Omise secret keys and webhook secrets: a full-power key (Omise has no restricted key), a base64 webhook secret, a manual roll24MediumPartial 2026-10-13 (encrypted, verified, braked; Omise IP range and key roll drill open)Technical
SEC-20A sandbox cloned from production shared production's Stripe account and key: Create webhook endpoint there deleted production's endpoint, and switching the console to a sandbox sent production's website and webhooks into it34HighClosed 2026-10-24 (DECISIONS #55)Technical
SEC-1 — SEED_DEV_DATA=true unauthenticated token minting. mint_dev
(src/api/handlers/auth.rs:517) is reached when POST /v1/auth/token
arrives with no Authorization header — or any header that doesn't parse as Bearer … (e.g.
Basic xyz also falls through); its only gate is seed_dev_data. It creates the user if
absent and returns a valid member JWT for any email. The flag defaults to true in
docker-compose.yml:47 and is set on the public test host's .env. With a
minted JWT an attacker reads any member's access and invoice history, opens a **live Stripe Customer
Portal session** for them, and cancels/changes their membership or manipulates their team.
  • Required action: refuse mint_dev in any production/non-sandbox environment via refuse_in_production, and add a startup guard that hard-fails if SEED_DEV_DATA=true in production. Set the compose default to false.
  • Update 2026-08-10 (Partial): the startup guard exists — APP_ENV=production refuses to boot with SEED_DEV_DATA=true. Residual: the guard only fires if the operator sets APP_ENV=production on each deployment (manual step, see §5).
  • Closed 2026-10-02: the manual step is now enforced — deploy.sh refuses to deploy unless .env sets APP_ENV=production (a deliberate staging host runs with ALLOW_NON_PRODUCTION=1). ops/RUNBOOK.md §3.

SEC-2 — Admin cookie key derived from JWT_SECRET=dev. The PrivateCookieJar key is SHA-512(JWT_SECRET) (src/main.rs:94) and the cookie payload is the guessable "{admin_id}|{exp}". JWT_SECRET defaults to the literal dev in docker-compose.yml:39 and is only checked for presence, never entropy — and this is not hypothetical: the value in the local .env is 3 characters long. Knowing it lets an attacker forge an admin session cookie → full console (PII DB export, restore, Mailchimp wipe, service-key minting, environment switch) — and simultaneously forge every member JWT and WP hand-off token.

  • Required action: require a high-entropy JWT_SECRET (fail startup if it is short/default); derive the admin cookie key from a separate secret; shorten the guessable cookie payload to an opaque id.
  • Update 2026-08-10 (Partial): under APP_ENV=production a placeholder JWT_SECRET refuses to boot. Residual: the manual APP_ENV=production step per deployment; the separate-cookie-secret and opaque-payload items remain.
  • 2026-10-02: the APP_ENV step is enforced by deploy.sh (see SEC-1). Still Partial: the cookie key is still derived from JWT_SECRET, and the payload is still "{admin_id}|{exp}" (both behind the strong-secret guard; scheduled with SEC-8 option 2).
  • Fixed 2026-10-24 (risk sheet): optional ADMIN_SESSION_SECRET gives the staff console's two cookies (fms_admin, fms_admin_device) their own key, state::AdminKey (SHA-512 of the secret with a domain prefix). A cookie written with the old JWT-derived key is still read and re-issued with the new key by the role gate, so setting it signs nobody out (the owner's condition). The payload was never readable (encrypted, PrivateCookieJar); it now also carries an issue time. Production refuses a set value under 32 characters or equal to JWT_SECRET, and logs a warning while it is unset. Residual: until the secret is set, the old derivation stands. tests/admin_sessions.rs (a_separate_staff_key_signs_nobody_out).
  • Review 2026-10-24 (later): as first shipped, the old-key fallback had no end, so anyone holding JWT_SECRET could still forge a staff cookie under the old key after the secret was set, and SEC-2 was not achieved. Now the old key is read only for one session life (JWT_TTL_SECONDS) after ADMIN_SESSION_SECRET_SINCE (else after process boot, so each restart re-opens it for a day: a boot warning in production says to set it), the same window for the known-device cookie; and every cookie is refused when its issue time is more than five minutes ahead or its expiry is more than one session life after its issue time, so a forged one cannot be dated past a stamp. Fixed once the secret is set and that window has passed. tests/admin_sessions.rs (an_old_key_cookie_is_read_only_inside_the_window, a_session_dated_into_the_future_is_refused).

SEC-3 — Restricted read-only live keys in a local .env. — ACCEPTED (downgraded 2026-08-10). The original entry rated this Critical. That was too high, and the owner's clarification plus a re-check bear that out:

  • Both keys are rk_live_* — restricted, granted read-only scope against the live accounts. They cannot create charges, issue refunds, or mutate anything. An earlier summary of this finding described them as able to move money; that was wrong.
  • The file is on the developer workstation only, not on a deployed host. It is a single-user machine.
  • .env is .gitignored and has never been committed (verified across all branches).

Residual exposure is therefore workstation-scoped and read-only: someone with access to that machine could read live customer records (names, emails, subscription and payment history), and APP_ENCRYPTION_KEY in the same file would decrypt the local database's stored credentials if a dump were also taken. Real, but narrow, and not on the cutover path.

  • Accepted for local development. Not a cutover blocker.
  • Still required for production: deployed hosts must receive secrets as injected environment variables from a secret manager, never a file co-located with the data — tracked under the production lockdown (SEC-1/2/4), not here. chmod 600 on the local file remains a cheap improvement.

SEC-4 — Ungated ALLOW_UNVERIFIED_WEBHOOKS + global-secret fallback. When the resolved signing secret is empty and allow_unverified_webhooks is true, verification is skipped and the handler returns Ok (webhooks.rs:904); nothing at startup ties this to the environment (contrast the WP_ORIGIN/APP_ENCRYPTION_KEY warnings that do exist). Separately, an unknown account id in the webhook path resolves to None and falls back to the global STRIPE_WEBHOOK_SECRET (webhooks.rs:57). The hatch is on the public host now. With it on, anyone can POST forged Stripe events to grant active, mark invoices paid, or mass-cancel (triggering the irreversible team-lapse cascade). The signature scheme itself is correct — constant-time compare, replay window, empty-secret refusal — so the risk is entirely the ungated hatch + global fallback.

  • Required action: hard-fail startup if ALLOW_UNVERIFIED_WEBHOOKS=true in production; make an unknown account id a 4xx, not a global-secret fallback.
  • Update 2026-08-10/13 (Partial): APP_ENV=production now refuses unsigned webhooks at startup (residual: the manual APP_ENV=production step per deployment, and the unknown-account global-secret fallback). Related improvement 2026-08-13 (0ff4b28): connect now stores the real acct_… Stripe account id, so webhook attribution audits against the real account.
  • Closed 2026-10-02: both residuals. A path id that names no account is refused with an empty 404 before any secret is read — nothing written, no marker (accounts::find_optional, src/api/handlers/webhooks.rs); the global STRIPE_WEBHOOK_SECRET now applies only to a known account with no stored secret. tests/webhooks.rs (unknown_path_account_is_refused_with_an_empty_404). The APP_ENV step is enforced by deploy.sh (SEC-1).

SEC-5 — Webhook apply not account-scoped. After verifying against account A's secret, apply_event updates subscriptions by stripe_subscription_id alone — update_from_stripe (webhooks.rs:236) has no stripe_account_id predicate. A holder of one connected account's signing secret can sign an event naming a subscription in a different account and flip its status, violating the standalone-account isolation invariant. One mitigating fact: stripe_subscription_id is globally UNIQUE (migrations/0001_init.sql:53), so accidental cross-account row collision cannot occur — the exposure is a deliberately forged cross-account write. Likelihood is low (needs a valid per-account secret and a known sub_… id) but it is a cross-tenant integrity hole, and it compounds with SEC-4's unknown-account global-secret fallback.

  • Required action: constrain the subscription update to the path account id.
  • Closed 2026-08-26: two layers. sub_repo::update_from_stripe takes account_id: Option<Uuid> and adds AND stripe_account_id = $6 when pinned — the webhook pins the path account, the importer pins the account it is reading, operator reconciliation passes None. And apply_event resolves the row first: a tracked subscription on another account is logged and dropped (still marked processed, so it is neither retried nor mistaken for an adoption), and the same check guards capture_payment_failed (no reroute for a foreign sub), capture_action_required (no SCA access hold) and resolve_pending_update (no seat/tier settlement). tests/webhooks.rs (a_subscription_event_on_another_accounts_path_changes_nothing, update_from_stripe_pinned_to_the_wrong_account_is_a_no_op).

SEC-6 — CRM not environment-gated. Every other test-facing capability calls refuse_in_production, but the Mailchimp dispatcher has no environment check; the only brake is the manual CRM_SYNC_DISABLED (src/crm/worker.rs:36). Sandboxes are cloned from prod via pg_dump | pg_restore and carry the encrypted live Mailchimp key, so a scenario/seed run inside a sandbox mutates the real audience. The sibling POST /admin/api/connections/mailchimp/wipe (src/admin/api.rs:964) archives the entire audience and also lacks refuse_in_production (guarded only by a confirm-id echo and a ~2 000-contact heuristic).

  • Required action: gate the CRM dispatcher and the wipe endpoint on environment; refuse both outside a sandbox unless an explicit live-CRM flag is set.
  • Closed 2026-08-26 with one rule instead of a flag — a sandbox may only write to a Mailchimp audience it connected itself. services::connections::mailchimp_write_verdict (pure) compares the active environment's stored key material with production's: a sandbox with no connection (the env fallback is production's key) or with production's own connection (inherited by clone) is refused; production and a sandbox with its own test audience proceed. The CRM dispatcher consumes a refused event with a warn! naming what to connect (a rig must not dead-letter its tag pipeline); the wipe tool now calls refuse_in_production and refuses an inherited audience in a sandbox; and environments::create strips mailchimp_config from every new clone. Reads are not gated — Consolidation still diffs against whatever is connected. tests/crm_gate.rs (5 tests, via a new MailchimpClient::with_base_url seam) + unit tests. Stripe connections are still copied into a clone, but since 2026-10-25 a sandbox refuses a live account outright (EnvironmentRefused), the clone switches copied live accounts off and a sandbox holding one cannot start its workers; the live-write brake (OPS-2) is the second line (SEC-20).

SEC-7 — Unauthenticated free-plan checkout. The $0 branch of POST /checkout runs before the WordPress auth gate (src/checkout.rs:398, gate at :403), creating a users row + comp Community subscription + a CRM event for any email in the form, with no session, CSRF, captcha, or rate limit. Enables mass member-record creation and injection of arbitrary third-party addresses into Mailchimp, plus an account-enumeration oracle when wp_origin is empty.

  • Required action: require the auth gate (or a captcha + rate limit) before the free-plan branch; suppress the "already a member" enumeration response.

  • Update 2026-08-12 (Partial): a per-IP rate limit landed (039136d) — but on POST /checkout/discount only (token bucket, burst 20 / 10 per min, 10k-IP LRU, fails open; requires TRUST_PROXY=true behind a reverse proxy or the whole site shares one bucket). The $0 branch of POST /checkout itself remains unthrottled, so this finding's core is still live.

  • Re-verified 2026-09-22: src/checkout.rs:398–402 still takes the free branch before the WordPress auth gate; no rate limit was added to that branch. Status remains Partial.

  • Closed 2026-10-02 (DECISIONS #10 B = option 1): the free plan is behind the website sign-in. POST /checkout with a $0 plan and no member session creates nothing: with a website configured it redirects to WordPress's start link, without one it answers 403 with the branded page; the address is always the session's, never the form's, so "already a member" is only ever said to the member themselves (no enumeration). The GET gate covers free plans too. WordPress itself never posted a free plan here (it enrols through /v1/enroll/community), so the plugin is unaffected. tests/checkout.rs (an_unidentified_free_signup_creates_nothing, an_unidentified_free_visitor_is_sent_to_wordpress). Product consequence, told to the Business owner and Marketing: no guest sign-up on the FMS checkout page.

SEC-8 — Admin/session hardening cluster (Medium). No rate-limit or lockout on admin login and Turnstile is unconfigured, so online password guessing is unbounded and argon2's memory cost also makes it a cheap DoS (src/admin/auth.rs:167); COOKIE_SECURE defaults false and is absent from compose; admin sessions are stateless with no server-side revocation, a 24 h TTL, no password-rotation endpoint, and no MFA; there are no CSRF tokens anywhere (SameSite=Lax is the only barrier); and 500 responses serialize the raw sqlx/anyhow message (src/error.rs:112). Password hashing itself is sound (Argon2id, per-password salt, constant-time verify).

  • Required action: add login rate-limiting/lockout; set COOKIE_SECURE=true in prod (and enforce at startup); add CSRF tokens to admin mutations; map DB/internal errors to opaque 500 bodies; add an admin password-change path.

  • Partial 2026-08-25 (c7b6df3): AppError::public_message maps Config/Database/Migrate/ Internal to an opaque "internal server error" body (still logged in full); validation/auth/Stripe messages are preserved on purpose. Unit-tested in src/error.rs. COOKIE_SECURE=true is a required production setting enforced by the APP_ENV=production startup guard (SEC-1/2/4 lockdown). Login rate-limit/lockout, CSRF, session revocation and password change remain open.

  • Re-verified 2026-09-22: login rate limiting, CSRF, revocation and password change remain open. Login also skips password hashing for an unknown email (src/admin/auth.rs, api_login), making response timing an account-existence oracle. Use a dummy Argon2 hash for the missing-user path as part of the admin-auth work; options are in DECISIONS §10 A.

  • Re-synced 2026-10-01: admins can now change their own password (CHANGELOG 2026-10-01). Still missing: a login rate limit or lockout, CSRF checks, session revocation (a password change leaves other sessions signed in), and the dummy hash for unknown emails (src/admin/auth.rs:179-182). DECISIONS.md #10 A.

  • Narrowed 2026-10-01 (console roles, DECISIONS #37): the role gate reads the account on every request, so an owner switching an account off on Staff ends its open sessions at once, and a temporary password confines them to Your account. A person's own password change still leaves their other sessions signed in. Rate limit, CSRF and the dummy hash remain open.

  • Narrowed 2026-10-02 (DECISIONS #10 A = option 1): the sign-in is throttled per client address (burst 10, 5/min) and per account address (burst 5, 2/min, case-insensitive, keyed hash — rotating addresses does not help a guesser), answering 429 + Retry-After with the wait in words; a refused attempt is still an admin.login activity row. An address with no account verifies a dummy Argon2 hash, so timing no longer says which addresses exist. RateLimiter::check_name, admin::auth::LoginLimiters; tests/admin_login_limits.rs. Still open (option 2, before broad console access): CSRF checks and revoking other sessions on a password change.

  • Code review 2026-10-03: option 1 tightened. Only a failed password costs the per-account token, and a browser that signed in to the account before (an encrypted 180-day cookie) skips the per-account bucket, so someone guessing slowly from elsewhere cannot keep the owner out. IPv6 clients are bucketed by /64. Option 2 (CSRF, revocation) unchanged. docs/progress/2026-10-03.md.

  • Fourth AI-connector review 2026-10-04: the per-account bucket (shared by the console login and the Claude sign-in page) is now taken before the password check and given back when the password is right. It used to be peeked first, so parallel guesses could all pass: a test showed 20 parallel guesses reaching the password check, and now 5 do. tests/admin_login_limits.rs. Options 1 and 2 otherwise unchanged. docs/progress/2026-10-04.md.

  • Narrowed 2026-10-11 (DECISIONS #47 = #10 A option 3, MFA): every console account, every role, signs in with a password and a six-digit authenticator code (TOTP, RFC 6238, ±1 step, a code works once: admin_users.totp_last_counter). The password alone mints a five-minute password-stage session that the role gate lets through to the code routes only (second_factor_required elsewhere); a session from before the change carries no stage and reads as password-stage, so every open session re-prompts once. An account not set up is held to Your account (totp_enrolment_required). Secrets are encrypted with APP_ENCRYPTION_KEY; ten single-use recovery codes are stored as SHA-256; a new phone needs password + code and stays pending until confirmed; owners reset a colleague (ends their Claude grants); staff-reset clears it. The code step has its own per-account bucket (burst 5, 1/min), shared with Your account's proofs and the Claude sign-in page, which asks for the code too. Migration 0080; tests/admin_totp.rs; docs/progress/2026-10-11.md. Still open (option 2): CSRF checks and revoking other sessions on a password change.

  • Closed 2026-10-24 (option 2, risk sheet): revocation: the cookie carries its issue time (ms) and admin_users.sessions_valid_after (migration 0094) ends every session issued before it (401 session_ended); stamped by the person's own password change (their current session is re-issued), Sign out my other sessions on Your account (POST /admin/api/account/sessions/end-others), an owner's temporary password or two-step reset, and staff-reset. A cookie from before carries no issue time: it keeps working until its account's sessions are next ended, so the deploy signs nobody out. Cross-site writes: the role gate refuses any non-GET /admin/api request whose Sec-Fetch-Site is not same-origin, or (older browsers) whose Origin is neither PUBLIC_BASE_URL nor the Host (403 cross_site_request); a client sending neither (a server, a script) is not riding a browser cookie and passes. admin::roles::same_site_write; tests/admin_sessions.rs.

SEC-9 — PII/secret hygiene. Live member PII sits unencrypted in the working tree — exports/live_th_subscriptions.json / live_sg_subscriptions.json (real addresses), further exports (wordpress_schema_2026-07-25.sql, profile_matrix.csv, th_rescue_audit_2026-07-19.csv), and a 3.3 GB production .wpress at repo root (all untracked, but present on disk). A real-looking FMS_SERVICE_KEY also sits in plaintext in wordpress-plugin/.env. The WP plugin stores the FMS service key, Turnstile secret, and bot-bypass secret in wp_options plaintext when the FMS_SERVICE_KEY constant is not defined (wordpress-plugin/fms-member-gate/includes/settings.php:779); that key mints member JWTs for any email, so a WP-side DB read is equivalent to SEC-1's impact.

  • Required action: remove the PII artifacts from the working tree (or encrypt + relocate); prefer the wp-config.php constant for the service key and document it as required; configure the service key's IP allow-list (fails closed) to bound the blast radius.

  • Re-verified 2026-09-22: the 3.3 GiB production .wpress remains on disk at the repository root (3,591,424,536 bytes); the plugin's service-key option remains plaintext. This pass records the finding only; it does not move/delete exports or rotate keys.

  • Partial 2026-10-02 (DECISIONS #10 D = option 1): FMS Member Gate no longer stores the service key. Without FMS_SERVICE_KEY in wp-config.php the settings field shows the exact line to add and refuses a pasted key; a key stored by an older version is still read for one release, flagged by an admin notice and removed with Forget stored key (plugin release noted in CHANGELOG). Still open: the migration data copies on the developer machine, deleted once the migration no longer needs them.

SEC-10 — Compliance gaps. No data-deletion capability exists anywhere (GDPR Art. 17 / PDPA) — the admin API has member create/read/update but no delete, and erasure would need manual action across Stripe, Mailchimp, WordPress, and app_logs. The admin DB export streams an unencrypted pg_dump to the browser. PCI scope is SAQ A-EP, not the lighter SAQ A, because FMS serves the Stripe.js payment page — which puts /checkout integrity and COOKIE_SECURE (SEC-8) in PCI scope.

  • Required action: build a subject-erasure workflow (or a documented manual runbook); encrypt DB exports; document the SAQ A-EP obligations and treat /checkout XSS/integrity as in-scope. (Owned by Legal / compliance: DECISIONS.md #22.)
  • PII and retention rule for the activity trail (2026-09-28, DECISIONS #14): activity_events holds member emails, member IP addresses and browser user agents, and admin emails, in the boot database. It is kept ACTIVITY_RETENTION_DAYS (default 180) and pruned daily. An address is stored only when it is the person's own (their browser reached FMS, or the website forwarded it with its service key) — never a server's standing in. Request bodies are copied by a per-route allow-list, so no password, API key, SMTP secret or card token can land there. Subject erasure, when built, must include this table (by target_user_id, target_email and actor_label). It rides the nightly backup; a restore rewinds it to the dump.
  • Backups encrypted (2026-10-24, risk sheet NEW-15): the "encrypt DB exports" item is done once BACKUP_AGE_RECIPIENT is set. Every copy that leaves the server (the console download, the console's nightly R2 upload, the sidecar) is encrypted to the owner's age public key; the private key stays offline and decryption never happens on the server (src/backup_crypto.rs, ops/backup.sh, restore.sh --identity). Production refuses a value that is not a public key; Health warns while copies leave plain. Residual: the key pair must exist and be kept (losing the private key loses the backups); the subject-erasure and PCI items are unchanged.

SEC-11 — Archive path gate bypass and malformed-path crash (Critical). The review reproduced anonymous access to /%63/<id>.json and /data%2findex.json: the old gate checked the raw path while static serving decoded it (archive/src/server.ts, formerly lines 281–295). /% also threw from decodeURIComponent out of the async request callback.

  • Closed 2026-09-22: decode the original request path once, reject malformed escapes and encoded separators with 400, check containment before gating, and gate the resolved data/ and c/ paths. Raw HTTP traversal tests now reach the guard. Request exceptions return opaque 500s; stream errors and unhandled rejections are logged without request/token text. Regression tests cover bypasses, malformed requests, a valid member cookie and continued service after errors.
  • Rollout: deploy the archive server fix; code closure does not certify an already-running host.

SEC-12 — Overpowered archive URL credential (High). The archive redirects at wordpress-plugin/fms-member-gate/includes/archive.php:113 and the admin-post handler previously minted a 900-second member JWT via FMS_Client::mint_token. That replayable URL credential also allowed /v1/me/* and Stripe-portal access, beyond opening the archive.

  • Closed 2026-09-22: use the existing hand-off class (src/auth.rs) with purpose=handoff, scope=archive, a default 120-second TTL and atomic consumed_handoff_tokens redemption. POST /v1/auth/handoff/redeem returns only email/tier/caps, is rate-limited, and refuses other token classes/scopes. /auth/session refuses scoped tokens. Expiry leeway is disabled so replay markers cannot be pruned while a token is still accepted. Plugin 0.35.0 uses this at both mint sites; nonce-less fallback requests render the button instead of minting. Archive redirects set no-referrer, JSON sets nosniff, and nginx suppresses token-bearing access/error logs.
  • Rollout: FMS → archive → plugin; see archive deploy steps.

SEC-13 — Default administrator credential (High). docker-compose.yml:57 supplies ADMIN_PASSWORD=seedpass123 when unset, and src/config.rs:277 (deployment_violations) does not reject that value in production. The other production guards do not close this gap.

  • Required action: reject empty/known default bootstrap passwords in production and require an explicit secret; account for already-seeded administrators (changing the env alone does not rotate their stored hash). Schedule with the SEC-8 cluster; DECISIONS §10 A.
  • Re-synced 2026-10-01: still Open; deployment_violations (src/config.rs:315-381) has no password check, though the new password-change screen lets an admin rotate a seeded password by hand. Added to the §5 cutover checklist 2026-10-01.
  • Closed 2026-10-02: deployment_violations refuses a set ADMIN_PASSWORD that is a known default (config::KNOWN_ADMIN_PASSWORDS, seedpass123 first) or under 12 characters; empty is allowed (the bootstrap does nothing). Already-seeded accounts: in production, boot verifies each active account's hash against the known defaults and holds any match to Your account (admin::auth::confine_default_passwords → must_change_password), rather than refusing to start and stranding the operator. (Superseded 2026-10-03, below: the sign-in is now refused.) docker-compose.yml no longer supplies seedpass123 (the rehearsal compose still does, in development). Unit tests in src/config.rs; tests/admin_account.rs (an_account_on_a_default_password_is_confined_at_boot).
  • Code review 2026-10-03: still Closed, tightened. Confinement alone let anyone who knew the default sign in and then change the password with the default as "current". In production a sign-in with a known default password is now refused outright (403 default_password, pointing at staff-reset). docs/progress/2026-10-03.md.
  • Re-audited 2026-10-15 — the behaviour as it stands: in production a sign-in whose password verifies and is a known default (any case or padding, admin::auth::is_known_default) is refused with 403 default_password before any cookie — console and Claude's sign-in page alike — so the account opens nothing, not even Your account. The way out is a password someone else sets: an owner's temporary password on Staff, or staff-reset on the server when no other owner can sign in (ops/RUNBOOK.md §7). Boot still checks every active account's hash and logs each match with that way out; its must_change_password mark only holds a session opened before the refusal. Boot matches the exact lowercase literals only (a stored SeedPass123 is not logged, though its sign-in is refused). Gap closed the same day: the console's password rule (password_problem: Your account, new colleague, temporary password) now refuses a known default too — padding got one past the 12-character minimum, after which production would have refused that sign-in. tests/admin_account.rs (the_console_never_sets_a_default_password). docs/progress/2026-10-15-cutover-code-owed.md.
  • 2026-10-15 (later) — choose a new password after the second factor (owner's ask): in production an account with two-step sign-in set up (DECISIONS #47) that signs in with a known default is no longer refused: the password opens only the five-minute code step, as any password does, on a marked password-stage cookie (admin::auth::setup_password_stage_cookie); a right authenticator or recovery code then marks the account must_change_password (require_password_change) before the full session cookie is issued, in the same request (admin::twostep::code_sign_in) — a failure to write the hold issues no cookie. The gate holds that session to Your account exactly like a temporary password; the password change takes the default as "current" and password_problem refuses another default. Why this is safe now when the 2026-10-03 tightening was right then: the default is public, so it proves nothing, but the authenticator does — someone who only knows the default stops at the code step with no session and no hold written. Exception: an account without two-step sign-in has no second factor to lean on, so it is still refused outright (403 default_password, now naming both ways out: an owner's temporary password on Staff, or staff-reset). Claude's sign-in page refuses a default whatever the two-step state (it has no password-change screen). Boot logs each match with the way out its two-step state gives it. tests/admin_account.rs (an_enrolled_default_password_signs_in_to_choose_a_new_one, a_recovery_code_after_a_default_password_holds_too, a_wrong_code_after_a_default_password_gives_nothing, a_default_password_without_two_step_cannot_sign_in_in_production, development_signs_in_with_the_default_unheld), tests/ai_assistant.rs (a_default_password_is_refused_on_the_sign_in_page). docs/progress/2026-10-15-cutover-code-owed.md (SEC-13, later the same day).

SEC-14 — FMS browser security headers (Medium). src/api/mod.rs has no common security-header layer for FMS pages: frame protection, nosniff and HSTS are missing, and /checkout has no CSP. The existing SEC-10 payment-page/SAQ A-EP assessment makes checkout integrity a priority. The archive's own CSP and new nginx headers do not protect the separate FMS origin.

  • Required action: add the shared headers and decide whether to include a nonce CSP in the same pass. The inline window.__* configuration blocks in src/cardform.rs, src/pricing.rs, src/checkout.rs and src/donate.rs need compatible nonces; DECISIONS §10 C.
  • Partial 2026-10-02 (DECISIONS #10 C = option 1 now, option 2 November): every FMS response carries X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (framing only — no script policy), X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin, plus HSTS when COOKIE_SECURE=true (src/security_headers.rs, mounted inside the request span in api::router). FMS frames Stripe, never the reverse, so nothing real is refused. tests/security_headers.rs. Open: the nonce CSP for the payment pages.

SEC-15 — Public submits and transport restrictions (Medium). /donate and /gift card-submit routes (src/donate.rs, src/gift.rs) have no rate limit. SMTP security=none allows credentials on a cleartext connection (src/services/email_settings.rs:114–125); src/r2.rs:48 also accepts an http:// endpoint despite its HTTPS validation message.

  • Required action: rate-limit donation/gift submits without breaking challenge finalization; refuse authenticated cleartext SMTP and require HTTPS for production R2. Preserve explicit local rehearsal/stub exceptions. These are scheduled fixes, not changes made in this pass.
  • Closed 2026-10-02 (DECISIONS #10 E, this half): POST /donate and POST /gift are throttled per address (CARD_SUBMIT_LIMIT, burst 10, 5/min) and their /finalize post-backs have a separate, roomier bucket (FINALIZE_LIMIT), so running out of card attempts never blocks finishing a 3DS challenge; a refusal is the branded Please wait a moment page with Retry-After (cardform::throttle_post). SMTP security = none with a username is refused at save and at boot (email_settings::cleartext_credentials_problem; Mailpit, which signs in with nothing, still works). A custom R2 endpoint must be https:// under APP_ENV=production (r2::endpoint_for). tests/rate_limit.rs, tests/email_settings.rs, src/r2.rs unit tests.
  • Code review 2026-10-03: still Closed, two corrections. A throttled 3DS finalize no longer says "nothing was charged" (the bank may already have approved it): it says the payment may have gone through and re-posts the same confirmation. An R2 endpoint on this machine (localhost, loopback) may use http:// in production, and the scheme is matched in any case. docs/progress/2026-10-03.md.

SEC-16 — Plugin hardening follow-ups (Medium). The [fms_team_pricing] contact handler sends mail without fms_mg_mail_throttle() (includes/pricing-team.php:263); FMS Member Gate has no uninstall.php, leaving stored secrets after deletion; includes/actions.php:344 redirects to a portal URL without fms_mg_stripe_url() validation. The migration assistant writes PII beneath uploads/fms-migration (wordpress-plugin/fms-migration-assistant/includes/jobs.php:27); .htaccess does not protect it on nginx, where randomized names are currently the fallback.

  • Required action: add the mail throttle, uninstall secret cleanup and Stripe-URL validation; move exports outside the served tree or explicitly deny direct access on both web servers and keep authenticated downloads. Deactivation must continue to preserve configuration. Schedule these together (DECISIONS §10 E); no broader plugin changes are included here.

SEC-17 — Claude reads FMS: the AI assistant connector (High, accepted with controls). Staff can add FMS to Claude and sign in with their own account; Claude then reads members, teams and reports as that person through seventeen fixed tools (src/ai/, migration 0071, DECISIONS #43, spec 05 FR-23; two of them read the staff guide's text, compiled in from docs-site/, and four the guide's plain-language project records, by role, 2026-10-05, compiled in since 2026-10-06). It is off by default (AI_ASSISTANT_ENABLED=false). Rated High by the PII floor: a connection reads member names, emails, plan dates and payment amounts.

  • Residual risks, stated plainly:
    1. Consent phishing. Someone sends a staff member a sign-in link started from the attacker's own Claude. If the person signs in on it, the attacker's Claude reads FMS as them until the connection is revoked. The sign-in page warns "only continue if you pressed Connect in your own Claude a moment ago". Nothing stops a person who ignores it. Since 2026-10-03 FMS emails the person (copy to the admin alert address) about a new connection, and since 2026-10-04 about every sign-in from Claude, so it is noticed within minutes; it still does not prevent it. Since 2026-10-05 the attacker's Claude must also carry the Cloudflare Access service token (below) to reach /mcp at all, which narrows this to someone who has the shared token.
    2. Data leaves FMS. Whatever a tool returns is sent to Anthropic and held under the retention terms of Frontier's Claude plan. FMS cannot recall it. A member's erasure request (SEC-10, DECISIONS #22) does not reach what Claude was already shown. Legal / compliance should read this beside #22.
    3. The console role does not narrow what Claude reads. Any active staff account can connect, and every connection gets the same tools about members, teams and money (a Viewer's Claude reads what an Owner's does; the role is only recorded on the trail). Since 2026-10-05 only the project records narrow by role: the decision board for owners and finance, the risk board for owners. The tools carry no secrets and move no money, and Viewers can already read the same screens. The Business owner may ask to limit it by role.
    4. Accepted after the fourth review (2026-10-04). Outside production the read pool is read-only by session default only (production uses the SELECT-only login). A replayed authorization code ends the grant, which is RFC behaviour. Claude's shared egress addresses share the per-address buckets (denial of service only). One person can occupy the three read connections for up to 15 s each. There is no RFC 9207 iss parameter on the redirect. If Claude's own Disconnect does not send client_id, it no longer ends the FMS connection; people disconnect on Your account.
  • Controls (all enforced in code, src/ai/, plus one at the edge):
    • A Cloudflare Access service token on /mcp (2026-10-05, ops/RUNBOOK.md §1). Beside the WAF clause that admits Anthropic's egress range by path, a Zero Trust Access application covers fms.frontiermyanmar.net/mcp with a Service Auth policy (token FMS-Claude) over a Block everyone policy. Claude's connector sends the token as two fixed request headers; a request without it is refused by Cloudflare and never reaches FMS. Not in code: the token is shared by all staff, expires yearly, and is kept by the operator with the other secrets and handed out by hand (no shared password manager). The per-person lock is still the FMS sign-in below.
    • Allow-list, never /admin/api. Each tool builds its answer column by column; nothing selects * or serialises a row, so a column added later does not reach Claude. views::check is a last gate: an answer carrying a denied field name (notes, IP, user agent, method/bank references, invoice links, tokens, secrets, Stripe ids) is refused whole and logged; a Stripe-id-shaped or credential-shaped value (a whole sk_live_... token, a Stripe id or client secret (pi_…_secret_…), an FMS token, a JWT anywhere in a value, a Mailchimp key, a URL carrying credentials in any scheme, or a long bare token before the @) is replaced with [withheld] on its own and the answer goes out; object keys are judged like values. Unknown filter values (tier, status, kind, currency) are refused, not ignored.
    • Read-only at the database. Every tool runs on ai_read_pool, whose connections set default_transaction_read_only = on and a 15 s statement timeout. In production that pool is its own Postgres login, fms_ai_reader (AI_DATABASE_URL, created by ops/ai-reader.sql): SELECT on the columns the tools read, column by column, teams, team_members and plans included (plans without mailchimp_tags and is_seed). On the money tables: payment_settlements without method, Stripe ids, fee_details, recorded_by; member_invoices without invoice links and description; gift_memberships without claim tokens, message and payment id; donations without payment ids; subscriptions without Stripe ids and external_method; discount_redemptions without the code; discount_codes unreadable. The Stripe invoice id is not readable; the generated member_invoices.has_stripe_invoice says only whether there is one. Also column grants on users (no vendor or website ids) and stripe_accounts (never the encrypted keys), no write privilege anywhere, no CREATE, no TEMPORARY (revoked from PUBLIC on the FMS database), no sequence rights, no other role membership, and no read of staff accounts, token tables, notes, keys, settings, logs, the outbox or the audit trail. A session default cannot be relied on alone (a statement can switch it off); this login cannot write whatever a statement asks. Required in production while the connector is on (a deployment violation: startup refuses without it), and ai::reader::verify re-checks it at every boot and on every environment switch (FMS points it at the active database and re-applies the script there first; not a superuser, no write privilege) as an allow-list: ai::reader::READ_COLUMNS names every readable column and it refuses to start if the login can read any other column anywhere, is a member of another role, can create roles, databases, objects in any schema or temporary tables, can use or advance a sequence, or lacks any listed column. A new table or column is unreadable until added to the script and READ_COLUMNS (fails closed). FMS re-applies the script at every boot (connector on, login exists) and after a restore, so operators no longer re-run it. Nothing dials Stripe or Mailchimp.
    • Per environment, off by default (DECISIONS #44, 2026-10-04; replaces production only). Each environment's database carries an owner switch, off by default in every environment, production included. The sign-in page and every tool call refuse unless the active environment is switched on and its reader passed its check. Every answer names its environment, and a sandbox's says it is test data. A clone starts switched off. The grant tables stay boot-database only and are left out of sandbox clones; a clone closes inherited pending connection emails and budget alerts so it never re-sends real mail. Residual: a sandbox's test numbers can reach a real Claude chat; the label on every answer and the deliberate, audited owner switch are the controls.
    • Per person, short-lived, revocable. OAuth 2.1 authorization code with PKCE S256 (required); dynamic client registration for public clients whose redirect targets are allow-listed to Claude's callback (claude.ai, claude.com) or loopback (Claude Code); opaque tokens stored as SHA-256; access tokens 1 hour; refresh tokens 30 days, rotated on every use; a replayed refresh token or authorization code ends the whole grant.
    • The console's rules at sign-in. Shared throttles, Turnstile, switched-off accounts and the SEC-13 default-password refusal; a temporary password must be changed in the console first. The account is re-read on every call: switching it off or an owner's password reset stops Claude at once, and both also end its connections (best effort; the token check already refuses the account). So does the person changing their own password, and that one ends them before the new password is saved and fails the change if it cannot, so "password changed" always means Claude is disconnected. A connection belongs to the password it was made with (2026-10-04): it carries a SHA-256 stamp of the password hash at sign-in, compared on every token check, so any change by any path (an owner's reset, staff-reset, a hand-run UPDATE, a sign-in that raced a change) ends it at once. The token endpoint requires client_id on both grants and checks it is the client the token was issued to, so a leaked refresh token alone is useless. A just-rotated refresh token presented again within 30 seconds (Claude refreshing twice at once) is refused and the connection stays; after 30 seconds it is theft and ends the grant.
    • Rate limits per address (register, token, /mcp) and per person (30 questions a minute, burst 60), plus an hourly budget of 300 questions at once, then 5 a minute; the first time it runs out the admin alert address is emailed once (at most once an hour per person). The per-account sign-in bucket is taken before the password check (SEC-8).
    • Audited. Every sign-in and tool call is an activity_events row on channel ai_assistant under the person's name (a throttled caller's refusals reach the trail once a minute, so a loop cannot flood it; a replayed token that ends a connection is ai.token_replayed, a disconnect sent from Claude is ai.disconnect). Tool-call rows carry no client IP (the socket is Anthropic's) and record the request's User-Agent header (the client's own claim is in details.client_info); sign-in rows carry the person's browser IP.
    • Told on every sign-in. Every sign-in from a Claude app (since 2026-10-04; before, only a new connection) FMS emails that person, Cc the admin alert address (ALERT_EMAIL_TO, else ADMIN_EMAIL): the account, app, where the sign-in went back to, time, browser address and user agent, and "if it wasn't you, Disconnect, change your password, tell an owner". A repeat is "Claude signed in to your FMS account again" and signing in again ends everything that app held before. The reason: a password plus the app's client id (not a secret) would otherwise sign in silently. The grant, the cleanup, the email (AiConnectionNotice) and the code are written in one transaction; if it fails nothing is made and an ai.sign_in row (error, not_recorded) is written, so a connection is never made silently. It needs a mail relay (Settings → Email or .env); with none it is skipped with a warning. The ai.sign_in row records new_connection.
    • Visible and cuttable. Connections are listed on Your account (Disconnect) and on Staff (Revoke all, owners). AI_ASSISTANT_ENABLED=false plus a restart hides every route but does not end connections (switching back on resumes live ones); Revoke all ends them (ops/RUNBOOK.md §9). A restore ends every connection the dump carried. Switching an account back on first ends any connection left behind. /oauth/revoke ends a connection only for a token that still works, presented with its own app's client_id.
  • Never visible to Claude: staff notes, activity and IP rows, Stripe and Mailchimp ids, bank references, invoice links, discount codes, team-invoice addresses, settings, connections, staff accounts, logs, backups, any environment Claude is switched off in.
  • Status: Accepted 2026-10-03 by Thomas with the colleague who asked; the Business owner signs by confirming DECISIONS #43, and Legal / compliance confirms the data question. It stays off until both have. Follow-up closed 2026-10-03: the person is emailed on a new connection (residual 1 is now noticed, not prevented; it depends on a mail relay being set up). Tests: tests/ai_assistant.rs, unit tests in src/ai/.
  • Code review, 2026-10-03 (docs/progress/2026-10-03.md G): the payer filter on recent_payments is now an exact address (it was a substring that also matched hidden bank references and charge ids); the token exchange is one transaction (a database failure no longer reads as a replay and ends the connection); find_members answers "member now" with FMS's access rule; the database login above was added; the per-person throttle is spent first (calls naming no tool cannot flood the trail); PUBLIC_BASE_URL is parsed, not prefix-matched; redirect_uri follows RFC 6749 §4.1.3. No residual risk above changed.
  • Second code review, 2026-10-03 (docs/progress/2026-10-03.md H): the token endpoint held two database connections per request (about ten at once could starve the shared pool and stall checkout and webhooks), now one; client_id required on both grants; the 30-second refresh reuse grace; changing your own password ends connections; % and _ in member search are literal (they listed every member), shared by the console and find_members; find_members uses the member list's own access rule and labels a retried renewal and an unpaid team-invoice seat separately. No residual risk above changed.
  • Third code review, 2026-10-03 (docs/progress/2026-10-03.md I): the reader login granted whole tables whose hidden columns (bank references, Stripe ids, invoice links, claim tokens, discount codes) no tool names; it now grants column by column, discount_codes not at all, and the boot check refuses any readable column on a never-read list (an allow-list, READ_COLUMNS, since 2026-10-04) (re-run ops/ai-reader.sql on deploy); recent_payments has its own query; your own password change ends connections first and fails if it cannot; replays and Claude-side disconnects are Audit rows; throttled calls reach Audit once a minute; one secret-looking value no longer withholds a whole answer; unknown filter values are refused; unused registrations last 90 days and a lost one sends Claude back to register. No residual risk above changed.
  • Fourth code review, 2026-10-04 (docs/progress/2026-10-04.md; three parallel reviewers, no high-severity flaw). Closed: stolen-credential silence (every sign-in is emailed and a repeat ends that app's earlier connection); the password race (a connection belongs to the password it was made with, any path); restore revival (a restore ends every connection the dump carried and re-applies the reader grants); the deny-list boot check (now the READ_COLUMNS allow-list, also refusing role membership, sequence and create/temporary rights); the parallel-guess bypass of the sign-in throttle (the bucket is taken before the password check: 20 parallel guesses reached it before, 5 now). Smaller: /oauth/revoke needs the app's own client_id; the sign-in page is closed where Claude is switched off (DECISIONS #44); the consent page names where the sign-in goes and treats the app name as a claim; an unregistered client is redirected only to Claude's hosted callbacks; a wider outbound filter; an hourly question budget; unfiltered recent_payments capped at 25 rows. Residual risk 4 above is new. Migration 0072 ends all existing connections once.
  • Fifth code review, 2026-10-04 (docs/progress/2026-10-04.md, "Fifth review"). All closed, no residual risk above changed:
    • Filter panic (closed). The outbound filter could panic on a name in Burmese or with accents near a word like "multi-user", so that answer failed. Fixed. An ordinary lowercase address such as tr_thanhtun@gmail.com was withheld as if it were a Stripe id. A Stripe-id-shaped run must now hold a capital letter. It also catches a JWT anywhere in a value and a long bare token before the @ of a URL.
    • Enable on an account already on (closed). It ended that person's Claude connections. Only enabling an account that is really off does now.
    • Sign-in against refresh (closed). A sign-in's clean-out could miss a token pair a refresh was adding at that moment. They now take turns on a lock on the connection.
    • Reader login hardening (closed). The boot check also refuses REPLICATION, TRIGGER and REFERENCES on a table, and SELECT on a sequence. ops/ai-reader.sql revokes CREATE on schema public from PUBLIC and any role membership. A grant added by hand is removed when FMS re-runs the script at boot; the boot stops only if that cannot be done.

SEC-18 — Environment list leaked database passwords (High, closed 2026-10-03). Found while building the connector. GET /admin/api/environments, readable by every console role including Viewer, returned each environment's database_url — the Postgres connection string with its password (services::environments::list). Any staff account, down to a read-only one, could read the database password of production and every sandbox. Postgres is bound to loopback and the compose network, which limited who could use it, but a leaked database password is one for the whole system.

  • Closed 2026-10-03: the list now strips the password (environments::without_password); a URL that does not parse is replaced by (hidden). The second review also strips ?password= and sslpassword= query parameters, not only user:password@. The console's own code declares the field and does not use it. Unit test listed_urls_never_carry_the_password.
  • Not checked: whether anyone read it. The activity trail does not record reads of this screen. Whether to rotate the Postgres password (POSTGRES_PASSWORD in .env, then the same change in the database) is Thomas's decision. Not recorded as done here.

SEC-19 — The Omise webhook secret and keys (added 2026-10-13, DECISIONS #48). An Omise secret key (skey_…) can charge every saved card on that account and refund; the API reference lists only public, secret and chain keys (no restricted or read-only key), so FMS's read-only connection is an FMS-side flag only. A webhook secret that leaks lets anyone forge deliveries to POST /v1/webhooks/opn/{account_id}.

  • Controls (built): both secrets are encrypted at rest with APP_ENCRYPTION_KEY (SecretBox, as the Stripe keys); a leak needs the database and the key. The connect path refuses a test key in production and a live key in a sandbox, and a key on the wrong provider's row (tests/opn_connections.rs). Every write to a live Opn account is refused unless ALLOW_LIVE_OPN_WRITES=true, and the per-account read-only flag refuses writes before a request is built (tests/opn_client.rs). The webhook secret must be valid base64 (Omise keys the HMAC with the decoded bytes); the ingress verifies any comma-separated signature over "{timestamp}.{body}" in constant time inside WEBHOOK_TOLERANCE_SECONDS, refuses an unknown or Stripe account id with an empty 404 before reading a secret, and refuses everything without a secret unless ALLOW_UNVERIFIED_WEBHOOKS (tests/opn_webhooks.rs, 13 cases). Even a forged delivery only makes FMS re-read the object from Omise with the account's own key (invariant 11).
  • Rolling: a webhook secret rolled in the Omise dashboard stays valid for 24 hours with two signatures on each delivery, so pasting the new one into Connections within that window loses nothing. A rolled live secret key is deprecated 60 minutes later (api-reference §1): connect the new key on Connections inside that hour. RUNBOOK §10.
  • Still open: Omise's webhook source IP range is undocumented (question 3), so the edge cannot allow-list it; no roll drill has been run.
  • Card entry on FMS's own billing page (2026-10-14, DECISIONS #49): an Opn-billed member changes their card at /account/billing/card (Stripe-billed members use Stripe's portal; FMS has no Stripe card path). The page reuses the checkout's Omise.js form (cardform, the card is tokenised by Omise in the browser; FMS sees only the token), sits behind SEC-15's per-address card-submit throttle, needs the member session plus a per-member form token, and re-reads the card's brand from Omise before saving (tests/ account_pages.rs, tests/opn_member_billing.rs). It adds a page to the SAQ A-EP scope of SEC-10, like /checkout. Open: the JSON twin POST /v1/me/billing/card (member token) is not behind the per-address throttle; it has no caller today.

SEC-20 — A cloned sandbox swallowed production's webhooks (High, closed 2026-10-24). A sandbox cloned from production carries production's Stripe and Omise account rows, ids and keys. Every environment registered the same bare endpoint address (/v1/webhooks/stripe/{id}), so pressing Create webhook endpoint in such a sandbox deleted production's live endpoint and created one whose events were then served by whichever environment the console had active; and switching the console to any sandbox sent production's website, webhooks and outbox into it (MIG-16's M4). Nothing in the console warned.

  • Closed 2026-10-24 (DECISIONS #55): a request's environment comes from its credential or link (env_scope), never the console switch; a sandbox's webhook address carries ?env=<label> (connections::webhook_url) and only production replaces the bare one (ensure_webhook_endpoint); a sandbox never points an Omise account's single address away from production (ensure_opn_webhook refuses); Consolidation checks each environment's own address and names an Opn address pointing elsewhere; production's background work runs whatever the console shows (crate::workers). A sandbox clone carries no service keys, and a copied key authenticates nothing inside a sandbox. Tests: tests/env_scope.rs, tests/opn_connections.rs.
  • Residual: since 2026-10-25 a sandbox refuses a live account outright (EnvironmentRefused, both registries and both webhook routes), a clone switches copied live accounts off (webhook secrets blanked) and a sandbox holding one cannot start its workers; the live brake (OPS-2) is the second line. Still connect separate test accounts to sandboxes (ops/RUNBOOK.md §6b).

Informational: admin authorization is enforced per-handler rather than by a middleware layer. An exhaustive audit (all 97 handlers in src/admin/api.rs plus the HTML routes) found no currently-unguarded route, but a new route added without the session extractor would be silently public — and SEC-6 is the live demonstration of the pattern failing: wipe_mailchimp_audience kept its session extractor while losing the refuse_in_production both its neighbours have (fixed 2026-08-26 — the wipe now refuses in production and against an inherited audience, tests/crm_gate.rs). Register as a regression risk and add a test that asserts every /admin/api/* route requires a session.

3.3 Migration / cutover readiness (MIG) ​

IDRiskLISevStatusOwner
MIG-1172 Community posts → members-only: open decision on the critical path43HighAccepted 2026-08-26 (by Thomas; the Business owner signs by confirming DECISIONS #1)Business owner
MIG-2No member-facing email (receipts, cancellation, expiry)22LowClosed 2026-09-29 (decision §2 = B, built; Stripe emails confirmed at the go-live session)Marketing
MIG-3Residual PMPro add-ons without FMS equivalent (membership-manager role, subscription delays) — donations/gifts/discounts Closed 2026-08-1322LowFixed 2026-10-24 (role 2026-10-10; delayed starts a plan setting, DECISIONS #33 B; live-site check pending)Business owner · also Technical
MIG-4Mailchimp unmaintained at cutover (no bulk push, no reverse sweep)44HighClosed 2026-10-02 (DECISIONS #3 = C)Technical · also Marketing
MIG-5PMPro category-level restrictions not synced → posts become public34HighClosed 2026-10-15 (DECISIONS #4 = A, automated in the plugin; the live-site sync is Thomas's cutover step)Business owner · also Technical
MIG-6.env.example wrong/incomplete; APP_ENCRYPTION_KEY fails silently34HighClosed 2026-08-10 (re-synced 2026-10-01)Technical
MIG-7wp_user_conflict locks a member out permanently, no remediation UI34HighClosed 2026-10-02Technical
MIG-8Spec 08 [MVP] integration health not built (no liveness/reachability)34HighClosed 2026-10-02 (DECISIONS #32 = A)Technical
MIG-9Stripe account attribution of ~879 imported subs is a manual step34HighClosed 2026-10-01 (the per-account importer binds the real account, B1)Technical
MIG-10Scheduled consolidation report off by default, undocumented33MediumClosed 2026-08-25Technical
MIG-11Spec 05 [MVP] statistics (churn/acquisition/gross volume) not built52MediumClosed 2026-09-02 — revenue, churn and acquisition builtFinance
MIG-12Rollback lossless only pre-writes (per segment since 2026-10-15: Hand back); master-switch asymmetry; outage-policy dead33MediumClosed 2026-10-27 (outage half built, Member Gate 0.57.0, DECISIONS #20 C; rig check owed)Business owner · also Technical
MIG-13No CI; no DNS/TLS step; no member-comms plan; transient-backed tokens33MediumPartial 2026-10-01Marketing · also Technical
MIG-14Doc/version drift; dead BYPASS_WP_AUTH_GATE env var; no history migration; stale roadmap; ~40 open questions42MediumClosed 2026-08-14Technical
MIG-15Live-run API hiccups: importer dies at the proxy timeout and can't pass 20k subs; serial outbox; no HTTP timeouts; Mailchimp 400s retried for hours; resyncs create unconsented contacts; Stripe-billed members unbound when addresses differ44HighClosed 2026-09-23Technical
MIG-16Migration at full size (~20k Community + ~1k paid): every slow Consolidation check and the invoice backfill die at the edge's 100 s cut; the Phase C bulk re-sync fans out from the browser; one failed Mailchimp read sinks a 21k report; per-client Mailchimp budgets; 10-connection DB pool; a sandbox left active parks production's outbox; CRM_SYNC_DISABLED discards44HighPartial 2026-10-12 (code landed; measurement and three server settings at cutover)Technical
MIG-17During the soak (the days between the all-at-once hand-over and PMPro off; weeks under the 2026-10-15 segment plan) PMPro's own expiry cron and Mailchimp add-on still act on members FMS already serves (tags stripped, levels dropped, PMPro's expiry email); hand-back leaves PMPro's copy of an FMS-side comp change stale33MediumPartial 2026-10-15 (gate defers to FMS, observer counts, re-sync restores; the rest ends when PMPro is off); window shortened to days 2026-10-27Technical · also Business owner
MIG-18Outstanding PMPro gift codes and discount codes are not migrated and nothing counts them (audit #15)33MediumClosed 2026-10-27 (none exist, owner's statement)Business owner · also Technical, Finance

MIG-1 — 172 Community posts → members-only (open decision). Because FMS community confers no capability by design, 172 posts free Community members read today become paid-only. The decision (leave public / accept lockout / add a community capability) has been open since 2026-07-14 (cutover-runbook.md:370-378) and the rehearsal demonstrated the lockout "as designed."

  • Required action: make the call and, if keeping access, edit the 172 rows before import; block cutover on it.
  • Accepted 2026-08-26 (owner decision): accept the lockout. The 172 posts migrate as members_only; free Community members keep their account and newsletters but lose article access, which is the intended upgrade nudge (spec 04: community confers no capability). No rows are edited and no code changes. Consequence to plan for at cutover: a visible day-one support/churn signal from the ~22k Community cohort — the MIG-2 email decision and the member-comms step (MIG-13) should say so before the flip.

MIG-2 — No member-facing email. (Closed 2026-09-29: DECISIONS §2 = B, built the same day.) FMS sent none; the plugin only emailed signup/email-change/gift confirmations. Members joining, renewing, or cancelling got no email; end-dated comp members got no expiry warning. PMPro sent all of these. Now FMS sends ends soon, has ended, is cancelled and welcome through the relay (services::lifecycle, spec 06 FR-18), each switched on per environment; Stripe keeps receipts, renewal notices, failed-payment and 3DS emails; the win-back campaign is Mailchimp's (fms:lapsed). Residual: the Stripe dashboard settings (DECISIONS §2 M8) are confirmed at the go-live session, and imported PMPro end dates decide the first ends soon batch, so they are checked before G1 is on.

  • Required action (done 2026-09-29): decide the receipt/notification story decided and built; still to do at the go-live session: verify Stripe's receipt, renewal, failed-payment and 3DS emails are on for both accounts (TH's failed-payment emails were turned off in the July rescue).

MIG-3 — Deactivated PMPro add-ons with no replacement. (Re-statused Partial 2026-08-13; the three revenue surfaces are Closed 2026-08-13, re-scored 5/3 High → 2/2 Low.) The original finding said donations and discounts were [Phase 2] and gift levels out of scope — no longer true. One-off donations (/donate, brand-routed: Visa/MC→TH, AMEX→SG), gift memberships (/gift, full purchase → claim link → end-dated comp → continuation-offer lifecycle) and discount codes (once/forever) all shipped 2026-08-10 (a7a340f) and were live-fire verified against the real TH+SG sandbox accounts 2026-08-13. Residual scope is the membership-manager role and subscription delays only — neither is a revenue path.

  • Required action: confirm whether the membership-manager role and subscription delays are actually in use on the live site; if so, decide replace-vs-accept before disabling PMPro. (Business owner: DECISIONS.md #33.)
  • Fixed 2026-10-24: both have an FMS equivalent. The role is Membership manager on Staff (2026-10-10). Delayed starts are a plan setting, First payment after (days) (plans.delay_days, migration 0096, spec 05 FR-13b): a new Stripe card signup is created trialing until day N, with access at once; never a plan change, grant, gift, team or Opn account. Member Gate 0.56.1's readiness row Subscription delays lists the PMPro levels that wait. Residual: the live-site check, and a PMPro date-pattern delay (e.g. Y1-05-01) has no exact equivalent (nearest number of days). tests/plan_delayed_start.rs, docs/progress/2026-10-24-delayed-start.md.

MIG-4 — Mailchimp unmaintained at cutover. Both importers deliberately emit no outbox events (to avoid flooding the worker), and no runbook step runs the bulk CRM push afterward (member_import.rs / stripe_import.rs backfills are opt-in and unlisted); there is no Mailchimp→FMS reverse sweep. After pmpro-mailchimp is deactivated, tags and PMPLEVELID freeze and drift from day one.

  • Required action: add an explicit, throttled bulk-CRM-push step to the cutover checklist; build a reverse sweep (FIN-10).
  • 2026-09-23: the push is now safe to run at cutover scale — it drains on the outbox's own concurrent CRM lane (money events never wait behind it), shares one per-key client, and is update-only (it can never add contacts). The checklist step itself and the reverse sweep are still open.
  • Re-synced 2026-10-01: Partial since 2026-09-23 — the bulk re-sync is safe at full scale (own CRM lane, update-only, never creates a contact). Still missing: the live-Mailchimp arming switch, the runbook step, and the reverse sweep (DECISIONS.md #3).
  • Closed 2026-10-02 (DECISIONS #3 = C): ALLOW_LIVE_MAILCHIMP_WRITES (boot-time, reported at every start, never a startup violation) arms Consolidation's Mailchimp fixes — the per-member re-sync, Re-sync all drifting, the level backfill and the reverse sweep's strip — against the live audience. One gate, connections::mailchimp_fix_refusal: the SEC-6 sandbox rule first (never overridden), then, in the production environment, the switch. Finding while building it: connecting an audience always stored mode = 'test', so the old label check never refused a console-connected production audience; the gate now keys on the environment. Console: a Mailchimp fixes off for the live audience / armed badge on Consolidation and Connections (GET /admin/api/connections → brakes). Runbook: Phase V gate (the drift is the expected drift) and the Phase C clean-up window (docs/migration-runbooks/README.md; rehearsal notes in local-rehearsal.md and new-vps-test-cutover.md). The reverse sweep is FIN-10's. tests/crm_gate.rs, tests/live_mode_brake.rs, config unit tests.

MIG-5 — Category-level restrictions become public. sync-restrictions.php:162 skips PMPro category-level rules ("not supported; restrict those posts individually"). Any post gated only by a category rule becomes public the moment PMPro is off — a content leak surfaced only as a UI count, with nothing blocking the flip.

  • Required action: enumerate category-gated posts on the live site and convert them to per-post caps before cutover.
  • Closed 2026-10-15: both PMPro syncs (Member Gate 0.50.0's Sync restrictions from PMPro, the migration assistant 0.4.0's restrictions job) now call one shared FMS_MG_Restrictions::flatten_pmpro_category_rules() after the per-post rows: every post a category rule covers gets its own gate row, mirroring pmpro_has_membership_access() — the post's own terms (no ancestors; tags too from PMPro 3.8.1), joined by term id, deleted levels ignored, the lowest level across category and per-post rules wins — over every status but trash/auto-draft. Both stale-row cleanups keep those posts, the summaries report "N category rule(s) on K categories flattened onto M post(s), P of which had no per-post rule" instead of the old warning, a read-only preview sits under the button, and the PMPro deactivation readiness check now fails on a category-covered post with no gate row (an article filed after the last sync). The rehearsal export 06_restrictions_export.sh flattens the same way. Residual: a post added to a formerly locked category after PMPro is gone gets no rule (editorial habit); a Community-readable category becomes paid-only, as MIG-1. Verified by php -l and throwaway scripts (the pure merge, and the DB paths against SQLite — not MariaDB); not yet run on the rehearsal rig or the live site. docs/progress/2026-10-15-mig5-category-rules.md.

MIG-6 — .env.example wrong and incomplete. It documents the contradicted Stripe-Connect header model, ships SEED_DEV_DATA=true and a STRIPE_WEBHOOK_SECRET the runbook says must be unset, and omits APP_ENCRYPTION_KEY entirely — which fails silently (config.rs makes it optional-empty, and a wrong/empty key "silently breaks every Stripe/Mailchimp call"). An operator configuring from it gets an instance that boots, looks healthy, and fails every encrypted-secret operation.

  • Required action: rewrite .env.example for production (correct architecture, safe defaults, document every required var); make an empty/mismatched APP_ENCRYPTION_KEY fail startup loudly.
  • Re-synced 2026-10-01: Closed 2026-08-10. .env.example documents APP_ENCRYPTION_KEY and the standalone-account model, and a missing or bad key refuses to boot in production (src/config.rs:355-365). The scan table had not caught up with §5.

MIG-7 — wp_user_conflict permanent lockout. When a member's WP id conflicts, the plugin logs and fails closed (wordpress-plugin/fms-member-gate/includes/class-fms-client.php:151), and there is no admin UI to unbind/rebind wp_user_id (only an email-change endpoint exists). At 23k members with known duplicate/case-variant emails, this will lock real members out with no self-service or operator remediation.

  • Required action: build an admin screen to inspect and rebind wp_user_id; pre-scan the import for conflicts.
  • Closed 2026-10-02: both. A refused sign-in now leaves a trace: resolve_service_key_user records the website account that tried, on the member it would have been (wp_identity_conflicts, migration 0068, one row per member, attempts counted; best-effort — the 409 stands either way). The member page's Website account card shows the binding, the refused account, and who holds it; Move #N to this member (repository::wp_identity::rebind: off the other member, onto this one, one transaction), Forget website account (unbind; the next sign-in binds again) and Keep … and dismiss. GET/POST /admin/api/members/{id}/website-account (read: every role; repair: support + owners; an AuditNote names the action, the account and who it moved from). Nothing CRM or billing reads the binding, so no resync. The member import pre-scans every upload (member_import::scan_identities, preview and apply): addresses and website ids on more than one row, rows whose address FMS has bound to a different website account, and FMS members whose addresses differ only by case — reported in the preview as Fix these before applying, never skipped (the console also checks the whole file, across upload chunks). tests/wp_user_rebind.rs, tests/member_import.rs.
  • Code review 2026-10-03: still Closed. Support may now move only the website account that tried to sign in as the member; any other move is owner-only (DECISIONS #40). The rebind takes an advisory lock, reports a lost race as a validation error rather than a 500, and records the account the member loses (replaced_wp_user_id). A lost binding race no longer leaves a misleading conflict note, repeats within a minute are not written again, and a later successful bind clears the note. docs/progress/2026-10-03.md.

MIG-8 — Spec 08 integration health not built. The health monitor is internal-only (src/services/health.rs:3) — no Stripe/Mailchimp liveness checks, no webhook inbound-reachability probe, no flap/debounce. A revoked Mailchimp key, an expired regional Stripe key, or a deleted webhook endpoint produces no alert — the exact silent failure the project exists to prevent. (Partial compensations: the daily payment-health tripwire and the reconciliation webhook-endpoint config check exist.)

  • Required action: add per-account Stripe + Mailchimp liveness and webhook-reachability probes with debounce, wired to the alert transport. (Build-or-accept: DECISIONS.md #32.)
  • Closed 2026-10-02 (DECISIONS #32 = A): services::integration_health. A poller (every INTEGRATION_HEALTH_POLL_SECONDS, 300) probes each connected Stripe key with GET /v1/account (a read; brake-exempt) and the Mailchimp audience fetch, storing the outcome in integration_probes (migration 0069, per environment; apart from the person-verified status on Connections). Health reads the rows only: stripe_account:{id} — the key (refused = down at once; no answer = down after two misses; not checked for three intervals = degraded) and webhook arrival (the latest of the idempotency marks, the raw archive and the subscription watermark; silence over 48 h with live subscriptions = degraded) — and mailchimp. Alerts ride the existing transition emails + HEALTH_ALERT_WEBHOOK_URL; not critical (a slow Stripe must not 503 the public probe). A Re-check on Connections records a probe, so a fixed key clears at once; disconnecting or deleting forgets the probe and closes its alert. tests/integration_health.rs (against the mock), unit tests. Not built: spec 08 FR-5 (capability verification).
  • Code review 2026-10-03: still Closed, four corrections. A Mailchimp key that cannot be read or decrypted now reads down instead of deleting its row and closing its alert; a database blip on either key is a missed check, not a revoked key. Webhook arrival reads only what webhooks wrote (checkout and finalize stamping the subscription no longer hide a dead endpoint), with (stripe_account_id, received_at) indexes (migration 0070). Silence degrades only when a renewal was due more than 48 h ago and nothing has arrived since, so a quiet account does not cry wolf. The first poll runs at start, and an old row is not reported as a stopped poller until this process has polled that environment. docs/progress/2026-10-03.md.

MIG-9 — Manual Stripe account attribution. The shell toolkit — not the Rust importers — assigns every imported paid sub to the placeholder --stripe-account-id (migration/steps/02_subscriptions.sh:128-160); the real account (~879 rows, ~204 discrepancies) must be fact-checked by hand (cutover-runbook.md:359-367). Wrong attribution → the member's portal opens against the wrong Stripe account and reroute uses the wrong capability config. Note the dependency on B1: stripe_import.rs imports per connected account and binds the real one, so if B1 is resolved in favour of the parallel-run runbook (plugin export + Stripe importer), this risk largely dissolves.

  • Required action: if the toolkit path is used, complete the attribution step and verify before go-live as a blocking checklist item; if B1 lands on the parallel-run path, mark this risk closed by that decision.
  • Re-synced 2026-10-01: Closed. Stripe subscriptions are imported by the per-account Rust importer, which binds the real account; the shell toolkit is rehearsal-only (migration-runbooks/README.md, B1 2026-08-10). §5 box ticked.

MIG-10 — Consolidation report off by default. The scheduled drift digest requires CONSOLIDATION_REPORT_INTERVAL_SECONDS > 0 (src/main.rs:194), which is absent from .env.example; the compensating control for MIG-8 won't run in prod unless someone sets an undocumented var. (Re-verified 2026-08-14: still absent from .env.example — remains Open.)

  • Required action: document the var and enable it in the production config.
  • Closed 2026-08-25 (c7b6df3): documented in .env.example (0 = off; 86400 = daily) and whitelisted in docker-compose.yml. Setting it > 0 is a per-deployment step (RUNBOOK §2).

MIG-11 — Statistics not built. Partial 2026-09-02. Spec 05 [MVP] churn / acquisition / gross-volume endpoints and the Statistics page did not exist; per-account breakdown did. Finance would lose revenue/churn visibility the day PMPro's reports disappear.

  • Revenue is now built — the Reports tab: gross / Stripe fee / net per settlement currency, by UTC month and by source, from the payment_settlements ledger, including money collected outside Stripe. This closes the "Finance can't see revenue" half, and answers 05-Q1's multi-currency question (report each currency separately; never convert). docs/progress/2026-09-02.md.
  • Remaining: churn rate and acquisition rate. Confirm their definitions (05-Q1 — rolling window? do comp/$0 subs count?) and build them, or explicitly accept that gap for launch.
  • Re-synced 2026-10-01: Closed 2026-09-02. Churn and acquisition landed the same day as revenue (services::growth, GET /admin/api/reports/growth, tests/growth.rs).

MIG-12 — Rollback fragility. Rollback is only truly lossless before FMS takes writes, and the two runbooks disagree on the primitive; once PMPro is deactivated, settings.php:135 keeps FMS gating on regardless of the master switch, so the "one-click rollback" no longer stands alone; and fms_mg_outage_policy()'s 'pmpro' fallback is dead after deactivation, so an FMS outage then locks every member out of every restricted post.

  • Required action: define one rollback procedure that accounts for post-write divergence; change the post-deactivation outage default to a safe fail-open-or-cache policy. (Outage policy owned by the Business owner: DECISIONS.md #20.)
  • Re-worded 2026-10-27 (DECISIONS #52 note): everyone is handed over on one morning; the rollback during the soak is the master switch (the website, at once) and Hand back… per segment, team or member (FMS stops acting), or for everyone the console's red Hand back everyone… card at the bottom of the Migration page (added the same day). The 2026-10-15 wording below still describes the tools.
  • Outage half built 2026-10-27 (DECISIONS #20 = C, 24 h; progress/2026-10-27-outage-last-answer.md): Member Gate 0.57.0 adds the outage policy last_answer (the new default; lock and pmpro remain): each authoritative answer is also kept as a per-member copy (fms_mg_lastgood_{uid}), served only when FMS does not answer (transport, bad response, unauthorized, 5xx, 429) and only within fms_mg_last_answer_hours (0–72, default 24; FMS_MG_LAST_ANSWER_HOURS wins) and the same generation (bumped when the environment, API URL or key changes). Visitors stay locked; a non-member answer overwrites the copy; signing out drops it. The owner's second answer: a site still on pmpro behaves as last_answer once PMPro is inactive, so the dead fallback no longer locks every member out. A readiness row warns on lock or 0 hours; the admin notice says which policy is serving. A site that never saved the setting moves from lock to last_answer; a stored lock stays. Residual (accepted by the decision): a membership that ends during an outage reads until the copy runs out. Owed: the rig check (seven steps in the progress note). With the rollback half already settled, MIG-12 is closed.
  • Re-worded 2026-10-15 (DECISIONS #52): the rollback is now per segment. Until PMPro is off, a segment, a team or a member is handed back on the console's Migration page and PMPro serves them again; nobody else moves. It is lossless for those members only while FMS has taken no writes for them (a cancel or plan change through FMS stays in Stripe, but PMPro's copy of a comp change is stale, MIG-17). Unticking the master switch is still the website's rollback for everyone at once; it does not re-hold anyone in FMS, so hand the segments back too. The outage half of this risk is unchanged.
  • Re-synced 2026-10-01: Partial. The rollback procedure was unified by B1. The outage policy still defaults to lock and its only alternative, pmpro, stops working once PMPro is off (wordpress-plugin/fms-member-gate/includes/gate.php:351-353); the plugin remembers an answer for 300 s. The policy is DECISIONS.md #20 (Business owner). Added to the §5 cutover checklist 2026-10-01.

MIG-13 — Process gaps. No CI exists (this is how B0 reached HEAD); no DNS/TLS/vhost step and no member-communication plan appear in any runbook; signup/email-change/gift tokens and the mail budget are transient-backed and break on multi-server hosting.

  • Required action: add CI (build + migration-dup check + scoped tests); add DNS/TLS and member-comms steps to the runbook; require a persistent object cache on the live host.
  • Note 2026-10-27 (MIG-12's outage half): Member Gate 0.57.0 keeps each member's last answer in a transient. A persistent object cache may evict that copy before its 24 hours are up; the member then fails closed (locked, with the "can't check your membership" card), never open. (Member comms owned by Marketing: DECISIONS.md #19.)
  • Re-synced 2026-10-01: Partial. CI exists since 2026-08-10 (.github/workflows/ci.yml, 3f65a87); certificate and TLS steps are documented in the split-host and same-host runbooks and ops/RUNBOOK.md; the object-cache requirement is in the plugin readme only. The member-communication plan is DECISIONS.md #19 (Marketing). Member comms added to the §5 cutover checklist 2026-10-01.

MIG-14 — Drift and open questions. — Closed 2026-08-14 (docs synced). The 2026-08-14 documentation pass refreshed docs/ROADMAP.md "Where we are" (it had been ~6 weeks stale) and the associated doc drift; the open-question triage lives with the specs. Original finding kept as the record: readme.txt Stable tag 0.21.0 vs plugin 0.26.0 with no update channel; BYPASS_WP_AUTH_GATE written env-var-style (BYPASS_WP_AUTH_GATE=false) in the runbook but actually a compile-time const false — the runbook parenthetically notes the const, yet the = syntax invites setting it in .env and believing it took effect; PMPro membership history migrated by nothing; docs/ROADMAP.md "Where we are" ~6 weeks stale (calls shipped Sprint 4 "next"); ~40 open spec questions remain, including 05-Q5 (admin auth, "blocks FR-20"), 02/01 currency-FX, and PRD Q9/Q10 baselines never gathered.

  • Required action: triage the open questions into pre-cutover vs. deferrable; refresh the roadmap; correct or remove the dead env-var documentation.

MIG-15 — Live-run API hiccups. — Closed 2026-09-23. A pre-cutover review of every external call on the PMPro→FMS path found: the Stripe importer ran inside one admin request (dies at nginx's 120 s) and its 200-page cap restarted at page 1, so more than 20k subscriptions could never be imported; the outbox delivered one event at a time, so a bulk Mailchimp re-sync held reroutes and settlements back for hours; neither HTTP client had a timeout; Mailchimp 400s were retried 12× (~2.4 h) before dead-lettering; every re-sync upserted with status_if_new: subscribed; Stripe-billed members were skipped by the WordPress export, so one whose Stripe and WordPress addresses differ was never bound; and past_due members lost access on migration day. All closed — resumable import job, two outbox lanes, timeouts + Retry-After, permanent rejections + address markers, update-only re-syncs, binding rows (plugin 0.36.0), dunning grace. docs/progress/2026-09-23.md. Residuals are listed there (Consolidation's own Mailchimp client, PATCH-404 ambiguity on a deleted audience). Follow-up 2026-10-15 (migration audit #10–#13, cdece76, Member Gate 0.52.0): binding rows were not enough on their own. A Stripe-billed seat member had no binding row, and a Stripe-billed team admin was exported as an open-ended external grant. Both are now binding rows, and the admin's team hangs on their Stripe team_* row. Stale PMPro Stripe ids are resolved by website account or address (matched_by_identity). A WordPress address that is another FMS member is a row error (two_members_for_one_person). The Stripe importer finds a member by the subscription or customer it already holds before the address, so a re-run after an address change no longer mints a duplicate. Still open: a Stripe customer with no email that FMS has never seen is not created from the binding row. docs/progress/2026-10-15-segment-migration.md.

MIG-16 — Migration at full size. — Partial 2026-10-12. A second pre-cutover review, against ~20k free Community and ~1k paid Stripe-billed members, verified each claim on main. MIG-15's fixes hold (bulk import, importer, outbox lanes, Mailchimp client, update-only re-syncs). What was left: every slow Consolidation check ran inside one HTTP request, one Mailchimp GET per member (≈21k) or one Stripe GET per subscription (~1k sequential, ~200 s), and Cloudflare cuts a request at 100 s (general knowledge) before nginx's 120 s (ops/nginx/fms.frontiermyanmar.net.conf); FMS has no request timeout, so the handler was cancelled and the work lost (H2). The same held for the Phase D1 invoice backfill (no page cap, no resume, H4), Payment health, the Mailchimp → FMS sweep and Strip all, which re-swept the audience (H5). Re-sync all drifting, the Phase C clean-up step, fired one request per drifting member from the browser, each with its own Mailchimp client (H1), and drift may be ~20k because a present Community contact with no PMPLEVELID is level drift (services/pmp_level.rs:145). One failed Mailchimp read aborted the whole report (H3). The rehearsal never timed a Mailchimp check (H6). Also: Mailchimp's 10-connection budget was per client object, not per key (M3, crm/mailchimp.rs:174); the DB pool is 10 (DB_MAX_CONNECTIONS), shared by requests, both lanes, sweeps and Consolidation, while the WordPress gate gives up at 5 s (M1); grants already ended at import are downgraded or cancelled by the hourly sweep without warning (M2); a sandbox left active parks production's outbox, money lane included (outbox/worker.rs:125-134, M4); CRM_SYNC_DISABLED marks events done without calling Mailchimp, so it discards the queue rather than pausing it (crm/worker.rs:261-268, M5); a Community newsletter edit queues ~20k re-syncs with no warning (M6); behind Cloudflare with no nginx real_ip, per-IP limits are shared per edge (M7); Google's daily-limit reply is retried about 6 h then parked as Failed, against ≈2,000/day on smtp.gmail.com and ≈10,000/day on smtp-relay(general knowledge) (L1); signups waited behind the clean-up backlog (L2).

  • Landed 2026-10-12 (b36c386, bf58283): Consolidation checks, the invoice backfill and the full report run as background jobs (consolidation_jobs, migration 0081: one at a time, progress, Cancel by lease, last result kept, lost runners and restart orphans failed, 30-day retention); an unreadable member is retried once then counted, and the report says complete: false and INCOMPLETE; Re-sync all drifting and Strip all act on the stored result in one request; the five in-request GET routes are removed; one Mailchimp budget per endpoint + key (shared_semaphore); signups claimed first on the CRM lane (partial index 0082); the bulk import reports already_ended; the plan editor asks above ~1,000 re-syncs; the PMPLEVELID backfill is two statements; stub knobs STUB_LATENCY_MS / STUB_MAX_CONCURRENT; migration assistant 0.3.2 (L4).
  • M4 closed 2026-10-24 (DECISIONS #55): production's background work is its own worker set (crate::workers), started at boot and pinned to production's database, so a sandbox the console shows no longer parks production's outbox, money lane included; a sandbox's work runs only while an owner has started it. Requests reach the environment their credential or link names, not the active one. tests/env_scope.rs.
  • Still to do before cutover (Technical): run the measurement protocol on the test VPS (migration-runbooks/measure-throughput.md) and Size the day a week ahead (runbook Phase P step 5); set DB_MAX_CONNECTIONS=30 and the nginx Cloudflare set_real_ip_from + real_ip_header CF-Connecting-IP lines. (Keeping production active on the day is no longer needed: M4 closed 2026-10-24.) Ship the console's job-based Consolidation screen with this backend (it removes routes the current screen calls).
  • Residual: the Mailchimp path is untimed until the measurement; a one-scan audience read (A1b) waits on it; lapsed members are not exported, so fms:lapsed misses pre-cutover lapses (L3, a product decision). docs/progress/2026-10-12.md.
  • 2026-10-15 (cdece76 + the console pass): the bulk import is still a browser loop over chunks. Chunks are now packed under 900 KB, so the activity trail records each one. Each chunk carries the whole file's identities, so FMS lists the held members the export no longer carries; staff end them with Reconcile absent members…. Verify compares every row with FMS, read-only. The Migration page's Last member import is stamped in migration_marks (migration 0089) on every apply, whatever the body size. already_ended (M2) stays a warning and is not part of the "Looks clean" gate (problems).

MIG-17 — PMPro still acts on handed-over members. — Partial 2026-10-15. The migration (DECISIONS #52) keeps PMPro active after members are handed over. Under the 2026-10-15 segment plan that was weeks, as the authority for every member not yet handed over; since 2026-10-27 everyone is handed over on one morning and PMPro stays active only for the soak, two to three days (the owner may shorten it to the same day), so a rollback stays a click. PMPro does not know which members FMS now serves. Its own cron still ends a comp member's level when PMPro's end date passes, its Mailchimp add-on still strips that member's tags, its expiry emails still go out (general knowledge of PMPro; unverified on the live site), its Stripe webhook still follows a cancel made in the Stripe portal, and a staff edit in PMPro's wp-admin still changes its copy (and may act on the gateway, unverified). Handing a segment back after its members changed something through FMS leaves PMPro's copy of a comp change stale.

  • In place (2026-10-15): the plugin's pmpro_has_membership_access_filter makes PMPro's access check give FMS's verdict for a handed-over member, so a dropped PMPro level locks nothing; a pmpro_change_level observer lists PMPro changed N handed-over members since … on the plugin's settings page (never vetoes); the hand-over queues one update-only re-sync per member, and any later re-sync for that member (their next change, or Consolidation's Re-sync all drifting with ALLOW_LIVE_MAILCHIMP_WRITES) puts the tags back. PMPro's own account, billing, cancel and checkout pages send a handed-over member to FMS's.
  • Required action: keep the soak short (the owner sets its length, #52); read the observer's list each morning of the soak and before PMPro is off; staff change handed-over members in the FMS console only; run the Phase C Mailchimp clean-up after PMPro's add-on is off. Rehearse it: a handed-over comp member whose PMPro end date passes on the rig.
  • Ends when PMPro is deactivated (the readiness check refuses until nobody is held).
  • Owner: Technical; the Business owner accepts the residual as #52's price (or asks for PMPro's cron to be suppressed for handed-over members, not built).

MIG-18 — Outstanding PMPro gift codes and discount codes. — Closed 2026-10-27 (none exist, owner's statement). Audit #15. Nothing in the plugin reads pmpro_discount_codes or the Gift Levels add-on's data; FMS's gift_memberships are made only by /gift. After PMPro is off, the checkout interceptor drops a discount_code and sends ?pmpro_level=N to the paid FMS checkout (pmpro-compat.php), so a gift bought in PMPro before the cutover and not yet claimed asks its recipient to pay, and a group discount code handed to a company stops working without notice. Unverified: the exact URL PMPro Gift Levels mails. The count on the live site is unknown.

  • Required action: count unredeemed gift codes and discount codes with uses left on the live site (Technical); the Business owner chooses between honouring them by hand (a comp grant or an FMS discount code per case) and an export into gift_memberships with re-sent claim links (the audit's smallest fix: a blocking, acknowledgeable readiness row plus the export).
  • Owner: Business owner · also Technical (the count), Finance (money already taken for a gift).
  • Closed 2026-10-27: the Business owner confirmed that no unredeemed PMPro gift memberships and no PMPro discount codes with uses left exist, so there is nothing to count, honour or export, and the runbook's count and Phase C settle steps were removed. If one turns up, staff grant the membership by hand (a comp grant on the member page).

3.4 Operational (OPS) — added 2026-08-14 ​

IDRiskLISevStatusOwner
OPS-1Backup/DR: pipeline built + drilled locally; real R2 target + drill pending; no backup-freshness check24MediumPartial 2026-10-02 (a blank target now warns; real bucket + drill at deploy)Technical
OPS-2Accidental mutating Stripe writes against live accounts from dev/sandbox tooling35CriticalClosed 2026-08-12Technical
OPS-3Two card providers to reconcile: money settles in THB on Opn and USD/THB/SGD on Stripe; Stripe-side checks do not see Opn rows; Omise never retries a webhook33MediumPartial 2026-10-14 (per-currency Reports, provider labels, Opn reconcile — the only safety net for a missed webhook, so it stays on; no Opn↔FMS Consolidation check)Finance · also Technical
OPS-1 — Backups & disaster recovery. Built 2026-08-12 (5d9314d, closing Sprint 8): a
scheduled compose backup service takes a nightly pg_dump+gzip, ships it via rclone to R2, and
prunes to 7 daily + 12 weekly; deploy.sh, restore.sh and ops/RUNBOOK.md exist. A restore
drill ran 2026-08-12 against a local rclone remote — all 32 tables row-count identical after
restore. Residual: the real R2 bucket, scoped token and lifecycle rule are still pending operator
tasks; no drill has run against real R2; and there is no backup-freshness health check — blank
R2_* vars silently degrade the job to local-only dumps sitting on the same host as the database.
  • Required action: provision the R2 bucket + scoped token + lifecycle rule; re-run the drill against real R2; add a backup-freshness signal to the health monitor so silent local-only degradation alerts.
  • Re-synced 2026-10-01: Settings → Backup → Off-site copies runs the nightly R2 upload from the console, with an offsite_backup health component (26 h, non-critical; 2026-09-11). It has never run against a real bucket, the drill is planned post-cutover, and the component only appears once a console target is set, so a blank configuration still fails silently.
  • Narrowed 2026-10-02: a blank configuration no longer fails silently. Under APP_ENV=production on the production database, no console target makes offsite_backupdegraded ("not configured — the only copies are on this server"), unless compose says the .env sidecar is the uploader (BACKUP_SIDECAR_TARGET, from R2_BUCKET/BACKUP_REMOTE_PATH), which reads ok but unobserved. Both configured is degraded (two uploaders prune one prefix, RUNBOOK §4.0). Laptops and sandboxes show nothing. tests/backup_settings.rs. Still open: the real bucket and the restore drill against it — deploy-day tasks.

OPS-2 — Live-mode Stripe write brake (mitigating control). — Closed 2026-08-12. Built as a967940: every mutating Stripe call against a mode='live' account is refused unless ALLOW_LIVE_STRIPE_WRITES=true (an explicit arming override); reads are exempt; the typed LiveWriteRefused 403 dead-letters immediately and is replayable once armed; the reroute worker asserts the brake up front. Covered by tests/live_write_brake.rs (5 cases). Recorded here as the mitigating control for the accidental-live-write face of the FIN-2 / FIN-7 / MIG-9 family — it bounds wrong-account money movement from tooling, seeds and rehearsals, though it does not fix those risks' own logic defects. Residual (minor, accepted within the Closed status): brake state is reported at startup only; there is no admin-UI banner showing whether live writes are armed.

OPS-3 — Two providers to reconcile (added 2026-10-13, DECISIONS #48). With Opn connected, money arrives through two providers with different shapes: Omise settles every charge in THB (funding_currency), fee fee + fee_vat, and its own events list. Finance month-ends and Consolidation were built for Stripe alone.

  • Mitigation (built): Reports show net / gross / fees per settlement currency, never combined, so Opn's THB is its own section (tests/opn_settlements.rs reports_show_a_thb_section_of_their_own); the member page, the payments ledger and CSV exports name the provider (Card · Opn). The Stripe-side Consolidation loops (System ↔ Stripe, Money ↔ Stripe, product sync, the Stripe importer) take Stripe rows only, so an Opn row is never reported as missing at Stripe; duplicate detection includes Opn (a member billed by both is flagged, resolved by hand). Opn's own reconcile (services::opn_reconcile, GET /events + lost answers + abandoned checkouts) runs every OPN_EVENT_POLL_SECONDS and on demand at POST /admin/api/connections/opn/{id}/reconcile (owner). Health shows opn_account:{id} (key probe, webhook silence).
  • 2026-10-14, Opn support: Omise does not retry webhook deliveries — a missed or refused one is gone. The GET /events poll and the reconcile are therefore the safety net, not a back-up: they must stay on (OPN_EVENT_POLL_SECONDS, 900), and the edge must let Omise's published IPs reach /v1/webhooks/opn/ (RUNBOOK §10.1 step 7). Refund fees now follow Opn's rule: credited back only on a void (a full refund before settlement); a charge voided before it ever settled records no ledger row instead of retrying forever (tests/opn_settlements.rs).
  • Still open: no Consolidation check compares FMS's Opn rows with Omise's charges and refunds (the Money ↔ Stripe twin).

4. Corrections to prior beliefs ​

So the register is not read as uniformly negative, and so stale notes get fixed:

  • Plan/quantity drift detection is implemented (reconciliation.rs, plan_mismatch), and Stripe webhook-endpoint checks are implemented (existence + stale-event list). Prior project notes list both as gaps — that is stale.
  • Webhook signature verification is correct — HMAC-SHA256 over the raw body, all v1= candidates checked, constant-time compare, replay window, empty-secret refusal. The webhook risk (SEC-4) is the ungated dev hatch, not the crypto.
  • SQL is fully parameterized; the only format!-built SQL is database DDL over a validated, force-prefixed identifier.
  • PCI tokenization is correct — card data never reaches FMS; only pm_… tokens transit.
  • The WordPress plugin is the strongest-implemented component — consistent nonces, capability checks, no IDOR (identity derived server-side from the logged-in WP user), no unauthenticated endpoints, and the service key never reaches the browser. Its one material weakness is SEC-9 (plaintext option storage).
  • Member auth is soundly constructed — HS256 pinned on encode and decode, single-use hand-off tokens fenced by a purpose claim. The gap is revocation/refresh, bounded by short service-token TTLs.

5. Cutover blocker checklist ​

Must be closed (or explicitly accepted, in writing, by the Business owner on the risk owner's advice — rule set 2026-10-01) before PMPro is disabled on the live site. An acceptance is recorded on the item as Accepted by <name> on <date>: <what is accepted and what watches it>. Everything not on this list can be scheduled post-cutover with monitoring.

Hard blockers — cutover cannot proceed:

  • [x] B0 — migration 0025 duplicate fixed, committed, and reconciled per environment (done 2026-08-10; guard test + CI added; per-environment fixup in migration-runbooks/sqlx-0025-fixup.md)
  • [x] B1 — one authoritative cutover runbook chosen; docs/README.md updated; rollback unified (done 2026-08-10; parallel-run model authoritative, 2026-07-14 runbook scoped to PMPro deactivation)
  • [x] SEC-1 / SEC-2 / SEC-4 — production config locked: SEED_DEV_DATA=false, strong unique JWT_SECRET, ALLOW_UNVERIFIED_WEBHOOKS=false, all enforced at startup (enforcement done 2026-08-10 — APP_ENV=production refuses to boot otherwise; since 2026-10-02 deploy.sh refuses to deploy without it, and an unknown webhook account path is a 404.)(SEC-3 removed from this gate — the keys in question are restricted read-only and local-only; see its entry. Production secret injection is covered by the lockdown above.)
  • [x] MIG-6 — .env.example corrected; empty/mismatched APP_ENCRYPTION_KEY fails startup loudly (done 2026-08-10; APP_ENCRYPTION_KEY was missing from .env.example entirely)
  • [x] MIG-1 — decision made on the 172 Community posts; rows edited if access is retained (decided 2026-08-26: accept the lockout — no rows edited, migration unchanged)
  • [x] MIG-5 — category-gated posts converted to per-post caps (no silent public leak) (options + recommendation in DECISIONS.md §4, 2026-08-26; closed 2026-10-15: the sync flattens category rules itself and the deactivation check refuses an uncovered post — Thomas still runs the sync on the live site)
  • [x] MIG-9 — Stripe account attribution of imported paid subs completed and verified (closed 2026-10-01: the per-account importer binds the real account, the shell toolkit is rehearsal-only — B1)
  • [x] SEC-13 — production refuses to start with the compose default admin password (added 2026-10-01; closed 2026-10-02 — a default or short ADMIN_PASSWORD refuses to boot, and an account still on one is held to Your account)

Cutover-day functional gaps — resolve or accept before disabling PMPro:

  • [x] MIG-2 — member receipt/cancellation/expiry email story decided and in place (DECISIONS §2 = B, built 2026-09-29; switch the emails on after the end-date check, Stripe settings at the go-live session)

  • [x] MIG-3 — donations, gift memberships and discount codes built and live-fire verified (done 2026-08-13 — /donate, /gift and the discount catalog shipped 2026-08-10 and were verified against real TH+SG sandbox accounts; residual membership-manager role / subscription delays tracked in the register at Low)

  • [x] MIG-4 — bulk Mailchimp push added to the runbook; drift plan agreed (DECISIONS #3 = C, built 2026-10-02: the arming switch, the Phase C clean-up step, and the Mailchimp → FMS reverse sweep)

  • [x] MIG-7 — wp_user_conflict remediation path (UI or documented manual procedure) exists (closed 2026-10-02: the member page's Website account card, and the import pre-scan)

  • [x] MIG-8 — external-integration health/alerting live (or accepted with the daily tripwire as the interim control) (DECISIONS.md #32 = A, built 2026-10-02)

  • [x] MIG-12 — an outage policy that does not lock every paying member out once PMPro is off (added 2026-10-01; Business owner, DECISIONS.md #20) (Built 2026-10-27: #20 C, 24 h, Member Gate 0.57.0, policy Keep each member's last answer; a dead pmpro setting behaves the same once PMPro is off. The rig check is owed before the morning.)

  • [ ] MIG-13 — members told about the cutover, before and after (added 2026-10-01; Marketing, DECISIONS.md #19)

  • [ ] MIG-16 — the cutover's Mailchimp work measured and the day sized; DB_MAX_CONNECTIONS=30 and nginx real IP (an active sandbox no longer matters: M4 closed 2026-10-24) (added 2026-10-12; code landed the same day; Technical, runbook Phase P step 5)

  • [ ] MIG-17 — PMPro's cron and Mailchimp add-on acting on handed-over members during the soak (the days between the all-at-once hand-over and PMPro off): the soak kept short, the plugin's PMPro changed N handed-over members list read each morning and before PMPro is off, the Phase C clean-up run after (added 2026-10-15, re-cut 2026-10-27; Technical, Business owner accepts the residual and sets the soak's length, DECISIONS.md #52)

  • [x] MIG-18 — outstanding PMPro gift and discount codes counted on the live site and either honoured by hand or exported (added 2026-10-15; Business owner) (Closed 2026-10-27: none exist, the owner's statement; one that turns up is granted by hand.)

Billing hazards — one explicit decision each (fix now vs. accept-and-monitor):

  • [x] FIN-1 (price edits never reach Stripe) · FIN-2 (discarded cancels) · FIN-3 (past_due split) — the three most likely to move money wrongly or strip a paying member. (2026-10-02: FIN-2 closed — the durable cancel is tested end to end and a member billed on two accounts is reported. FIN-3 still waits on DECISIONS #21.)(2026-10-27: FIN-3 decided — #21 B, up to 30 days of access while Stripe retries; dunning grace on the Stripe renewal webhook is code owed before the morning, then FIN-3 is ticked.)(Closed 2026-10-27: dunning grace built the same day; all three are closed. FIN-3 ships with the FMS deploy before the morning.)(2026-08-25: FIN-1 closed — price edits refused once mapped, drift shown in Consolidation; FIN-2 code-complete, integration test owed; FIN-3 still open and still needs its decision: DECISIONS.md #21.)

6. Unfinished-work inventory (roadmap input) ​

In flight (uncommitted working tree): as of 2026-09-02, the whole 2026-09-02 session — migrations 0034_member_notes / 0035_payment_settlements, the settlements ledger + Reports tab, member notes, team grants/rename, four new test suites (38 modified + 17 new files) — awaits its commit; commit it before anything else starts. (Previously: nothing, as of 2026-08-26 — c7b6df3 committed 2026-08-25.) The "gift newsletters" feature (FIN-12) landed as 391534a (2026-08-10, spec 06 FR-7a) and the migration renumber as d4be250; the stash-loses-the-boot-fix hazard is gone. FIN-12's surviving sub-risks (unindexed consolidation hot path, shared-inbox opt-out compliance, redeemed-token retention) remain open in the register.

Unbuilt [MVP] scope: Spec 05 churn/acquisition statistics (MIG-11 — revenue built 2026-09-02); Spec 08 external-integration health, webhook reachability, and flap/debounce (MIG-8). (The scheduled consolidation report is now documented — MIG-10 closed 2026-08-25 — but still a per-deployment opt-in.)

Deactivated-with-no-replacement (MIG-3): none since 2026-10-24 — the membership-manager role (2026-10-10) and subscription delays (a plan's First payment after, 2026-10-24) have FMS equivalents. (Donations, gift levels and group discount codes shipped 2026-08-10 and were live-fire verified 2026-08-13.)

Reconciliation still missing (FIN-10): amount/currency, refunds, disputes, invoice-total cross-checks, Mailchimp→FMS reverse sync.

Test coverage gap (FIN-7): closed 2026-10-02 — tests/stripe_requests.rs over the in-process Stripe mock covers every money-moving request body. Previously: no Stripe HTTP mock — seat amend, tier amend, cancel, and portal sessions still had zero automated coverage (a with_base_url seam exists since 2026-08-12). The 2026-08-13 live-fire session exercised every write path except portal sessions; 42678fe (one-off PaymentIntents rejected by Stripe, invisible to the suites) is the standing argument for the R3 mock. Added 2026-08-25: the durable EnsureStripeCancel path (FIN-2) has no integration test. Tested 2026-10-02 (dispatcher) and 2026-10-15 (all four enqueue sites), tests/ensure_cancel.rs.

Process (MIG-13): no CI, no migration-duplicate guard, no DNS/TLS or member-communication step in the runbook.

Open questions (MIG-14): ~40 across the specs, notably admin-auth definition (05-Q5), currency/FX (02/01), the AMEX-failure baseline and existing-subscriber count (PRD Q9/Q10), and the Stripe Customer Portal feature set (06-Q1 — mis-configuring it re-introduces dunning).

Internal staff guide. Screenshots come from the rehearsal rig and never show real members. Project status (for developers and admins).