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=productionnow refuses to start a deployment carrying dev seeds, a placeholderJWT_SECRET, unsigned webhooks, a missingAPP_ENCRYPTION_KEYor an insecure cookie; and.env.exampleis corrected. One operator action remains on those gates — setAPP_ENV=productionper 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 durableEnsureStripeCanceloutbox 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.
- The code at
HEADdoes not boot. Two migrations share version number0025, and PostgreSQL rejects the second, aborting startup. The fix (renumber to0026) exists only as an uncommitted working-tree change; it is clean for fresh databases and for databases that aborted atHEAD, but any database migrated from the pre-rename working tree (in practice, local dev) needs its_sqlx_migrationsrow reconciled by hand. This was found independently by two of the three audits. Until the fix is committed, no deploy ofHEADcan start. - 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.
| Likelihood | Impact (tuned for a payments system) | |
|---|---|---|
| 5 | Almost certain / already occurring | Severe: unbounded wrong-money movement, indefinite unbilled access, or PII breach |
| 4 | Likely under normal operation | Major: money moved wrongly in a bounded case, mass access loss, or key data-integrity drift |
| 3 | Possible; needs a specific but ordinary sequence | Moderate: reversible access/CRM drift, support burden, no money lost |
| 2 | Unlikely; needs an uncommon combination | Minor: narrow, self-correcting, or cosmetic |
| 1 | Rare | Negligible |
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
| ID | Risk | L | I | Sev | Status | Owner |
|---|---|---|---|---|---|---|
| B0 | Duplicate migration version 0025 → app will not start | 5 | 5 | Critical | Closed 2026-08-10 | Technical |
| B1 | Two conflicting cutover procedures; doc map points at the lossy one | 3 | 5 | Critical | Closed 2026-08-10 | Technical |
Both Tier 0 blockers are closed. B0: the renumber to
0026is 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 inmigration-runbooks/sqlx-0025-fixup.md. B1:migration-runbooks/README.mdis now declared authoritative,docs/README.mdpoints at it, and the 2026-07-14 runbook carries a superseded notice scoping it to PMPro deactivation plus the rehearsal record. The02_subscriptions.shcomp-expiry defect that made it lossy is fixed — PMPro'senddateis imported intocurrent_period_endand 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 old0025_newsletter_delivery, hand-write a_sqlx_migrationsfixup (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)
| ID | Risk | L | I | Sev | Status | Owner |
|---|---|---|---|---|---|---|
| FIN-1 | Admin plan-price edits never reach Stripe | 3 | 5 | Critical | Closed 2026-08-25 | Technical |
| FIN-2 | Discarded Stripe cancel_subscription results → double-bill / charge-without-access | 3 | 5 | Critical | Closed 2026-10-02 | Technical |
| FIN-3 | past_due access-set split demotes paying members; CRM paths disagree | 4 | 3 | High | Closed 2026-10-27 (#21 B, dunning grace built) | Business owner · also Finance |
| FIN-4 | Dead-letter queue is one-way and alert-masked | 3 | 4 | High | Closed 2026-10-02 | Technical |
| FIN-5 | Stable Stripe idempotency keys on checkout | 3 | 4 | High | Closed 2026-08-26 | Technical |
| FIN-6 | hold_on_backup = unbounded manual-reconcile hold, unbilled access | 2 | 5 | High | Closed 2026-10-02 (link instead of hold) | Technical · also Finance |
| FIN-7 | No Stripe HTTP test seam → every money-moving write uncovered | 3 | 4 | High | Closed 2026-10-02 | Technical |
| FIN-8 | invoice.payment_failed on an untracked sub dropped, no reroute | 3 | 4 | High | Closed 2026-10-02 | Technical |
| FIN-9 | Unhandled webhook families permanently unrecoverable | 3 | 4 | High | Closed 2026-10-02 | Technical |
| FIN-10 | Reconciliation blind spots (amount, refunds, disputes, MC reverse) | 3 | 4 | High | Closed 2026-10-02 | Technical · also Finance |
| FIN-11 | Grace/cascade edge cases → indefinite team access / stranded member | 2 | 5 | High | Open | Technical |
| FIN-12 | Gift newsletters (committed 2026-08-10): CRM N+1 at scale, token retention, opt-out asymmetry | 3 | 3 | Medium | Partial 2026-10-01 — only the shared-inbox opt-out is left (DECISIONS #23) | Legal / compliance · also Marketing |
| FIN-13 | Renewal-time SCA: invoice.payment_action_required unregistered → SCA-blocked renewal looks like a plain decline | 3 | 3 | Medium | Closed 2026-08-15 (purchase-time Closed 2026-08-13) | Technical |
| FIN-14 | An Opn (Omise) charge is made but FMS never hears the answer: a timed-out POST /charges may have charged | 2 | 4 | Medium | Partial 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-15 | A 3-D-Secure-mandatory Omise account cannot renew a saved card unattended: every Opn renewal waits for the member | 1 | 4 | Low | Resolved 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-16 | Opn 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 approval | 2 | 3 | Medium | Open 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_fullre-runensure_price(create a new Stripe price and repointplan_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_editablerefuses 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.mdPart 2): an individual plan's price edit saves and, in the same transaction, queues onePlanPricePropagateoutbox 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 aplan_price_aliasesrow at its old amount, repointsplan_prices, drops the plan'sforeverdiscount prices on that account and queues oneArchiveStripePriceper 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_priceandensure_discount_pricekeys 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 amode='live'account behindALLOW_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.rsasserts 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 durableEnsureStripeCanceloutbox 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 drivesensure_stripe_cancel; the cross-account two-live-subs detector is not built. - Test 2026-10-02:
tests/ensure_cancel.rsdrives the durable order throughRerouteDispatcher::dispatchagainst the in-process Stripe mock (tests/common/stripe_mock.rs, over the newStripeRegistry::with_base_urlseam — 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.rsnow covers all four enqueue sites through their real entry points against the mock (oncefails a singleDELETE /v1/subscriptions/{id}with a 500): the structural reroute (reroute_structural), the backup-recreate unwind (reroute_unwind), the checkout decline cleanup viaorchestrate_stripe_subscription(checkout_sync_failure) andfinalize_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 pendingEnsureStripeCancelrow with the right account, subscription, invoice andsource, 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 withrequeue_deadafter arming voids and cancels on the live key. No product bug. Noted:AppError::BadRequestis not permanent, so a malformed order (impossible from the typedensure_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 isdouble_billed_in_stripewhen 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_membersheads 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 rulereconciliation::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_dueconfers 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 andnow + GRACE_PERIOD_DAYS, the shape the cutover import and Opn renewals already use); a successful retry returns it toactive, Stripe's final failure ends it. Grace rows are live inaccess_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_dueon abilling_source = striperow with no live SCA hold tograce/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) andnow + GRACE_PERIOD_DAYS; a successful retry returns it toactive, Stripe's cancel orunpaidends 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 storepast_duethe same way. Grace rows are in the access set, so the delta and full CRM paths agree. Suiteswebhooks,grace,reroute,migration_segments,consolidationand 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 resetsattemptsand keepslast_error.tests/reconciliation_outbox.rs. Remaining: thedead > 0alert 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, migration0066;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 turnsoutbox_queuedown (red), Mailchimp- only dead lettersdegraded; 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 notcriticalin the sense of the public/health/status503: one refused address must not page every uptime monitor; the alert email plusHEALTH_ALERT_WEBHOOK_URLcarry it (the webhook is the out-of-band path if the email dispatcher is the thing dying).max_attemptsis 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 inservices::health. - Code review 2026-10-03: still Closed. Dead
AdminEmailevents 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
AdminEmailevents 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_emailwatches only the member queue) and the webhook is the one channel that still works.system_alerts.levelis 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. Newtests/reconciliation_outbox.rs::the_alert_webhook_fires_on_every_re_alertprovesHEALTH_ALERT_WEBHOOK_URLis 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_alertnow 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
PaymentFailedRerouteis 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 wasactiveagain, 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 rowstale_invoice_paid(classificationNULL, 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 theunpaidrescue is due. The payload now carriesstripe_event_id/stripe_event_createdso a dead letter shows its age. The other two event families were already order-safe: subscription status through thelast_stripe_event_atwatermark, 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_keyfolds everything that shapes the request body — price id, payment-method id, quantity, billing-cycle anchor — into the key as a hashed suffix on the stableorch-{user}-{account}-sub-{plan}prefix. An identical retry still replays (the 3DS park path depends on it); a different card, seat count,foreverprice or gift anchor is a fresh request instead of a 24 hidempotency_errorlock-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 backincompleteand was canceled replays asincompleteagain and goes back throughhandle_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_eventrepoints 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 rewordedAdminEmail("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, withbackup_has_other_subscriptionin the outreach email.hold_on_backupandrecord_hold_with_eventare 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;mainnever sets it) points every per-account client attests/common/stripe_mock.rs, an in-process Stripe that records method, path, query, bearer,Idempotency-Keyand the decoded form (duplicates kept), answers scripts (per path, per account key, one-shot) before Stripe-shaped defaults.tests/stripe_requests.rsasserts 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_priceonce 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_incompleteonly 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 witherror_if_incomplete,proration_behavior=none, no free-cycle anchor), and the one-off PaymentIntents for donate and gift (automatic_payment_methods[enabled]=falsewithpayment_method_types[]=card— the42678feregression —confirm,use_stripe_sdk, no customer for a guest; a 3DS donation parked, not paid). The same mock now also carriestests/ensure_cancel.rs,tests/reroute_link.rs,tests/reconciliation_money.rsandtests/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_failednow queues, in the webhook's transaction, the adoption (source: invoice.payment_failed; idempotent beside the siblingsubscription.updated's) and a systemPaymentFailedRerouteflaggedafter_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.refundedqueuesSyncRefundsfor 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(migration0067), written in the same transaction as its mark;listenedsays whether FMS subscribed to the type then. KeptWEBHOOK_RAW_RETENTION_DAYS(90), pruned byservices::retentionon both pools; size on theoutbox_queuehealth 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_eventsruns each through the liveapply_eventin its own transaction, stampsreplayed_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_requiredopens an SCA access window only while the stored invoice is stillaction_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_failedqueues a freshPaymentFailedReroute; 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, anerrorrow); 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 throughStripeClient::list_created_since_page, againstmember_invoicesand thepayment_settlementsledger —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 (queuesSyncRefunds), mark disputed / settled (re-reads the charge's disputes from Stripe first). Pure comparators unit-tested;tests/reconciliation_money.rsagainst 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_addressempty); Strip all re-sweeps and queues the ordinary update-onlyAddressTagResync, whose desired state for such an address is every tag off. Gated byconnections::mailchimp_fix_refusal(MIG-4).tests/reconciliation_mailchimp_reverse.rsagainst the rehearsal stub. - Both new checks are in the consolidated report, the digest and the CSV/HTML export.
- Incomplete runs are not clean.
- 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
completeanderror; 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, migration0027; 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_dueby Stripe, which grants nothing. A boundedsubscriptions.sca_hold_until(default 14 days, migration0033) 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_reasonon 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_oncecommits anopn_charge_attemptsrow (requested) before the request, puts its id in the charge metadata (fms_attempt, withfms_kind,fms_ref), and never retries the write; the ledger'sUNIQUE (kind, ref_id, period_end, attempt)refuses a second request for the same try, and the renewal sweep takes rowsFOR UPDATE SKIP LOCKED(tests/opn_renewals.rsrunning_two_sweeps_at_once_charges_once). EveryOPN_EVENT_POLL_SECONDS(900)services::opn_reconcilereadsGET /eventsfrom a per-account cursor and, for an attempt stillrequestedafter an hour, searchesGET /chargesbymetadata.fms_attempt: found → settled through the oneopn_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 /chargesdoes have duplicate-charge protection —ref_idin the body; the sameref_idreturns the existing charge with its current status.charge_oncenow sends the attempt row's id asref_id(tests/opn_donations.rsthe_same_ref_id_sent_again_answers_the_first_chargeagainst 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 /chargeswindow, 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 (thenpaid_without_membership). The reconcile still marks an unfound attemptlostrather than re-sending it with itsref_id: that switch waits for the live-fire scenarioopn_ref_id_dedupeto 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 /chargeswithreturn_uriis challenged, without it is not. FMS's renewals send noreturn_uri(opn_renewals, pinned bytests/opn_renewals.rs, which asserts noreturn_urion the renewal), so they are not challenged; first charges from the card form send one and are. The live-fire scenarioopn_renewal_no_3dschecks 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_urianyway is still held for the member (member_invoicesataction_requiredwith theauthorize_uri,hold_for_sca14 days, the Confirm payment banner and therenewal_scaalert;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 anauthorize_uriworks 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, migration0087) 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)
| ID | Risk | L | I | Sev | Status | Owner |
|---|---|---|---|---|---|---|
| SEC-1 | SEED_DEV_DATA=true → unauthenticated JWT minting for any email | 4 | 5 | Critical | Closed 2026-10-02 (deploy.sh refuses without APP_ENV=production) | Technical |
| SEC-2 | Admin cookie key = SHA-512(JWT_SECRET); JWT_SECRET=dev is the compose default | 4 | 5 | Critical | Fixed 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-3 | Restricted read-only live keys + APP_ENCRYPTION_KEY in a local .env | 2 | 2 | Low | Accepted 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-4 | No startup guard on ALLOW_UNVERIFIED_WEBHOOKS; unknown-account → global secret | 3 | 5 | Critical | Closed 2026-10-02 | Technical |
| SEC-5 | Webhook apply not scoped to path account → cross-account writes | 2 | 5 | High | Closed 2026-08-26 | Technical |
| SEC-6 | Mailchimp/CRM not environment-gated; mailchimp/wipe ungated | 3 | 4 | High | Closed 2026-08-26 | Technical |
| SEC-7 | POST /checkout free path: unauthenticated record creation + CRM injection | 4 | 3 | High | Closed 2026-10-02 (DECISIONS #10 B = 1) | Technical · also Marketing |
| SEC-8 | Admin login: no rate-limit/lockout · COOKIE_SECURE off · no session revocation · no CSRF · raw DB errors in 500s · no MFA | 3 | 3 | Medium | Closed 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-9 | PII exports + .wpress in tree; WP secrets plaintext in wp_options | 3 | 4 | High | Partial 2026-10-02 (service key wp-config only; the data copies remain) | Technical |
| SEC-10 | No data-deletion capability; unencrypted DB export; PCI SAQ A-EP scope | 3 | 3 | Medium | Open | Legal / compliance · also Technical |
| SEC-11 | Archive paywall bypass via percent-encoded paths; malformed escapes crash the server | 5 | 4 | Critical | Closed 2026-09-22 | Technical |
| SEC-12 | Archive hand-off puts a replayable full member JWT in the URL | 3 | 4 | High | Closed 2026-09-22 | Technical |
| SEC-13 | Compose's default admin password is not a production startup violation | 3 | 4 | High | Closed 2026-10-02 | Technical |
| SEC-14 | FMS has no security-headers layer or checkout CSP | 3 | 3 | Medium | Partial 2026-10-02 (headers done; checkout script CSP in November) | Technical |
| SEC-15 | Unthrottled donate/gift submits; cleartext SMTP credentials and HTTP R2 endpoints allowed | 3 | 3 | Medium | Closed 2026-10-02 | Technical |
| SEC-16 | Plugin contact-mail throttle, uninstall cleanup, portal URL validation and migration-export storage gaps | 3 | 3 | Medium | Open | Technical |
| SEC-17 | Claude reads FMS: a colleague can be tricked into approving someone else's Claude, and what Claude reads leaves FMS | 2 | 4 | High | Accepted 2026-10-03 with controls (by Thomas; the Business owner signs by confirming DECISIONS #43) | Business owner · also Legal / compliance, Technical |
| SEC-18 | GET /admin/api/environments returned each environment's Postgres connection string with its password to every console role | 3 | 4 | High | Closed 2026-10-03 | Technical |
| SEC-19 | The Omise secret keys and webhook secrets: a full-power key (Omise has no restricted key), a base64 webhook secret, a manual roll | 2 | 4 | Medium | Partial 2026-10-13 (encrypted, verified, braked; Omise IP range and key roll drill open) | Technical |
| SEC-20 | A 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 it | 3 | 4 | High | Closed 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_devin any production/non-sandbox environment viarefuse_in_production, and add a startup guard that hard-fails ifSEED_DEV_DATA=truein production. Set the compose default tofalse. - Update 2026-08-10 (Partial): the startup guard exists —
APP_ENV=productionrefuses to boot withSEED_DEV_DATA=true. Residual: the guard only fires if the operator setsAPP_ENV=productionon each deployment (manual step, see §5). - Closed 2026-10-02: the manual step is now enforced —
deploy.shrefuses to deploy unless.envsetsAPP_ENV=production(a deliberate staging host runs withALLOW_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=productiona placeholderJWT_SECRETrefuses to boot. Residual: the manualAPP_ENV=productionstep per deployment; the separate-cookie-secret and opaque-payload items remain. - 2026-10-02: the
APP_ENVstep is enforced bydeploy.sh(see SEC-1). Still Partial: the cookie key is still derived fromJWT_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_SECRETgives 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 toJWT_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_SECRETcould 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) afterADMIN_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.
.envis.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 600on 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=truein production; make an unknown account id a 4xx, not a global-secret fallback. - Update 2026-08-10/13 (Partial):
APP_ENV=productionnow refuses unsigned webhooks at startup (residual: the manualAPP_ENV=productionstep per deployment, and the unknown-account global-secret fallback). Related improvement 2026-08-13 (0ff4b28): connect now stores the realacct_…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
404before any secret is read — nothing written, no marker (accounts::find_optional,src/api/handlers/webhooks.rs); the globalSTRIPE_WEBHOOK_SECRETnow applies only to a known account with no stored secret.tests/webhooks.rs(unknown_path_account_is_refused_with_an_empty_404). TheAPP_ENVstep is enforced bydeploy.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_stripetakesaccount_id: Option<Uuid>and addsAND stripe_account_id = $6when pinned — the webhook pins the path account, the importer pins the account it is reading, operator reconciliation passesNone. Andapply_eventresolves 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 guardscapture_payment_failed(no reroute for a foreign sub),capture_action_required(no SCA access hold) andresolve_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 awarn!naming what to connect (a rig must not dead-letter its tag pipeline); the wipe tool now callsrefuse_in_productionand refuses an inherited audience in a sandbox; andenvironments::createstripsmailchimp_configfrom every new clone. Reads are not gated — Consolidation still diffs against whatever is connected.tests/crm_gate.rs(5 tests, via a newMailchimpClient::with_base_urlseam) + 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 onPOST /checkout/discountonly (token bucket, burst 20 / 10 per min, 10k-IP LRU, fails open; requiresTRUST_PROXY=truebehind a reverse proxy or the whole site shares one bucket). The$0branch ofPOST /checkoutitself remains unthrottled, so this finding's core is still live.Re-verified 2026-09-22:
src/checkout.rs:398–402still 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 /checkoutwith a $0 plan and no member session creates nothing: with a website configured it redirects to WordPress's start link, without one it answers403with 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). TheGETgate 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=truein 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_messagemapsConfig/Database/Migrate/Internalto an opaque"internal server error"body (still logged in full); validation/auth/Stripe messages are preserved on purpose. Unit-tested insrc/error.rs.COOKIE_SECURE=trueis a required production setting enforced by theAPP_ENV=productionstartup 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-Afterwith the wait in words; a refused attempt is still anadmin.loginactivity 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_requiredelsewhere); 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 withAPP_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-resetclears 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. Migration0080;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(migration0094) ends every session issued before it (401session_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, andstaff-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/apirequest whoseSec-Fetch-Siteis notsame-origin, or (older browsers) whoseOriginis neitherPUBLIC_BASE_URLnor the Host (403cross_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.phpconstant 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
.wpressremains 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_KEYinwp-config.phpthe 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
/checkoutXSS/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_eventsholds member emails, member IP addresses and browser user agents, and admin emails, in the boot database. It is keptACTIVITY_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 (bytarget_user_id,target_emailandactor_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_RECIPIENTis 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/andc/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) withpurpose=handoff,scope=archive, a default 120-second TTL and atomicconsumed_handoff_tokensredemption.POST /v1/auth/handoff/redeemreturns only email/tier/caps, is rate-limited, and refuses other token classes/scopes./auth/sessionrefuses 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_violationsrefuses a setADMIN_PASSWORDthat is a known default (config::KNOWN_ADMIN_PASSWORDS,seedpass123first) 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.ymlno longer suppliesseedpass123(the rehearsal compose still does, in development). Unit tests insrc/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 atstaff-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 403default_passwordbefore 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, orstaff-reseton 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; itsmust_change_passwordmark only holds a session opened before the refusal. Boot matches the exact lowercase literals only (a storedSeedPass123is 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 accountmust_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" andpassword_problemrefuses 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 (403default_password, now naming both ways out: an owner's temporary password on Staff, orstaff-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 insrc/cardform.rs,src/pricing.rs,src/checkout.rsandsrc/donate.rsneed 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: nosniffandReferrer-Policy: strict-origin-when-cross-origin, plus HSTS whenCOOKIE_SECURE=true(src/security_headers.rs, mounted inside the request span inapi::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 /donateandPOST /giftare throttled per address (CARD_SUBMIT_LIMIT, burst 10, 5/min) and their/finalizepost-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 withRetry-After(cardform::throttle_post). SMTPsecurity = nonewith 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 behttps://underAPP_ENV=production(r2::endpoint_for).tests/rate_limit.rs,tests/email_settings.rs,src/r2.rsunit 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 usehttp://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:
- 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
/mcpat all, which narrows this to someone who has the shared token. - 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.
- 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.
- 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
issparameter on the redirect. If Claude's own Disconnect does not sendclient_id, it no longer ends the FMS connection; people disconnect on Your account.
- 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
- 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 coversfms.frontiermyanmar.net/mcpwith a Service Auth policy (tokenFMS-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::checkis 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 wholesk_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 setdefault_transaction_read_only = onand a 15 s statement timeout. In production that pool is its own Postgres login,fms_ai_reader(AI_DATABASE_URL, created byops/ai-reader.sql):SELECTon the columns the tools read, column by column,teams,team_membersandplansincluded (planswithoutmailchimp_tagsandis_seed). On the money tables:payment_settlementswithout method, Stripe ids,fee_details,recorded_by;member_invoiceswithout invoice links and description;gift_membershipswithout claim tokens, message and payment id;donationswithout payment ids;subscriptionswithout Stripe ids andexternal_method;discount_redemptionswithout the code;discount_codesunreadable. The Stripe invoice id is not readable; the generatedmember_invoices.has_stripe_invoicesays only whether there is one. Also column grants onusers(no vendor or website ids) andstripe_accounts(never the encrypted keys), no write privilege anywhere, noCREATE, noTEMPORARY(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), andai::reader::verifyre-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_COLUMNSnames 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 andREAD_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 requiresclient_idon 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_eventsrow on channelai_assistantunder 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 isai.token_replayed, a disconnect sent from Claude isai.disconnect). Tool-call rows carry no client IP (the socket is Anthropic's) and record the request'sUser-Agentheader (the client's own claim is indetails.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, elseADMIN_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 anai.sign_inrow (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. Theai.sign_inrow recordsnew_connection. - Visible and cuttable. Connections are listed on Your account (Disconnect) and on Staff (Revoke all, owners).
AI_ASSISTANT_ENABLED=falseplus 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/revokeends a connection only for a token that still works, presented with its own app'sclient_id.
- A Cloudflare Access service token on
- 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 insrc/ai/. - Code review, 2026-10-03 (
docs/progress/2026-10-03.mdG): the payer filter onrecent_paymentsis 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_membersanswers "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_URLis parsed, not prefix-matched;redirect_urifollows RFC 6749 §4.1.3. No residual risk above changed. - Second code review, 2026-10-03 (
docs/progress/2026-10-03.mdH): 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_idrequired 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 andfind_members;find_membersuses 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.mdI): 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_codesnot 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-runops/ai-reader.sqlon deploy);recent_paymentshas 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 theREAD_COLUMNSallow-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/revokeneeds the app's ownclient_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; unfilteredrecent_paymentscapped at 25 rows. Residual risk 4 above is new. Migration0072ends 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.comwas 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,TRIGGERandREFERENCESon a table, andSELECTon a sequence.ops/ai-reader.sqlrevokesCREATEon schemapublicfrom 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.
- 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
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=andsslpassword=query parameters, not onlyuser:password@. The console's own code declares the field and does not use it. Unit testlisted_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_PASSWORDin.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 aliveOpn account is refused unlessALLOW_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 insideWEBHOOK_TOLERANCE_SECONDS, refuses an unknown or Stripe account id with an empty 404 before reading a secret, and refuses everything without a secret unlessALLOW_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 twinPOST /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_webhookrefuses); 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)
| ID | Risk | L | I | Sev | Status | Owner |
|---|---|---|---|---|---|---|
| MIG-1 | 172 Community posts → members-only: open decision on the critical path | 4 | 3 | High | Accepted 2026-08-26 (by Thomas; the Business owner signs by confirming DECISIONS #1) | Business owner |
| MIG-2 | No member-facing email (receipts, cancellation, expiry) | 2 | 2 | Low | Closed 2026-09-29 (decision §2 = B, built; Stripe emails confirmed at the go-live session) | Marketing |
| MIG-3 | Residual PMPro add-ons without FMS equivalent (membership-manager role, subscription delays) — donations/gifts/discounts Closed 2026-08-13 | 2 | 2 | Low | Fixed 2026-10-24 (role 2026-10-10; delayed starts a plan setting, DECISIONS #33 B; live-site check pending) | Business owner · also Technical |
| MIG-4 | Mailchimp unmaintained at cutover (no bulk push, no reverse sweep) | 4 | 4 | High | Closed 2026-10-02 (DECISIONS #3 = C) | Technical · also Marketing |
| MIG-5 | PMPro category-level restrictions not synced → posts become public | 3 | 4 | High | Closed 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 silently | 3 | 4 | High | Closed 2026-08-10 (re-synced 2026-10-01) | Technical |
| MIG-7 | wp_user_conflict locks a member out permanently, no remediation UI | 3 | 4 | High | Closed 2026-10-02 | Technical |
| MIG-8 | Spec 08 [MVP] integration health not built (no liveness/reachability) | 3 | 4 | High | Closed 2026-10-02 (DECISIONS #32 = A) | Technical |
| MIG-9 | Stripe account attribution of ~879 imported subs is a manual step | 3 | 4 | High | Closed 2026-10-01 (the per-account importer binds the real account, B1) | Technical |
| MIG-10 | Scheduled consolidation report off by default, undocumented | 3 | 3 | Medium | Closed 2026-08-25 | Technical |
| MIG-11 | Spec 05 [MVP] statistics (churn/acquisition/gross volume) not built | 5 | 2 | Medium | Closed 2026-09-02 — revenue, churn and acquisition built | Finance |
| MIG-12 | Rollback lossless only pre-writes (per segment since 2026-10-15: Hand back); master-switch asymmetry; outage-policy dead | 3 | 3 | Medium | Closed 2026-10-27 (outage half built, Member Gate 0.57.0, DECISIONS #20 C; rig check owed) | Business owner · also Technical |
| MIG-13 | No CI; no DNS/TLS step; no member-comms plan; transient-backed tokens | 3 | 3 | Medium | Partial 2026-10-01 | Marketing · also Technical |
| MIG-14 | Doc/version drift; dead BYPASS_WP_AUTH_GATE env var; no history migration; stale roadmap; ~40 open questions | 4 | 2 | Medium | Closed 2026-08-14 | Technical |
| MIG-15 | Live-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 differ | 4 | 4 | High | Closed 2026-09-23 | Technical |
| MIG-16 | Migration 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 discards | 4 | 4 | High | Partial 2026-10-12 (code landed; measurement and three server settings at cutover) | Technical |
| MIG-17 | During 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 stale | 3 | 3 | Medium | Partial 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-27 | Technical · also Business owner |
| MIG-18 | Outstanding PMPro gift codes and discount codes are not migrated and nothing counts them (audit #15) | 3 | 3 | Medium | Closed 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:communityconfers 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 storydecided 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, migration0096, spec 05 FR-13b): a new Stripe card signup is createdtrialinguntil 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 storedmode = '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 inlocal-rehearsal.mdandnew-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, mirroringpmpro_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 export06_restrictions_export.shflattens 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 byphp -land 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.examplefor production (correct architecture, safe defaults, document every required var); make an empty/mismatchedAPP_ENCRYPTION_KEYfail startup loudly. - Re-synced 2026-10-01: Closed 2026-08-10.
.env.exampledocumentsAPP_ENCRYPTION_KEYand 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_userrecords the website account that tried, on the member it would have been (wp_identity_conflicts, migration0068, 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; anAuditNotenames 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 (everyINTEGRATION_HEALTH_POLL_SECONDS, 300) probes each connected Stripe key withGET /v1/account(a read; brake-exempt) and the Mailchimp audience fetch, storing the outcome inintegration_probes(migration0069, per environment; apart from the person-verifiedstatuson 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) — andmailchimp. Alerts ride the existing transition emails +HEALTH_ALERT_WEBHOOK_URL; notcritical(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 (migration0070). 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 indocker-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_settlementsledger, 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;lockandpmproremain): 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 withinfms_mg_last_answer_hours(0–72, default 24;FMS_MG_LAST_ANSWER_HOURSwins) 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 onpmprobehaves aslast_answeronce PMPro is inactive, so the dead fallback no longer locks every member out. A readiness row warns onlockor 0 hours; the admin notice says which policy is serving. A site that never saved the setting moves fromlocktolast_answer; a storedlockstays. 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
lockand 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 andops/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, migration0081: 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 sayscomplete: falseand 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 index0082); the bulk import reportsalready_ended; the plan editor asks above ~1,000 re-syncs; the PMPLEVELID backfill is two statements; stub knobsSTUB_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=30and the nginx Cloudflareset_real_ip_from+real_ip_header CF-Connecting-IPlines. (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:lapsedmisses 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 inmigration_marks(migration0089) 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_filtermakes PMPro's access check give FMS's verdict for a handed-over member, so a dropped PMPro level locks nothing; apmpro_change_levelobserver 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 withALLOW_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_membershipswith 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
| ID | Risk | L | I | Sev | Status | Owner |
|---|---|---|---|---|---|---|
| OPS-1 | Backup/DR: pipeline built + drilled locally; real R2 target + drill pending; no backup-freshness check | 2 | 4 | Medium | Partial 2026-10-02 (a blank target now warns; real bucket + drill at deploy) | Technical |
| OPS-2 | Accidental mutating Stripe writes against live accounts from dev/sandbox tooling | 3 | 5 | Critical | Closed 2026-08-12 | Technical |
| OPS-3 | Two 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 webhook | 3 | 3 | Medium | Partial 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_backuphealth 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=productionon the production database, no console target makesoffsite_backupdegraded ("not configured — the only copies are on this server"), unless compose says the.envsidecar is the uploader (BACKUP_SIDECAR_TARGET, fromR2_BUCKET/BACKUP_REMOTE_PATH), which readsokbut unobserved. Both configured isdegraded(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.rsreports_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 everyOPN_EVENT_POLL_SECONDSand on demand atPOST /admin/api/connections/opn/{id}/reconcile(owner). Health showsopn_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 /eventspoll 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
purposeclaim. 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
0025duplicate fixed, committed, and reconciled per environment (done 2026-08-10; guard test + CI added; per-environment fixup inmigration-runbooks/sqlx-0025-fixup.md) - [x] B1 — one authoritative cutover runbook chosen;
docs/README.mdupdated; 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 uniqueJWT_SECRET,ALLOW_UNVERIFIED_WEBHOOKS=false, all enforced at startup (enforcement done 2026-08-10 —APP_ENV=productionrefuses to boot otherwise; since 2026-10-02deploy.shrefuses 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.examplecorrected; empty/mismatchedAPP_ENCRYPTION_KEYfails startup loudly (done 2026-08-10;APP_ENCRYPTION_KEYwas missing from.env.exampleentirely) - [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_PASSWORDrefuses 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,/giftand 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_conflictremediation 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
pmprosetting 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=30and 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_duesplit) — 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).