Skip to content

Changelog

626Aug 2026729112Sept 20263101111412113814102110221023102411271228829101Oct 2026252113646526410111612201371418151016119320122723152432552732912Nov 2026833778
8 November · 7 entries · the latest day
List filters: a search box you can find, date pickers, fewer Members filtersTeam invoices from the consoleConsolidation by role, with a next step on every issueClick a month on any chart to see the rows behind itMembers → Import from WordPress, and a New team buttonDashboard and Reports draw each membership chart onceEvery console list shows ten rows a page, and Consolidation folds
Read the 8 Nov entries in full
331 entries over 47 recorded days. Point at a bar, tap it, or tab to it and use the arrow keys.
DayWhat changed
8 November
7 entries
List filters: a search box you can find, date pickers, fewer Members filtersTeam invoices from the consoleConsolidation by role, with a next step on every issueClick a month on any chart to see the rows behind itMembers → Import from WordPress, and a New team buttonDashboard and Reports draw each membership chart onceEvery console list shows ten rows a page, and Consolidation folds
7 November
3 entries
The console reads for the people who run membershipsA sixth console role, Developer, and the Technical tier (DECISIONS #37 amended)The Migration tab is the one-gate Cutover page (no segments)
3 November
8 entries
Member Gate 0.59.0: a Donations switchOne gate, no soak (decision, DECISIONS #52 note 2026-11-03)Archive: the date filter applies by itselfWebsite account page: Auto-renews on <date>Archive: dates and newsletter names in the search box become filtersArchive: the way homeArchive toolbarMember Gate 0.58.0: a post's Required membership level is Every member or Only paid member
2 November
1 entry
Live-shaped sandbox test, first full run + fixes
29 October
3 entries
Live-shaped sandbox test, Stripe side (ops/stripe-sandbox-mirror.py)Live-shaped sandbox test, WordPress side (fms-test-rig)Rehearsal rig
27 October
5 entries
Member Gate 0.57.0: each member keeps their last answer through an FMS outage (DECISIONS #Dunning grace for a failed Stripe renewal (DECISIONS #21 = B; closes RISK FIN-3)Console: Hand back everyone… (MIG-12)Cutover plan: everyone moves on one morning (DECISIONS #52 note, #18 note; no code change)Owner's answers (docs only)
Full record: CHANGELOG.md

Changelog ​

One line per change, newest first, each pointing at the record that has the detail (a dated note in docs/progress/, a commit, or both). The risk-register IDs (FIN-, SEC-, MIG-, OPS-) refer to docs/RISK-ASSESSMENT.md. Add a line here with every commit that changes behaviour, schema, ops, or a decision.

2026-11-08 ​

  • List filters: a search box you can find, date pickers, fewer Members filters. Search gets its own full-width row with a caption, a taller white field and a clear button on every list (Members, Teams, Audit, Reports). Members drops the Plan filter and Billing's Free option (Tier answers both); Billing is Card (Stripe or Opn) or Bank transfer; Team reads Everyone / Team admins / Team seats / Individuals; Joined is a From/To date-picker range (joined_from / joined_to replace joined_days on GET /admin/api/members, inclusive UTC days); Served by shows only while some members are on PMPro and others on FMS. Audit's From/To and Dead letters' Received since use the same date controls; Teams' Billing gains Card; Dashboard recent-subscription statuses read in plain words. Guide updated (members-find, migration); shots stale. docs/progress/2026-11-08-list-filters.md
  • Team invoices from the console. Finance can send a team its first invoice: New team gets a fourth billing choice, Invoice — paid by bank transfer (bill-to name and address, days to pay, optional discount code; Preview invoice shows plan, amount, discount, issuing account, due date, recipient and what the team takes over, then Create and send invoice; ?method=invoice), and Team invoices → New invoice (/admin/team-invoices/new) offers the same form (shared components/teams/TeamForm.tsx) beside a seat change for an invoice-billed team (POST /admin/api/teams/{id}/seats; other teams greyed, converting them is not built). New POST /admin/api/team-invoices/preview, POST /admin/api/team-invoices (queued, verified_by + requested_by_admin = the staff account, website checkout switch not required) and POST /admin/api/team-invoices/{id}/resend (open only, once per 10 minutes, resend_count), all Finance; list rows gain requested_by_admin, resend_count, team_id, team rows collection_method; invoiced seats 3–100 (was 3–19, migration 0101). Team invoices gets search / Status / Purpose filters, Resend email, Requested by staff and Open team; Teams shows Invoice billing. Guide updated (team-invoices, teams); shots stale. docs/progress/2026-11-08-team-invoices-from-the-console.md
  • Consolidation by role, with a next step on every issue. Membership managers see only the three checks that compare FMS with Stripe and Mailchimp (FMS ↔ Stripe, FMS ↔ Mailchimp, Mailchimp → FMS); every other role keeps all twelve. SECTIONS is the one filter (jump strip, Collapse all, intro order, fix links). Every issue row carries a plain Next step with up to two links (Open member / Find in Members, Fix it in …, the guide); StripeDrift and PaymentRisk carry user_id. The Member overview no longer says Needs a price mapping on rows it does not apply to. "System ↔ Stripe/Mailchimp" are now FMS ↔ Stripe/Mailchimp in the console, printable report, digest, audit trail and guide. docs/progress/2026-11-08-consolidation-by-role.md
  • Click a month on any chart to see the rows behind it. Dashboard Gains & losses and Reports Members over time, Acquisition, Churn rate, Renewals and Net revenue open an inline panel under the chart (charts/MonthDetail.tsx) with the month's figures and a paged list: the members who joined / came back / left / held access (new read-only GET /admin/api/reports/growth/members?month=&set=, capped at 200, each linking to the member page) or the month's payments (GET /admin/api/reports/payments gains month=YYYY-MM, replacing the window), narrowed by the segment clicked. The chart kit (StackedBars, DivergingBars, AreaLine) gets selected / onSelect: months are buttons, one Tab stop per chart, arrows between months. Who they are, now segments open Members filtered; Subscriptions by status bar segments filter like the legend; Where it went sets the Payments list's Kind filter. Reports' "12 months" labels read the period. Guide updated (dashboard, reports); shots stale. docs/progress/2026-11-08-clickable-charts.md
  • Members → Import from WordPress, and a New team button. The two migration cards that sat above the member list (owners) move to their own page, /admin/members/import (owner-only, like their server routes), re-titled as two numbered steps for two different files: Members and memberships — the WordPress export and Newsletter unsubscribes — the Mailchimp audience export, each with a When you need it line; Members gets a ghost Import from WordPress button beside New member and the list sits under the header. Teams gets a header New team button (Finance and up) opening /admin/teams/new, the old bottom Create a team form rebuilt like New member (opens the new team on success; a Stripe.js that fails to load shows an error in place of the card field). The Cutover page and Rehearsal link to the new import page. Frontend only. Guide updated (members-find, members-import, newsletter opt-outs, teams, migration shots stale). docs/progress/2026-11-08-import-page-and-new-team.md
  • Dashboard and Reports draw each membership chart once. The growth block was the same on both pages and repeated itself (tile sparklines over the charts beside them, a donut that was a chart's last month, retention drawn as the mirror of churn). The Dashboard now shows four headline numbers without sparklines, a new Gains & losses chart (joined and returned above the line, left below it; charts/DivergingBars.tsx) and the current mix as one bar; Reports keeps the history (members by source, acquisition, churn rate, renewals, month-by-month table). Machinery drops the tiles other cards already show. Bar-chart axis labels now sit on their gridlines. Frontend only. Guide updated; dashboard shots stale. docs/progress/2026-11-08-dashboard-charts.md
  • Every console list shows ten rows a page, and Consolidation folds. One shared pager (frontend/src/components/Pagination.tsx: usePagination + <Pagination>, sizes 10/25/50/100 remembered per list in the browser, hidden for ten rows or fewer, # keeps counting across pages) on Members, Teams, team seats, Dashboard recent (replaces Show all), Reports' payments, subscribers and skipped invoices (print still prints every row), Donations, Gifts, discount codes and redemptions, Dead letters, Team invoices, the plan catalog, Recent member email and Audit (where Next and Load older fetch the next server page; Show older gone). Consolidation: every issue list (and the Duplicates cards) pages at ten with the client 500-row cut removed, each check folds to a one-line summary with a count badge (remembered per browser), and a sticky strip lists the twelve checks with their counts and jumps to each; bulk fixes still cover every row. No server change; the server caps and their notices stay. Guide updated (list and Consolidation shots stale). docs/progress/2026-11-08-table-pagination.md

2026-11-07 ​

  • The console reads for the people who run memberships. The Dashboard opens on Needs attention (every role): team invoices waiting, renewals waiting for the member's bank, memberships in a payment grace period, free/bank-transfer memberships ending within 14 days, and failed updates — counts from repository::attention on GET /admin/api/dashboard's new attention; Plumbing becomes Machinery (developers and owners only, tiles Updates waiting / Failed updates). Every page header has a Guide button to its staff-guide page when STAFF_GUIDE_URL is set (sent on GET /admin/api/me as staff_guide_url; map in frontend/src/lib/guide.ts). Links to technical screens on business pages are shown only to roles that can open them; window.confirm replaced by named confirmation dialogs on Team invoices, Team detail, Member detail, Discounts, Reports, Consolidation and Dead letters; Consolidation and member pages say Update Mailchimp / failed updates instead of re-sync / outbox / dead letter, and server error text folds under Details; Team invoices' Stuck is Needs a developer. Members opens pre-filtered from a link. Guide updated. docs/progress/2026-11-07-console-for-non-technical-staff.md
  • A sixth console role, Developer, and the Technical tier (DECISIONS #37 amended). Permission::Technical (owner + developer) now carries the machinery: every /connections/** route, payment routing, dead-letter and webhook replay, Testing, seeding, scenarios, rehearsal, Sandboxes (except copying a catalogue to production), Logs, service keys, Backup, archive and email settings — 96 routes moved from Owner. A Developer reads everything else like a Viewer and changes no member, money, staff account or plan. Health and its Dead letters tab are now shown only to developers and owners; Your account shows what your role can do; the menu's hard-coded Test mode line is gone. Migration 0099. Guide updated (shots of Staff, the top menu and Your account are stale). docs/progress/2026-11-07-developer-role.md
  • The Migration tab is the one-gate Cutover page (no segments). PMPro or FMS for everyone: the page says who gates the website, lists what must be green before the morning (imports, Verify, Consolidation, website checks, backups, live payments, workers, failed updates) and the morning's six steps with Hand over everyone…, and a same-morning-only Go back. Steps only a person can confirm are ticked on the page (POST /admin/api/migration/steps/{key}, audited migration.step, stored in migration_marks, migration 0100); GET /admin/api/migration is readable by every role. Segment and team hand-over/back removed (routes, API, UI); a member hand-over moves only that member. The double-billing fix now runs on a held member, with a PMPro warning in the plan; Verify lists every row (20 shown, Show all, CSV) and stamps the page's Verify clean. Guide rewritten (admin/migration.md); old migration shots stale. docs/progress/2026-11-07-one-gate-cutover-page.md

2026-11-03 ​

  • Member Gate 0.59.0: a Donations switch. FMS Membership → Donations (Show the donation feature, on by default, so nothing changes until someone unticks it). Off hides the account hub's Make a donation card and the donation card in [fms_support] (the gift card stays), and stands the old Stripe donate-link rewrite down; FMS's own /donate page is untouched. Guide updated (no shot: no WordPress here). docs/progress/2026-11-03-donations-switch.md
  • One gate, no soak (decision, DECISIONS #52 note 2026-11-03): PMPro never runs beside FMS. The hand-over and PMPro off happen in the same window, every check must be clean before the switch, and rollback lasts only that morning. Plan and docs only, no code. Docs that still describe a soak are listed for revision. docs/progress/2026-11-03-one-gate.md.
  • Archive: the date filter applies by itself. Choosing a month, or both ends of a date range, closes the panel and filters the list at once; the Apply buttons are gone (hints say so; To before From still shows the inline error; keyboard edits apply on Enter or on leaving the fields). Keyword search re-checked end to end (plain words, words with a date filter, words left after the typed-filter parse), no change needed. Guide updated; the date-filter shot is stale. docs/progress/2026-11-03-archive-date-apply.md
  • Website account page: Auto-renews on <date> replaces Renews on for a card membership that is not cancelling ([fms_account] membership card, Member Gate 0.58.0; guide and specs 06/07 updated). Commit.
  • Archive: dates and newsletter names in the search box become filters. July 2025 Political Insider newsletters sets the month and the Political Insider tab and empties the box (Enter, leaving the box, or an 800 ms pause; never per keystroke); a note under the box names what moved, with Undo. Pure parser archive/web/query-parse.js + tests; nothing recognised = plain text search as before. Guide updated. docs/progress/2026-11-03-archive-smart-search.md
  • Archive: the way home. The Frontier logo in the masthead now goes to the website's home page (siteUrl; without one it stays on the archive), and the account menu's Back to <host> became Back to Home page (house icon, host beneath), moved up to sit right under Archive manager / Membership; the title still opens the archive's front page. Guide updated; the account-menu shot is stale. docs/progress/2026-11-03-archive-home-links.md
  • Archive toolbar: the All types filter is gone (and the index's types facet with it; an old ?type= link is ignored); sort is Newest first / Oldest first / Most read (Widest reach and Longest reads removed, an old sort=sent|longest link falls back to newest); Clear filters is a red outlined button beside the sort, shown only while something is set. Guide updated; the archive list shot is stale. docs/progress/2026-11-03-archive-toolbar.md
  • Member Gate 0.58.0: a post's Required membership level is Every member or Only paid members: the Inherit the site default entry is gone (an untouched post still follows the site default: saving the level it was shown writes no rule); new plugin-derived any_member (any live membership, Community included: member or a granting status) on posts, the site default, the gate block and [fms_gate cap="any_member"]; members_only relabelled Only paid members; both member rules are meterable; old tier rules round-trip as Saved: …. Guide updated; the metabox shot is stale. docs/progress/2026-11-03-post-access-levels.md

2026-11-02 ​

  • Live-shaped sandbox test, first full run + fixes: run end to end on the rig (mirror 558 rows, 0 differ; masked restore of today's site; 60 members / 37 kinds planted; FMS found every named defect — first pass 217 found / 0 missed / 0 unexpected, 256 / 0 / 0 / 0 with ≤ 40-row planted-only parts). Restore guard false positive fixed (unescaped _ in LIKE 'pmpro_%stripe_connect_%' matched pmpro_stripe_connection_test; the filter drops it too); the dump stage refuses a stale database.sql; event pump: TH/SG label swap, >100 events per cycle, last event re-sent every cycle; catalogue + check-manifest.py (order-driven signals expected, subset --part-rows); runbook corrected throughout. Open: the importer never lists test-clock past_due (sandbox gap). docs/progress/2026-11-02-live-shaped-sandbox-run.md

2026-10-29 ​

  • Live-shaped sandbox test, Stripe side (ops/stripe-sandbox-mirror.py): copies the live subscription export into any number of Stripe sandboxes with masked identities (SPEC.md pseudonyms, same salt as the WordPress copy, hash16 of live ids for the cus_/sub_rehearsal_ join); test keys typed in only (sk_test_/rk_test_, probed); resumable state + report CSV under exports/; test-clock past_due; --verify, --prefer, --purge. Smoke-tested on the TH sandbox (anchor renewal, nothing charged; past_due reached; AMEX refused on TH) and purged. docs/migration-runbooks/new-vps-test-cutover.md §3.1b, docs/migration-runbooks/live-shaped-sandbox.md.
  • Live-shaped sandbox test, WordPress side (fms-test-rig): tools/rebind-stripe-ids.php (points PMPro's pseudonymised Stripe ids at the sandbox mirror's), tools/plant-scenarios.php (38 seeded PMPro ↔ Stripe defect kinds, each with its expected FMS class or panel, a delta pass and a content-only mode; tests/run.php) and ops/check-manifest.py (found / missed / unexpected against the member export, saved Preview/Verify JSON and the Consolidation CSV). Five detection gaps recorded in the runbook's catalogue (ghost level, deleted group owner, seat without a membership row, expired team owners, silent capacity raise). docs/migration-runbooks/live-shaped-sandbox.md
  • Rehearsal rig: the event pump names its environment (?env=, PUMP_ENV_LABEL) instead of relying on the console's switch; the rig image rebuilt at HEAD and two-step sign-in enrolled from FMS_ADMIN_TOTP_SECRET.

2026-10-27 ​

  • Member Gate 0.57.0: each member keeps their last answer through an FMS outage (DECISIONS #20 = C, 24 h; RISK MIG-12 outage half): outage policy Keep each member's last answer (last_answer, the new default; lock and pmpro remain), fms_mg_last_answer_hours 0–72 (default 24; FMS_MG_LAST_ANSWER_HOURS wins), a per-member copy served only when FMS does not answer and dropped on a change of environment, API URL or key, a readiness row (warn, never blocking), an admin notice and a member "We can't check your membership right now" card; owner's rule: a site still on pmpro behaves as last_answer once PMPro is inactive; a site that never saved the setting moves from lock to last_answer. Rig check owed; the gating-settings shot is stale. docs/progress/2026-10-27-outage-last-answer.md
  • Dunning grace for a failed Stripe renewal (DECISIONS #21 = B; closes RISK FIN-3): a renewal past_due (Stripe-billed, no live SCA hold) enters grace/dunning until the later of the period start and now + GRACE_PERIOD_DAYS (30); a paid retry returns it to active, Stripe's cancel or unpaid ends it, the sweep's local lapse is the backstop; the reroute worker still handles dunning rows; a Stripe dunning row blocks a second checkout; adoption and Consolidation's fixes do the same (import audit #18). Suites webhooks, grace, reroute, migration_segments, consolidation and 22 more green. Follow-ups: the importer's deadline still uses the period end; the console member page shows the grace pill but not its end date. docs/progress/2026-10-27-dunning-grace.md
  • Console: Hand back everyone… (MIG-12): a red card on Settings → Migration; same route, permission and audit row as the per-row hand-back; runbooks and guide reworded; stale segment wording fixed. docs/progress/2026-10-27-all-at-once-cutover.md
  • Cutover plan: everyone moves on one morning (DECISIONS #52 note, #18 note; no code change): the Business owner reversed the segment schedule. Load and verify with everyone held, then one morning (delta pass, S0, master switch on, Hand over everyone…, New members join through FMS, checks), a two-to-three-day soak with PMPro active, PMPro off; the segment, team and member tools are the rollback and straggler tools. Runbook Phase S and a T-relative The day, step by step; split host recorded as the live topology, with The archive (while PMPro is active the website opens the archive only for a handed-over member; what changes if the archive moves); roadmap table with a proposed calendar (the morning Tue 11 Nov, PMPro off Fri 14 Nov, for the owner); MIG-17 now the soak's days; staff guide migration pages. docs/progress/2026-10-27-all-at-once-cutover.md.
  • Owner's answers (docs only): RISK MIG-18 closed — no outstanding PMPro gift or discount codes exist (the count and Phase C settle steps removed; one that turns up is granted by hand); DECISIONS #21 decided B — a failed renewal keeps reading for up to 30 days while Stripe retries. Dunning grace on the Stripe renewal webhook is code owed before the morning (~1 day) and closes FIN-3; runbook T-7 row for the 30-day Stripe retry schedule and GRACE_PERIOD_DAYS=30. docs/progress/2026-10-27-all-at-once-cutover.md (Owner's answers, the same day).

2026-10-25 ​

  • Security audit fixes — backup and restore (leads 4, 5; migration 0097): a restore no longer brings back switched-off staff, old passwords and sessions, used recovery codes or revoked service keys. credential_revocations (boot DB only, left out of every dump with --exclude-table) snapshots each staff/key/app credential change in its own transaction (repository::credential_journal) and is re-applied after the console restore and at every boot (covers restore.sh), keeping the last active owner; superseded rows pruned by services::retention. The Backup page counts production (backup::status reads auth_pool) and Restore is refused while a sandbox is active (restore_dump, BackupRestore.tsx). Runbook §5, staff guide Backup and Staff pages. docs/progress/2026-10-25-security-audit-fixes.md.
  • Security audit fixes — gifts and sandboxes (leads 1, 6, 9; no migration): both registries refuse a mode='live' account in a sandbox before the live brake (EnvironmentRefused, reads included); both webhook routes 404 a live account's delivery in a sandbox before any secret is read; a clone switches copied live accounts off and blanks their webhook secrets; a sandbox holding one cannot start its workers (409 sandbox_has_live_accounts). A gift link opens a confirm page and the claim is POST /gift/redeem (form token bound to session and link), one transaction that claims the gift first (gifts::claim, a concurrent loser creates nothing) and retires only the free Community row, with a MemberTagResync (src/stripe.rs, src/opn/registry.rs, services::environments, src/workers.rs, src/gift.rs, services::gifts). Staff guide Sandboxes and gift pages. docs/progress/2026-10-25-security-audit-fixes.md.
  • Security audit fixes — checkout and payments (leads 2, 3, 7, 8, 10; migration 0098):/checkout/finalize acts only on a row marked subscriptions.checkout_parked_at and provisions it once; any other row is answered locally with no Stripe call. The discount quote never looks up a posted email. POST /checkout and /checkout/finalize get the card-submit and finalize throttles. A finalize mid-challenge keeps the discount slot, and the webhook records a parked row's redemption (redemption_for_claim no longer re-checks the cap the claim took). A booked Opn plan change drops a forever discount (ChangePreview.drops_discount). The billing page's stale-form message no longer says the page was open too long (src/checkout.rs, services::subscriptions, api::handlers::webhooks, services::{discounts,member_billing,opn_renewals}, src/account.rs). Staff guide billing, change-plan, discount-codes and troubleshooting pages. docs/progress/2026-10-25-security-audit-fixes.md.

2026-10-24 ​

  • Security audit (source review, two passes): ten open leads, none fixed yet. The highest priority: a production-cloned sandbox keeps live Stripe/Opn accounts behind only the process-wide brake; anonymous /checkout/finalize acts on live memberships; the discount quote reveals whether an email has paid; and restoring a backup brings revoked staff and service keys back. Docs only, no code change. docs/security/2026-10-security-audit.md.
  • Archive spotlight 0.4.0 — house-ad units and story-form posts (spec 10 FR-15/17): three compact units labelled From the members' archive — promo card, banner strip, rail sidebar list — sized by container queries, with member / visitor wording from the Member Gate (fms_mg_archive_available_to_visitor, no script); Settings → Placements puts them after post N of the main list and after top-level paragraph N of an article (includes/placements.php, both off by default), plus a classic widget and the block / shortcode / home-page layouts. Posts become short stories (deck, hook with drop cap, two paragraphs, fact box, quote, What to watch); the FR-15 caps now count every field (≤ 300 words, facts' figures from the issue) and each post gets an inline-SVG motif illustration (includes/illustrations.php) drawn at render time, since kses strips stored SVG. Old posts keep rendering. No new route. Staff guide wordpress/archive-spotlight; plugin readme.txt.
  • Delayed start: a plan's first payment after N days (RISK MIG-3 fixed, DECISIONS #33 B, migration 0096): First payment after (days) on an individual paid plan; a new Stripe card signup gets access at once and is created trialing until day N (trial_end); never a plan change, grant, gift, team, reroute or Opn account (routing::exclude_opn_for_delayed); checkout says Free until … and Start membership, /pricing and Member Gate show First N days free; delay_days on /v1/plans, /v1/plans/all and Claude's list_plans; customer.subscription.trial_will_end registered (logged only). docs/progress/2026-10-24-delayed-start.md.
  • Member Gate 0.56.1 (review fixes to 0.56.0): the paywalled wrapper is a <span> for inline locked text and a plain block <div> otherwise (no more display: contents; constrained block layouts forwarded to its children); Rank Math and AIOSEO carry the paywall schema like Yoast (a gated page's WebPage with Yoast too), the plugin's own JSON-LD only without any of the three, its headline as plain text; [membership level="-N"] = not on exactly level N (PMPro's rule), an unknown negated id hides the block; readiness row Subscription delays (MIG-3); pricing cards show First N days free for a delayed-start plan. Commit message.
  • Review fixes: staff sessions and backups (RISK SEC-2, SEC-8): the old JWT-derived staff cookie key is read only for one session life after ADMIN_SESSION_SECRET_SINCE (else after boot; a production warning while unset), the known-device cookie too; a cookie dated more than five minutes ahead, or living longer than one session after its issue time, is refused; a new password and its session stamp are one UPDATE and the re-issued cookie is dated after the database's stamp; switching an account off ends its sessions. ops/backup.sh writes the dump under tmp/ (trap-cleaned), uploads only fms-*.dump.gz(.age) and refuses a recipient that is not age1…; restore.sh refuses an option without its value and a passphrase-protected key, and stages a fetch as restore/latest.dump.gz(.age). Runbook §2 table, §4.1, §4.2, §5.
  • Stripe API version pinned on webhook endpoints (risk sheet NEW-03, migration 0095): with STRIPE_API_VERSION set, a new webhook endpoint is created with it as its api_version (payloads no longer follow the account's default when someone upgrades it in Stripe); the key probe records each account's default (stripe_accounts.api_version, an unpinned GET /v1/account); each Stripe card on Connections shows default vs pinned; Health gains Stripe API version (amber in production while unpinned, or when an account's default differs). Runbook §6b: pin before re-provisioning.
  • Staff sign-in hardening (RISK SEC-8 closed, SEC-2 fixed once its secret is set; migration 0094): a new password, Sign out my other sessions (Your account), an owner's temporary password or two-step reset and staff-reset end the account's other sessions (sessions_valid_after, 401 session_ended; the person's own browser is re-issued); every console write must come from the console's own pages (Sec-Fetch-Site / Origin, 403 cross_site_request); optional ADMIN_SESSION_SECRET gives the staff cookies their own key, re-issuing old-key cookies so nobody is signed out. tests/admin_sessions.rs.
  • Backups are encrypted before they leave the server (risk sheet NEW-15): with BACKUP_AGE_RECIPIENT (the owner's age public key; the private key stays offline) the console's nightly R2 upload writes ….dump.gz.age, its Download gives ….dump.age, and the backup sidecar encrypts too (age in its image); restore.sh --identity decrypts; the console restore refuses an encrypted or gzipped upload with the missing step; production refuses to start with a value that is not a public key; Health warns while copies leave plain. Pruning keeps counting the old plain copies. Runbook §4.1 step 6, §4.2, §5, §5.1 (drill rewritten; run on a dev copy, 92 = 92). src/backup_crypto.rs.
  • Member Gate 0.56.0 (risk sheet NEW-07, NEW-09): PMPro's [membership] shortcode inside articles is answered by FMS tier once PMPro is off (it used to print raw and show the hidden text), with a readiness row counting the posts that use it; locked text a reader or a verified crawler sees is wrapped in .fms-paywalled and gated articles carry Google's isAccessibleForFree: false
    • hasPart (Yoast's Article piece, else one JSON-LD block). includes/paywall.php, new php tests/run.php. Not yet seen on a live WordPress.
  • Invoice email switches (migration 0093): Settings → Email → Invoice emails switches the renewal invoice to members billed by invoice, and the payment confirmed and renews-soon emails to invoiced teams and members billed by invoice (on by default). Each shows which Stripe setting sends the same email, and saving asks staff to confirm. Payments outside Stripe are always confirmed, and every team invoice (renewals included) and the first invoice of a move to invoice billing have no switch. A new read-only Every email members and staff get card lists every email, who sends it and where it is switched. docs/progress/2026-10-24-invoice-email-switches.md.
  • Edge check before pushing to master: ops/RUNBOOK.md §1 now holds the Cloudflare skip rule as it stands (The skip rule today, origin address kept as a placeholder), what Cloudflare Access guards, its four Bypass destinations with why each is safe, and a six-step check every new route, fetch, caller or URL shape goes through, security first; CLAUDE.md working agreement points at it.
  • Archive Manage newsletters no longer blames the manager for Cloudflare: the dialog said Your account can no longer manage newsletters on any 403, so a Cloudflare browser check on /api/series read as a lost role. Now only the archive's own managers_only says that; a check gets the security-service message and a cross-origin save Reload the page and try again (archive/web/manage.js failMessage).
  • Archive behind Cloudflare's challenge: when Cloudflare challenges one of the reading page's background calls (seen today: /api/me → 403 HTML, Unexpected token '<'), the page reloads itself once to pass the check and otherwise says Our security service needs to check your browser again (archive/web/edge.js). The real fix is a skip-rule clause for the archive's data routes (ops/RUNBOOK.md §1, The archive page's own data calls; §7 row). No progress note.
  • Environment binding (DECISIONS #55, migration 0092): a request's environment comes from its credential or its link, never from the console switch — a service key belongs to the environment it was made in (service_key_homes), member and hand-off tokens carry a signed env, a link names one with ?env=<label> (env_scope; 409 environment_mismatch, 401 environment_gone / wrong_environment, 404 unknown_environment); a sandbox's member cookie, links, mail, return addresses and Stripe webhook endpoint carry its label, its pages show a Test environment strip, and it never deletes production's endpoint or takes over production's Omise address (SEC-20). Key listings say which environment a key belongs to; a copied key is refused in a sandbox; clones carry no keys. New GET /v1/environment; Member Gate 0.55.0 follows it; archive FMS_ENVIRONMENT. docs/progress/2026-10-24-environment-binding.md.
  • Background work per environment (DECISIONS #55, closes MIG-16's M4): production's worker set always runs, whatever the console shows; a sandbox's runs while an owner has pressed Start on Settings → Sandboxes (environment_workers, kept across restarts; src/workers.rs), on its own full-size pool, and never mails the admin alert address. Health gains a workers component (Off for a stopped sandbox, never an alert); heartbeats and webhook counts are per environment; retention prunes every environment whose work runs. docs/progress/2026-10-24-environment-binding.md.

2026-10-23 ​

  • Archive spotlight 0.3.0: the home-page section can sit before/after an HTML comment or an element with a class, the nth one (Frontier's theme marks sections only as <!-- end hero section -->, no IDs and no <main>); an administrator's home-page view lists the page's classes, comments and IDs on the settings screen; a spot not on the page adds nothing. Spotlights are kept out of the site's other post lists by default (pre_get_posts, includes/lists.php) — they were showing in the theme's Latest list as news. docs/progress/2026-10-23-spotlight-cf-access.md.
  • Archive spotlight 0.2.0 — on the home page without a block: Settings → Archive Spotlight → Home page places the section on the front page itself (buffered HTML, inserted once: top/end of <main>, above the site footer, before/after an element ID; pure includes/inject.php), for a custom theme with no block or shortcode spot; the screen reports the last home-page view (placed / spot not found / block already there). Card titles no longer show &#8217;. docs/progress/2026-10-23-spotlight-cf-access.md.
  • Archive spotlight 0.1.2: a Cloudflare WAF challenge (cf-mitigated: challenge) on the export call is named as one, pointing at the zone's WAF skip rule (runbook §1 gains the /api/export/ clause), instead of blaming the Access token; Cloudflare refusals quote their Ray ID; the token message names the Bypass-instead-of-Service-Auth mistake. docs/progress/2026-10-23-spotlight-cf-access.md.
  • Archive spotlight through Cloudflare Access (FMS Archive Spotlight 0.1.1): optional Cloudflare Access client ID + encrypted secret on Settings → Archive Spotlight, sent as CF-Access-Client-Id / CF-Access-Client-Secret with every archive export call when both are set (FMS_AS_CF_ACCESS_CLIENT_ID / _SECRET in wp-config.php win). A 403, or a redirect to an Access sign-in, now names Cloudflare or "something in front of the archive" instead of "The archive answered 403" — the archive itself never answers 403 there. docs/progress/2026-10-23-spotlight-cf-access.md.
  • Copy the catalogue from a sandbox to production (owners, Settings → Sandboxes → Compare with production, spec 05 FR-5c): a preview of the newsletter tags, plans (by plan code), discount codes (plans re-mapped by code, uses kept) and donation amounts that differ, then a ticked apply to production through the console's own plan save (price propagation, tag re-syncs and guards included). Plan identity, Stripe prices, connections and the email / Claude switches never move; a stale preview is a 409. services::catalog_promote, docs/progress/2026-10-23-catalog-promote.md.
  • Refresh my access in the newsletter archive (Member Gate 0.54.0): the archive keeps FMS's answer in its 12-hour sign-in cookie, so a new archive-manager tick, an untick or a plan change showed only after reopening the archive from the website (the console and guide now say so). The account menu's new Refresh my access (GET /refresh) drops the cookie and goes back through the website with fms_refresh=1. The plugin then forgets its cached access answer for that member (FMS_Session::forget_summary) and hands back a fresh one at once. docs/progress/2026-10-23-archive-refresh.md.
  • Test WordPress stacks mount FMS Archive Spotlight beside Member Gate (docker-compose.rehearsal.yml, wordpress-plugin/docker-compose.yml); rehearsal/restore-wp.sh skips it like the other live-mounted plugins and does not activate it. Recreate the WordPress container to pick it up.

2026-10-22 ​

  • Archive spotlight posts on the website (developer experiment, spec 10 FR-17): new WordPress plugin FMS Archive Spotlight 0.1.0 turns the newest archive issues into short public posts written by Claude (Frontier's API key, stored encrypted), capped server-side so they stay teasers (≤ 300 words, one verbatim quote at most, figures only from the issue), drafts by default, with an Archive spotlight block / [fms_archive_spotlight] for the home page. The archive gains a bearer-keyed GET /api/export/issues[/{id}] (off until ARCHIVE_EXPORT_KEY is set). docs/progress/2026-10-22-archive-spotlight.md.

2026-10-20 ​

  • The archive reads by newsletter (DECISIONS #54, supersedes #39's reader groups): a member reads a newsletter's back issues when one of their plans includes it on the Plans screen (an email opt-out changes nothing). FMS adds newsletters to the hand-off redeem and to GET /v1/plans/all, and a public GET /v1/newsletters; the archive maps each series to one (series_overrides.read_rule, the five known series built in, anything else locked until a manager picks; Manage newsletters gets a Who can read dropdown). docs/progress/2026-10-20-archive-reads-by-newsletter.md.
  • The console refuses an address the website would reject: New member and the Login email card check domain::member_email_problem (the rule of WordPress's is_email()) and answer "Enter a full email address, like name@example.com.", so a console change can no longer end as Website rejected this address. docs/progress/2026-10-20-email-history.md.
  • WordPress follows every FMS address change, and FMS keeps them all (migration 0091, Member Gate 0.53.0): each change of a member's address is a member_email_changes row (old, new, source, the staff member for a console change, the website's answer), written in the same statement as the change; the plugin's five-minute cron pulls GET /v1/users/email-changes and answers each on POST /v1/users/email-changes/{id}/website, so a member who never signs in again is updated too. An address WordPress refuses is answered rejected; a row WordPress cannot write is skipped and retried, never blocking the feed (the mint's email_mismatch self-heal stays the fast path; held members wait for hand-over; the backfill is before_history). Member page Email history card. docs/progress/2026-10-20-email-history.md.

2026-10-19 ​

  • Opn cards can be read again (every Opn card failed with "we couldn't read that card"): OpnClient::retrieve_token now reads the browser's token at GET vault.omise.co/tokens/{id} with the account's public key (Omise's Tokens API), not api.omise.co with the secret key, which answers not_found. Fixes checkout, donations, gifts and the member card change at once. The mock now answers not_found on the API host and wants a pkey_ on the vault, so the old call fails the suites. Awaiting live-fire with real test keys. docs/progress/2026-10-19-opn-token-read.md.

2026-10-16 ​

  • Add to the audience from Consolidation (DECISIONS §11 A follow-up; the never-create-on-re-sync rule stands): on the System ↔ Mailchimp check an owner or membership manager can add a paying member Mailchimp does not hold, one from the fix panel or all from the last check (Add absent members to the audience), through POST /admin/api/reconciliation/tags/fix with add_absent. Each is the signup's own MemberTagResync (create_if_absent, source reconcile_add); refused addresses, members PMPro still holds and members who get no newsletter now are counted and left out; same mailchimp_fix_refusal gate, audited like every console write. Suite consolidation. Staff guide: Consolidation §10, Staff roles, FAQ. docs/progress/2026-10-16-production-env.md.
  • A plan price edit reaches Stripe (FIN-1, the owner's ask; replaces the refuse-the-edit guard): saving a new amount queues one PlanPricePropagate per mapped Stripe account (services::price_propagation, default lane): a new price on the plan's product, the old one kept as an alias at its old amount, plan_prices repointed, the plan's forever discount prices dropped, the old prices archived by ArchiveStripePrice orders. Existing subscribers keep their price. A braked or read-only account dead-letters (replay from Health → Dead letters). A mapped team rung and an open team-invoice commitment still refuse. ensure_price / ensure_discount_price keys name the amount. Edit plan says how many accounts follow; Consolidation → Plan prices ↔ Stripe counts propagations waiting and refused. Suites plan_price_guard (inverted), plan_price_propagation. Staff guide: Plans, Consolidation. docs/progress/2026-10-16-production-env.md.
  • Deploy-day fixes from the go-live sweep: docker-compose.yml now passes the ~20 env vars the app read but never received (OUTBOX_CRM_CONCURRENCY, ALLOW_LIVE_OPN_WRITES, STAFF_GUIDE_URL, CRM_SYNC_DISABLED, the HTTP timeouts, the Opn/FX knobs, the TTLs, …), and the lib test compose_passes_every_env_var_the_app_reads keeps compose and .env.example complete; the nginx conf ships Cloudflare's real-IP lines, so per-IP limits see the visitor, not the edge; .env.example, ops/RUNBOOK.md §1/§2/§7/§9 and docs/SETUP.md corrected (PUBLIC_BASE_URL required, STRIPE_SECRET_KEY not passed, the brake armed from Phase S0, partial refunds tracked, Claude per environment); roadmap, decision and risk boards reconciled. docs/progress/2026-10-16-production-env.md.
  • Membership emails decided per payment connection (DECISIONS #53, amending §2 M9; migration 0090): a card membership (Stripe or Opn) takes Welcome and Your membership is cancelled from the switches on its connection's page (Settings → Connections → the account → Membership emails, owners, POST /admin/api/connections/accounts/{id}/emails, audited); free-access, bank-transfer, gift and seat memberships keep following Settings → Email, now Memberships FMS manages itself, which also lists every connection read-only. One derivation, services::lifecycle::policy, keyed on billing_source, never the account id. Existing connections inherit today's switches; a new one starts off; a sandbox clone switches every connection off. Scenario M7. Suite connection_emails.
  • Opn members get FMS's own billing emails (DECISIONS #53): Payment received after every successful charge (with the receipt link), Your membership renews soon N days before (per connection, 7 by default, naming the amount and a booked plan change) and We couldn't charge your card after each declined renewal try, at dunning and for a renewal held for 3-D Secure (never the bank's link). Queued through the outbox (BillingMailDue) in the charge's own transaction with stable keys; not stoppable; refused on a Stripe connection, which sends its own. Suite opn_billing_mail. Staff guide: Email and Connections pages. docs/progress/2026-10-16.md.
  • Emails carry the real logo. The serif text wordmark at the top of every email (member mail, admin alerts, the Claude sign-in notices, staff invites, the Settings → Email previews and test) is now the Frontier Myanmar logo — "Frontier" in black, "MYANMAR" in red — served by FMS itself at /brand/logo.png (compiled in, cached a day) and hotlinked from PUBLIC_BASE_URL; the name stays as the image's alt text for clients that block remote images. Rendered::with_origin fills the origin on the paths that send without the member-mail queue. docs/progress/2026-10-16-email-logo.md.
  • Member pages carry the real logo too. /pricing, /checkout, /donate, /gift and /account headers show the FMS-served logo (/brand/logo.png, red MYANMAR) instead of hotlinking the black WordPress upload; no text fallback is needed any more. docs/progress/2026-10-16-email-logo.md.
  • CI and the guide deploy green again. Both had failed on every push since 2026-10-14: the staff guide's email page referenced twelve screenshots nobody captured (shots:refs), so the Cloudflare Pages deploy never ran and the live guide fell behind master; and clippy 1.99 on the CI runner raised four lints the local toolchain does not (result_large_err on AppResult, now a clippy.toml threshold; double_must_use from async_trait's generated code, allowed on the two traits; one rfind, one sort_by_key). The email steps keep their text without pictures.
  • Staff guide: "Migration, start to finish" (docs-site/wordpress/migration/walkthrough.md): the whole move on one page — the two paths, seven phases, a table of every kind of member and what to check, what to do when FMS is unreachable, an import is stale, PMPro changes a moved member, a payment fails, a member complains, and the undo at each stage. The Migration, import and opt-out pages gain screenshots (manual shots from mocked data; retake from the rig).
  • Migration moved off the top menu: the PMPro → FMS hand-over page is a one-time job, so it is now a Settings tab (Settings → Migration, owner + membership manager; same /admin/migration URL). Staff guide updated.

2026-10-15 ​

  • Console: the import panel and newsletter opt-outs (Members, owners). Bulk import shows the export file's notes above any result (row breakdown, the timezone, the members left out on an unmapped PMPro level with level id and name), sums every new counter, and says Looks clean only at 0 problems and 0 unmapped-level members, naming what is not clean. It sends the whole file's identities with every chunk (packed under 900 KB so the activity trail still records each). New Verify (read-only, eleven classes with samples) and Reconcile absent members… behind a confirm dialog; Import confirms in the console's own dialog. New Newsletter opt-outs card: Mailchimp CSV parsed in the browser, Export taken at, Preview then Apply. Last member import on Migration comes from migration_marks (migration 0089), stamped on every applied import whatever its size. Activity labels for hand-over, opt-out and verify/reconcile rows. docs/progress/2026-10-15-segment-migration.md.
  • Member Gate 0.52.0: the export carries what the importer needs. starts_at on grant, team-admin and binding rows; PMPro's dates read in the site's timezone (notes.timezone) instead of as UTC; binding rows for Stripe-billed seat members and Stripe-billed team admins (team_capacity, no more external team grant; notes.stripe_team_owners); the level map honours the checkout map's filter; notes.skipped_members names the unmapped-level members; the CLI writes notes. docs/progress/2026-10-15-segment-migration.md.
  • Importer fixes so a migration reaches 100 % without hand edits (audit #1, #2, #5–#8, #10–#13, #16; cdece76). Bulk import gains mode: "verify" (every row classified, nothing written), file / whole_file (lists held members the export no longer carries) and reconcile_absent (ends their comp/external grants and seats, never a Stripe row or a handed-over member); a re-run re-dates a same-plan grant; a Stripe-billed team admin's team hangs on their Stripe team_* row and is never shrunk; new report fields matched_by_identity, stripe_access_mismatch, two_members_for_one_person, stripe_team_missing, teams_resized, problems, absent_in_file; the seat preview is honest. The Stripe importer resolves by subscription and customer before the address and stores start_date as starts_at. docs/progress/2026-10-15-segment-migration.md.
  • Newsletter opt-outs from the console (audit #4, cdece76): POST /admin/api/members/newsletter-optouts (owner) seeds opt-outs from a Mailchimp audience export for members who came from PMPro only, matched by delivery, login or previous address; an absent member gets nothing and is listed; tags granted after the export are skipped; existing preferences are kept; no outbox event. migration/steps/05_newsletter_optout.sh is superseded. docs/progress/2026-10-15-segment-migration.md.
  • Webhook endpoints are exempt from the live Stripe write brake (audit #14, cdece76): creating and deleting one moves no money, so the cutover no longer arms ALLOW_LIVE_STRIPE_WRITES for it; the per-account read-only toggle still refuses them. ops/RUNBOOK.md §6b.
  • Migration Assistant 0.4.1 (70e3204): the pre-flight checklist counts Member Gate's readiness rows by their real statuses (pass/fail/warn/info); before, every blocking row read as failing.
  • Segment-by-segment migration (DECISIONS #52, the owner's request): members move from PMPro to FMS a segment, a team or a member at a time, each step reversible on its own. Members an importer creates are held (users.legacy_held, migration 0088): FMS keeps their copy current from webhooks and re-imports but serves nothing and acts on nothing for them. The console's new Migration page (owners and membership managers, Permission::Migration) counts six derived segments (teams, Fanatics, Followers, Friends, Community, no membership) and hands over one, a team, a member or everyone behind a preview (one transaction, one update-only Mailchimp re-sync per member); Hand back is the per-segment rollback. While held: console and member writes answer 409 legacy_held; Mailchimp, lifecycle mail and outreach skip them; a structural failure dead-letters as legacy_held; the delta import skips members FMS serves (skipped_managed, audit #3); seats the import creates and members adoption creates mid-migration are held too (audit #18). The access summary says managed_by; GET /v1/migration/status feeds the plugin. Member Gate 0.51.0 serves only handed-over members while PMPro is active (PMPro's own access check defers to FMS for them), adds New members join through FMS, two blocking readiness rows and a list of PMPro's changes to handed-over members. New risks MIG-17 (PMPro's cron and Mailchimp add-on still act on handed-over members) and MIG-18 (PMPro gift and discount codes not migrated); runbook Phase S. Suite migration_segments. Not yet run on the rehearsal rig or a real WordPress.docs/progress/2026-10-15-segment-migration.md.
  • Audit of the PMPro → FMS import, export and verification path: eighteen ranked findings, each with evidence and the smallest fix. #3 and #18 are fixed by the segment work; an importer and export pass takes most of the rest; #15 (gift and discount codes) is recorded as MIG-18. docs/progress/2026-10-15-migration-audit.md.
  • MIG-5 closed (DECISIONS #4 = A, automated): the PMPro restriction sync now locks the posts PMPro locked through a whole category. Member Gate 0.50.0's Sync restrictions from PMPro and the migration assistant 0.4.0's restrictions job call one FMS_MG_Restrictions::flatten_pmpro_category_rules() after the per-post rows: every post a category rule covers gets its own gate row, mirroring pmpro_has_membership_access() — the post's own terms (no ancestor categories; tags too, as PMPro 3.8.1+), joined by term id, rules on a deleted level 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 job's used to delete everything not in PMPro's per-post table); the old "NOT synced" warning is a result line; a read-only preview sits under the button; the deactivation readiness check fails on a category-covered post with no row; 06_restrictions_export.sh flattens the same way. Caveats: a post added to a formerly locked category after the last sync gets no rule (re-sync before deactivating, then an editorial habit); free-Community categories become paid-only. Checked with php -l and throwaway scripts (SQLite, not MariaDB); not yet run on the rehearsal rig or the live site. docs/progress/2026-10-15-mig5-category-rules.md.
  • SEC-13, later the same day: a staff account still on the setup password signs in and chooses a new one — after the authenticator code. In production a known default on an account with two-step sign-in set up opens the code step as usual; a right code (or recovery code) marks the account must_change_password before the full session cookie is issued, so it is held to Your account until a new password is saved (the default is accepted as "current"). Without two-step sign-in the sign-in is still refused (403 default_password, naming an owner's temporary password and staff-reset); Claude's sign-in page still refuses a default either way. docs/progress/2026-10-15-cutover-code-owed.md.
  • FIN-2: the durable Stripe-cancel path is tested end to end. tests/ensure_cancel.rs now drives all four enqueue sites (structural reroute, unwind, checkout decline cleanup, finalize) through their real entry points: a failed inline cancel leaves exactly one EnsureStripeCancel order, which the real outbox worker drains into a cancel on the origin key; a brake dead letter replays after arming. The last three "code owed before cutover" items of ROADMAP §Next 5 (FIN-2 test, FIN-4, SEC-13) are struck. docs/progress/2026-10-15-cutover-code-owed.md.
  • FIN-4 re-audit (tests + records): the dead-letter alert's webhook is proven to re-fire with every re-alert email; dead admin emails on their own turn Event queue amber once (the 2026-10-03 note said they never touch the colour). docs/RISK-ASSESSMENT.md FIN-4 Re-audit 2026-10-15.
  • SEC-13 re-audit: the records now say what the code does — in production a known-default staff password is refused at sign-in (not held to Your account); the way out is an owner's temporary password or staff-reset. The console's password rule also refuses a known default (a padded seedpass123 got past the 12-character minimum). docs/progress/2026-10-15-cutover-code-owed.md.
  • Settings → Connections redesigned (frontend only): an overview — Add account menu (Stripe / Opn dialogs), one Payment accounts row per account in routing order with status, brands, priority, webhook state and a problem line, the routing diagram, and Newsletter rows (Mailchimp audience, archive) that open in place — plus one page per account (/admin/connections/{id}: Summary, Keys, Webhook, Routing, Local-currency pricing, Import, Danger zone; ?account= redirects). The Stripe import no longer auto-starts (Preview import); results show on the section that ran them; dialogs replace browser confirms. Fixed: Test audience title in production, three redundant brake fetches, Opn rows reusing the Stripe DOM id. Noted: a disconnected Stripe account is still routed to. Staff guide + shots updated. docs/progress/2026-10-15-connections.md.
  • Members see the price their card type pays before Pay, with a binding quote (DECISIONS #51 B, stage 2; migration 0087; plugin 0.49.0): an Opn account's brands are split into one card group per price (USD Visa/MC, baht JCB/AMEX/UnionPay) and every group carries its price per plan; a price block above Pay on /checkout, /gift, /donate shows it (baht with the rate and the USD list price, never "about $") and the Pay button reads Pay ฿4,120.00; the team ladder, gift selector and discount preview (now per card group) re-price; the form's quoted_currency/amount is compared before any provider call, card save, row or attempt — a stale or missing quote charges nothing and re-renders with today's price — and is stored on opn_charge_attempts; cardform::money is currency-correct (฿4,120.00 THB); success panels show the amount charged (+ the FMS receipt); /v1/me/membership serves an Opn row's stored terms; the billing page says baht is re-priced at each renewal and the card page shows a new card type's price; /v1/plans currency_note; discount_redemptions.currency. docs/progress/2026-10-15-fx-pricing.md (Stage 2).
  • Opn local-currency prices derived from a cached rate; typed Opn prices removed (DECISIONS #50 C, #51 Q2/Q4): JCB/AMEX/UnionPay on an Opn account pay round_to_step(USD catalog × rate × (1 − allowance)) in the account's own currency (opn::pricing); Visa/MC keep the USD price. Rate cached per account (account_fx_rates, migration 0086) from Omise's GET /forex/usd every FX_RATE_REFRESH_SECONDS, 5 % jump guard → owner accepts, typed-rate option, stale after FX_RATE_MAX_AGE_HOURS; health fx_rate. Owner-only Local-currency pricing on the Opn card (rounding, conversion-fee allowance behind a confirmed warning, …/pricing routes). Renewals re-rated each period with the terms stored on subscriptions.price_*; no usable rate = the renewal waits, never dunning. USD amounts checked against the THB ceiling after conversion; USD fixed codes convert at the rate. Every plan_prices row on an Opn account deleted. docs/progress/2026-10-15-fx-pricing.md.
  • Disable a payment connection (one must stay enabled): Settings → Connections → Disable on any Stripe or Opn account (stripe_accounts.enabled, migration 0085, owner-only POST /admin/api/connections/accounts/{id}/enabled). A disabled account takes no new business — routing::route_candidates skips it, so checkout, donate, gift, the card forms and the reroute worker's recreate pass it by, and a new team/member invoice on it is refused — while members already on it keep renewing; disabling refuses unless another enabled, connected account remains; is_main unchanged. Spec 02 FR-7. progress/2026-10-15.md.
  • Decision #51 opened (Business owner): what an Omise-paying member is shown and charged. A THB Opn price wins for every brand on the account (Visa/MC too) while every member page says USD; Opn renewals re-price from the live row (nothing stored on subscriptions); USD teams from ~26 seats exceed Omise's ceiling after conversion. Options A (USD on Omise, Visa/MC only) / B (baht prices shown before Pay, binding quote) / C / D, a stored-terms work package recommended now under either, and nine owner questions. Design: docs/superpowers/specs/2026-10-15-opn-currency-display-design.md; docs/DECISIONS.md #51 (+ a note on #50), decision card 51. No code change.

2026-10-14 ​

  • Decision #50 opened (Business owner): Opn (Omise) THB prices hand-typed, or derived from the USD catalog price at one stored rate and refreshed only on approval. docs/DECISIONS.md #50, decision card 50.
  • Team prices confirmed, no largest team (DECISIONS #25 decided): $3,024 / $3,094 at 16 / 17 seats and $175 a seat from 18 with no upper limit — the formula services::team_pricing already held, so no price changed; team-invoice requests and seat upgrades no longer stop at 19 (they use the ladder's 3–100 bounds). Spec 01 Q1 and PRD Q7 closed. progress/2026-10-14.md.
  • A member's cancel ends at the period end, then free Community (owner's rule, DECISIONS #49, plugin 0.48.1): POST /v1/me/membership/cancel (the website's [fms_cancel]) now calls the billing page's member_billing::cancel_at_period_end for a card-billed individual membership — Stripe cancel_at_period_end=true (no DELETE), Opn the local flag — with Resume until then; when such a row ends (Stripe's customer.subscription.deleted/canceled webhook, or the Opn renewal sweep) the member is moved to free Community on the same transaction unless they hold another live membership (enroll::after_period_end_cancel, idempotent), and gets "is cancelled" that day. A dunning lapse, staff Stop and a team owner ending a team are unchanged. [fms_cancel] says the end date and offers Resume membership. progress/2026-10-14.md "The owner's cancel rule".
  • Member billing page, whichever provider collects (WP7, DECISIONS #49, migration 0084, plugin 0.48.0): the owner's rule (D0) — a provider with a hosted portal is the member's billing surface, so a Stripe-billed member manages card, cancel and resume in Stripe's Customer Portal (the hub card and /account/billing send them to an account-scoped session); an Opn-billed member gets FMS's own page (/account/billing, /account/billing/card, /account/receipts/{id}, behind the member session): status strip, card change through Omise.js (an overdue renewal is charged on the new card at once), plan change at the period end with Keep current plan, cancel at the period end with Resume, and printable receipts (Omise issues none). API GET /v1/me/billing (managed_in: stripe_portal | fms), …/billing/card{,/start} (Opn only; a Stripe row is refused card_change_unavailable), /v1/me/membership/{cancel-at-period-end,resume,keep-plan}, GET /v1/me/receipts/{id}; the console's member page shows an Opn member's booked change (From {date}: {plan}). The Stripe card-change backend built first (SetupIntent create/read, default-card setters, mock and tests) was removed unused (WP7c). Suites member_billing (8), opn_member_billing (9), account_pages (6). progress/2026-10-14.md "Member billing, whichever provider collects (WP7)".
  • Opn's second reply (same day): Visa, Mastercard and JCB enabled on the test account; a one-use token sent twice is refused (token was already used), so the live-fire opn_ref_id_dedupe now charges a saved card and the reconcile may only ever re-send saved-card (renewal) charges — a lost token-based first charge stays lost; the Opn mock refuses a reused token too; webhook source IPs named in the runbook. progress/2026-10-14.md.
  • Opn's answers acted on (WP6, DECISIONS #48 addendum): every POST /charges sends its attempt row's id as Omise's duplicate-charge ref_id (ledger and never-retry kept; the reconcile re-sends only after live-fire opn_ref_id_dedupe passes); dynamic 3-D Secure pinned — first charges send return_uri, renewals never do (a challenged renewal now also emails staff); refunds credit Omise's fee back only on a void, and a charge voided before it settled records nothing instead of retrying (skip reason voided); live-fire gains opn_ref_id_dedupe and opn_renewal_no_3ds; the Opn card's 3-D Secure line states the rule. FIN-15 resolved for test, FIN-16 added (dispute liability), OPS-3: no webhook retries, the poll is the safety net; RUNBOOK §10.2 answers, edge allow-list. progress/2026-10-14.md "Opn's answers (WP6)".
  • Payment routing is a diagram: Settings → Connections → Payment routing draws one column per account in routing order and one row per card brand (Visa and Mastercard now apart), each cell the step at which that brand reaches that account (1 first, 2 and 3 the fall-through), a line along the path, Not routable rows, and the Opn caveat (USD on Visa/Mastercard only; other brands need an Opn price). The owner found the old Then: … line confusing; it also printed Opn (Omise) (Opn). GET /admin/api/routing gains accounts and brands (old routing rows kept, plus candidates); the dashboard's Members by account lists every account and counts by id. progress/2026-10-14.md.

2026-10-13 ​

  • Opn in the live-fire rehearsal and the scenario gallery: Testing → Live-fire takes an optional Opn test account and runs opn_preflight, test charge, decline, donation, checkout, renewal, declined renewal → dunning, a guided 3-D Secure checkout, a webhook round trip, reconcile and cleanup against the real Omise test API (an Omise account setting is a Warning, an FMS fault a failure; services::rehearsal::opn, tests/opn_rehearsal.rs); gallery group O (dunning policy, cancel at period end, webhook dedupe, attempt mismatch; src/scenarios/opn.rs); Settings → Connections → Opn card gains Reconcile now. 4e3405e, progress/2026-10-13.md "WP4b".
  • A card the Opn account cannot price goes to the next account that takes the brand (DECISIONS #48 D4, WP5): the card form (cardform::card_groups_for) and the Stripe pre-charge assertion (checkout, donations, gifts) skip an Opn account with no chargeable price for what the page sells (opn::pricing::PlanOffer), so JCB on a USD plan without a THB price is taken by Stripe Singapore instead of being refused; if no account takes it, the card is refused. Opn's no_price refusal stays as the safety net. The group holding Visa's account is the card form's first slot. progress/2026-10-13.md "WP5".
  • Records for Opn Payments (Omise Thailand) as a second provider: DECISIONS #48 (Opn beside Stripe through one account registry; FMS-driven renewals and dunning), Decision board card 48, risks FIN-14, FIN-15, SEC-19, OPS-3, spec deltas (00, 01, 02, 08), roadmap, ops/RUNBOOK.md §10 (Omise dashboard steps, the five questions to send Omise, go-live order, recovering a lost charge answer). No code change; two staff-guide sentences corrected (a card Opn cannot price is refused, not sent to Stripe, when Opn comes first). progress/2026-10-13.md.
  • Two pre-existing clippy warnings left as found (src/api/handlers/invoices.rs:60 sort_by_key, src/ratelimit.rs:386 rfind); no Opn commit touched those files. progress/2026-10-13.md "How to test it".
  • What an Opn-billed membership cannot do yet is refused by name: change plan (member and console), the billing portal, move to Stripe billing by invoice, Record payment, Renew, access-period edits, payment-method notes, seat changes on an Opn team ("This membership is billed by Opn; … is not available for Opn-billed members yet."). dcbfbfd, 46c677d; audit.
  • Member self-cancel and staff Stop work on an Opn membership: the row ends locally, the member goes to Community and gets the cancellation mail; no Omise call (the renewal sweep never charges a canceled row). 46c677d, progress/2026-10-13.md "WP2".
  • Opn memberships renew by FMS, with FMS's own dunning (DECISIONS #48 D3): a sweep every OPN_RENEWAL_SWEEP_SECONDS (default 3600) charges the saved card at the period end as a merchant-initiated charge; declines retry at +3 and +7 days with the row still active, then dunning grace (GRACE_PERIOD_DAYS) + outreach + an admin email; a brand no longer in capability goes to the reroute worker's Stripe-free Opn branch; a 3-D Secure answer becomes an action_required invoice with a 14-day SCA hold. Risks FIN-14, FIN-15. 46c677d.
  • Memberships on Opn at /checkout: the card is saved on the member's Omise customer before a CIT first charge; the row is incomplete until Omise says successful; 3-D Secure redirects to the bank and returns at GET /checkout/return; once / forever discounts computed in FMS; a gift holder continuing is charged now with the term from the gift's end; provisioning via the OpnMembershipProvision outbox event. 46c677d.
  • Opn reconcile: every OPN_EVENT_POLL_SECONDS (default 900) per Opn account, GET /events from a cursor (opn_event_cursor), lost charge answers found by metadata after an hour, abandoned 3-D Secure checkouts retired after a day; owner-only POST /admin/api/connections/opn/{id}/reconcile runs it now. Risks FIN-14, OPS-3. 46c677d.
  • Opn test tools on Settings → Testing (sandbox only): a real charge on a connected Omise test account with Omise's twelve documented test cards, recorded as a test_tool attempt and never in the ledger; Check capability again. ad6f61a.
  • Provider labels: Reports, the payments ledger, CSV exports and the member page say Card · Opn or Card · Stripe, with THB money formatting. ad6f61a.
  • Opn prices on Plans: a THB (or other) price per plan per Opn account, inside the account's limits, never locking the catalog price; without one only Visa/Mastercard can pay a USD plan on Opn. ad6f61a.
  • Settlements in THB for Opn charges: gross funding_amount, fee fee + fee_vat, net, and the rate when currencies differ, through the same FetchSettlement event; refunds read back in THB (no fee credited back, UNVERIFIED); Reports gain a THB section. Risk OPS-3. 740dd0b.
  • Signed Opn webhooks: POST /v1/webhooks/opn/{account_id} verifies the Omise-Signature HMAC with the base64-decoded secret (either signature during a 24 h roll), dedupes by evnt_…, archives and enqueues OpnEventReceived; the worker re-fetches the object (charges settle, refunds sync, disputes flag and mail). Risk SEC-19. 740dd0b.
  • Donations and gifts on Opn, with the 3-D Secure redirect: /donate and /gift charge through Omise when the card routes there; a 3-D Secure charge parks the row and redirects to the bank, back at GET /donate/return / GET /gift/return; an attempt row is written before every charge (opn_charge_attempts). 740dd0b.
  • The card form takes Opn cards: an Opn card group tokenises with Omise.js, detects the brand by prefix and switches slot; moving between providers asks the member to re-enter the number. 740dd0b.
  • Routing priority: route_candidates orders by stripe_accounts.routing_priority (default 100, the old order), then main first; set per account on Connections (Opn at 10 = Opn first). DECISIONS #48 D4. dcbfbfd, ad6f61a.
  • Opn Payments accounts on Settings → Connections (owners): connect with keys (test keys only in a sandbox, live only in production), capability from Omise (cards, currencies, limits), webhook address set at Omise, webhook secret pasted from the Omise dashboard, re-check, disconnect, remove; opn_account:{id} on Health. Risk SEC-19. dcbfbfd, ad6f61a.
  • New env vars, all optional: ALLOW_LIVE_OPN_WRITES (default false: every write to a live Opn account refused, reads exempt — the twin of ALLOW_LIVE_STRIPE_WRITES), OPN_HTTP_TIMEOUT_SECS (60), OPN_RENEWAL_SWEEP_SECONDS (3600), OPN_EVENT_POLL_SECONDS (900; both loops at least 60 s). .env.example. dcbfbfd, 46c677d.
  • Migration 0083_opn_provider: stripe_accounts.provider / routing_priority / supported_currencies / charge_limits, subscriptions.provider_card_id, billing_source = 'opn' (audited), member_invoices.source and integration_probes.kind accept Opn, new opn_charge_attempts and opn_event_cursor. dcbfbfd, audit.

2026-10-12 ​

  • Live-fire rehearsal: renewal_sca, a renewal that needs bank confirmation (3D Secure) on Stripe's always-authenticate test card (4000 0027 6000 3184) on a test clock: asserts the held invoice (member_invoices.action_required + hosted_invoice_url), sca_hold_until, the renewal_sca alert and no reroute / outreach / grace, then pays the invoice out of band (StripeClient::pay_invoice_out_of_band, through the brakes) and asserts it settles and the membership is active again. Unverified until the first rehearsal run; the go-live gap stays open until then (progress/2026-10-12.md "Rehearsal: renewal that needs bank confirmation").
  • Settings → Testing → Checks gains six transaction-scoped groups for the paths shipped since August: I Renewal needs bank confirmation (the 3DS hold: link kept, access held, then it stops); J Team invoices (staff vouching for an address, the billing address, a paid notice that only queues a re-read, the overdue gate); K Change plan and move to Stripe billing from the console (nothing granted before payment, stale invoices found, late money refused, bank transfers, invoice-billed renewals); L Gift memberships and discount codes (a claim makes a 12-month comp membership, a link works once, the discount sums, the one-use cap); M Membership emails (each only when switched on, the stop link, a refused send parked); N Refunds and webhook safety (partial and full refunds in Reports, a repeated event acted on once, an event on the wrong account dropped). Each check seeds its own @seed.test rows inside one transaction, drives FMS's real code (synthetic Stripe events through the webhook capture, now webhooks::capture_verified; sweeps scoped to the seeded rows), checks, and rolls everything back: no Stripe or Mailchimp call, no email, the sandbox left as found. docs/progress/2026-10-12.md.
  • Settings → Testing reads as four plain sections under the title Test tools (was Test data & scenarios): Demo data (Load demo data, the list behind What it creates), Checks (Run all checks / Run group, pass-fail pills), Stripe test accounts and Live-fire rehearsal (status pills, test-clock hint). Every window.confirm on the page, and the one in Create products & subscriptions, is now a console dialog (components/ConfirmDialog.tsx) that lists what will happen. Same API calls, same behaviour. Staff guide follows. docs/progress/2026-10-12.md.
  • A member's subscription rows carry one actions menu instead of a wall of buttons. Each row's last cell is a single ⋯ button; its menu lists Edit dates, Payment method, Record payment, Renew · Change plan, Move to Stripe billing · Stop (danger, last), each under exactly the gate its button had, and a row with nothing to offer (a stopped one) shows a dash. The menu is the console's one dropdown, components/ui/menu.tsx (ActionMenu, portalled so a table cannot clip it, keyboard-operable); Plans → Tools uses it too. Staff guide and doc-shot scripts follow. docs/progress/2026-10-12.md.
  • Consolidation checks run in the background; Mailchimp throttles at full size (MIG-16; migrations 0081, 0082). System ↔ Mailchimp, Stripe ↔ Mailchimp, System ↔ Stripe, Payment health, Mailchimp → FMS, the invoice backfill and the full report (export and email) run as jobs: one at a time ("Another check is running"), with progress, Cancel and the last result kept with its age; a lost runner or a restart fails the job. One unreadable Mailchimp member no longer sinks a 21k read: the report says incomplete and the exports and email say INCOMPLETE. Re-sync all drifting and Strip all act on the stored result in one request, never a browser loop; the five in-request check routes are gone. Every Mailchimp client on one key shares one 10-connection budget, signups jump the CRM queue, the bulk import warns about grants that have already ended, a plan newsletter edit that re-syncs over ~1,000 members asks first, the Mailchimp stub gains STUB_LATENCY_MS / STUB_MAX_CONCURRENT, and migration assistant 0.3.2 fixes its help text. CRM_SYNC_DISABLED discards the CRM queue; it is not a pause. The runbook gains Size the day and a measurement protocol (DB_MAX_CONNECTIONS=30, nginx real IP, no active sandbox). docs/progress/2026-10-12.md.
  • Console dialogs open centred on screen, however far the page is scrolled. The page fade-in (animate-fade-up, fill-mode both) left a transform on every page wrapper, which pinned each dialog's fixed scrim to the page: Change plan, Move to Stripe billing, Stop, Renew, Record payment and the rest could open below the fold. The fade-in now fills backwards (no transform once it ends), the shared Dialog portals to <body>, and Record payment uses Dialog (Escape, scroll for a tall form). The per-dialog workarounds are gone. Commit on claude/zealous-hypatia-mzjm4h.

2026-10-11 ​

  • Two-step sign-in for every console account (DECISIONS #47; migration 0080; RISK SEC-8, #10 A option 3). After the password the console asks for the six-digit code from an authenticator app (TOTP), for every role, with no opt-out; a code works once. An account not set up yet is held to Your account and the new setup screen (/admin/enrol: scan the QR or type the key, confirm a code, save ten single-use recovery codes). Your account gains Two-step sign-in (on since, recovery codes left, Set up a new phone, New recovery codes, each behind password + code); owners get Reset two-step sign-in on a colleague's Staff page (ends their Claude connections); staff-reset clears it; staff-totp sets a known secret for the rigs (FMS_ADMIN_TOTP_SECRET). Claude's sign-in page asks for the code too. Every session open at deploy is asked for the code once. One per-account code bucket (burst 5, 1/min) serves all three doors. New audit actions; guide sections on Your account, Staff, Connect Claude and troubleshooting. docs/progress/2026-10-11.md.

2026-10-10 ​

  • Move a free or bank-paid member to Stripe billing by invoice (DECISIONS #46; migration 0079; spec 05 FR-12d). Move to Stripe billing on the member page (Finance powers) issues one invoice for the first paid period on the Team invoices issuing account and FMS emails it; nothing changes until Stripe says it is paid (card, or staff's Mark paid by bank transfer, which marks it paid out of band). Then a Stripe subscription billed by invoice, anchored at the period end with no proration, replaces the comp/external row; renewal invoices are emailed by FMS and extend access when paid, with the team-invoice grace and sweep; dunning, SCA holds and the reroute worker never act on them. Cancel, Stop and the sweep void the invoice; a late payment is refused. The website refuses a paid plan change on such a membership. Payment health skips invoice-billed rows. New guide page Move a member to Stripe billing by invoice. docs/progress/2026-10-10.md.
  • Plan changes from the console, hardened after review (DECISIONS #45; migration 0078). An invoice-first plan-change invoice is voided, never left payable, when the membership is stopped or ends, when the period it priced renews, or a day past due (health sweep, void before lapse); a payment that still lands late is refused, not granted (dead letter, refund by hand). The invoice /send moved to the outbox (SendPlanChangeInvoice); a failed invoice deletes its line item before the draft; the preview no longer creates a Stripe price; a refused pending cycle switch retries once atomically; the member page reads pending changes in one query and hides Change plan on seats and ended rows; the days-to-pay field can no longer save a value it does not show. docs/progress/2026-10-10.md.
  • Change a member's plan from the console (DECISIONS #45; migration 0077; spec 05 FR-12c).Change plan on the member page (Finance, Membership manager, Owner) upgrades or downgrades a membership through the member's own tier-change code: a card upgrade is charged to the saved card or sent as a Stripe invoice the member pays, and applies only once Stripe says it is paid; a downgrade becomes account credit; monthly ↔ yearly restarts the cycle (card only); comp and external grants move freely. A waiting change shows under the row with its payment link and Cancel (voids the invoice). Days to pay default on Settings → Team invoices. The staff guide's cancel page no longer says a failed Stripe cancel is queued (it is refused). docs/progress/2026-10-10.md.
  • A fifth console role, Membership manager (DECISIONS #37 amended; migration 0076). Support plus money: everything Support and Finance can change, plus the two Mailchimp-side Consolidation fixes (re-sync a member's tags, strip paid tags from non-members), which move from owner-only to a new route tier Permission::MailchimpFix. Every other Consolidation fix, staff, connections, plans, sandboxes, backups and settings stay owners'. Through Claude it reads the decision board like Finance. The colleague who runs memberships now needs one sign-in, not two. docs/progress/2026-10-10.md.

2026-10-06 ​

  • A stale failed-payment event can no longer cancel a paid subscription (FIN-4 follow-up). The reroute worker re-reads the invoice from Stripe before acting; paid since ⇒ an audit row stale_invoice_paid and nothing else, and an unreadable invoice is retried instead of guessed. Voided, written-off or missing invoices still get the rescue, so a retry after a partial reroute (which voids the invoice itself) still finishes. Covers dead-letter replays and FIN-9 event replays alike. The PaymentFailedReroute payload now carries the Stripe event id and time. docs/progress/2026-10-06.md.
  • Claude's project records are compiled into FMS (DECISIONS #43 amended; migration 0075).recent_changes, read_roadmap, decision_board and risk_board now answer from the four plain-language records built into the binary, like the guide's pages, instead of a copy fetched hourly from the guide site with a Cloudflare service token. Nothing to connect: the Staff guide for Claude card, its Health row, the token and GUIDE_FEED_POLL_SECONDS are gone; an edit to a record reaches Claude at the next FMS deploy. New optional STAFF_GUIDE_URL (links into the guide); deploy.sh stamps FMS_COMMIT. The Docker context now carries the four records. docs/progress/2026-10-06.md.

2026-10-05 ​

  • Review fixes for the project records, and Access in front of /mcp. The guide feed's service token never follows a redirect (a redirect could have carried it to another host) and is never sent to an address it was not saved with; the 2 MB cap holds while reading; a poll that outlives a Save no longer files the old site's records under the new address; FMS's own address is refused; a board section with no cards answers when its owner is asked for; a switched-off feed says so. Runbook §1/§8 and the staff guide now describe the two Access applications (Claude's headers on /mcp, FMS's own token on the guide) — docs/progress/2026-10-05.md.
  • Claude reads the project records, by role (DECISIONS #43, spec 05 FR-23, SEC-17; migration 0074). Four more read-only connector tools: recent_changes (what changed and why) and read_roadmap for every role, decision_board for owners and finance, risk_board for owners; a tool a role may not use is not listed and is refused (audited) if called. They answer from ai/records.json, which every staff-guide build now publishes from the four plain-language records only (never DECISIONS.md / RISK-ASSESSMENT.md), and which FMS fetches hourly with a Cloudflare Access service token (Settings → Connections → Staff guide for Claude, Health component staff_guide_feed, GUIDE_FEED_POLL_SECONDS). The guide redeploys when either board changes. docs/progress/2026-10-05.md.
  • Claude connector: a Cloudflare Access service token on /mcp (SEC-17, ops only, no code). Thomas flagged that the WAF clause admitting Anthropic's range left /mcp open to any Claude account. Claude's connector now sends fixed request headers, so a Zero Trust Access application (Service Auth over Block everyone) refuses every /mcp call without the FMS-Claude token before FMS sees it. Staff type the two values into the connector; connections made before must be made again; the token expires yearly. Runbook §1, guide Connect Claude / When something goes wrong / Is it safe?. docs/progress/2026-10-05.md.
  • Claude sign-ins: the admin gets its own email (DECISIONS #43). Every sign-in now queues two outbox events, AiConnectionNotice (the person) and AiConnectionAdminNotice (the admin alert address), instead of one email with the admin in Cc, so a person's address that refuses mail no longer stops the admin's. The admin email lists the request: role, app and client id, sign-in target, connection id, time, IP and full user agent. Notices queued before still Cc. docs/progress/2026-10-05.md.
  • Decisions re-owned (DECISIONS preamble, DECISION-BOARD). The fifteen Technical decisions were checked for product, money or personal-data calls hidden inside them: #8 (a team-wide mute beats the seat) and #39 (a new plan reads nothing until a manager acts) now ask the Business owner to confirm; #6's member notes are asked about on Legal's #22; #36 informs Finance; #10 schedules console MFA before cutover; #44 relabelled Technical and added to the index. docs/progress/2026-10-05.md.
  • Claude reads the staff guide (DECISIONS #43, spec 05 FR-23). Two more read-only connector tools, search_guide and read_guide_page, answer how-to questions from docs-site/, compiled into the binary by the new build.rs (the guide as of the last FMS build; no database or network read). The Docker build stage now copies the guide's page Markdown. docs/progress/2026-10-05.md.

2026-10-04 ​

  • Docker build fixed (SEC-17 follow-up, OPS). The Rust build stage now copies ops/ai-reader.sql, which the fifth review compiled into the binary; docker compose up -d --build had failed since b6fb360. The release compile of the fms crate peaks at ~3.4 GB, so a 4 GB host OOM-kills rustc: deploy.sh now refuses to build below 6 GB of RAM + swap and ops/RUNBOOK.md §1 adds a swap file to server setup. docs/progress/2026-10-04.md.
  • Claude follows the active environment, off by default (DECISIONS #44 decided, SEC-17). Replaces #43's production only: each environment's database has an owner switch (ai_settings, migration 0073), off everywhere including production — after deploying, an owner ticks it on for production on Settings → Sandboxes. One gate (ai::settings::answering) for the sign-in page and every question; every answer names its environment and a sandbox's says it is test data; the read-only login's grants are applied and checked on every environment switch (ai::reader::build_for); a clone starts off; the worker drains the boot database's connector mail even while a sandbox is active. Staff guide: Admin → Claude and Sandboxes. docs/progress/2026-10-04.md.
  • Decision #44 opened: may Claude read a sandbox? The connector's production-only rule (#43) refused the first sign-in while a sandbox was active. Options and a recommendation (test on a local FMS with Claude Code, no change) in docs/DECISIONS.md #44. docs/progress/2026-10-04.md.
  • Cloudflare allow-list needs a Claude clause (OPS, documented). The connector's first real attempt failed with a 403: the host's Cloudflare rule challenges every machine caller it does not name, and Claude's connector paths were not named. ops/RUNBOOK.md §1 Cloudflare in front records the rule as it stands and the clause to add for Anthropic's outbound range 160.79.104.0/21; the staff guide names the symptom. No code change. docs/progress/2026-10-04.md.
  • Claude connector, fifth review (SEC-17). The answer filter no longer panics on non-ASCII names or withholds lowercase addresses, and catches JWTs and long bare tokens in URLs; Enable on an account already on no longer ends its connections; a sign-in and a refresh take turns; the hourly refusal says FMS emailed its admin address; the reader boot check refuses REPLICATION, TRIGGER/REFERENCES and sequence reads, and the script closes public CREATE and role memberships; the console restore warns separately when only the grants failed; invisible characters are cleaned everywhere an app name shows; email wording and a stale-password listing fixed. Staff guide admin/ai-assistant, admin/ops/backup, admin/ops/staff. docs/progress/2026-10-04.md.
  • Claude connector, fourth review (SEC-17). A connection now belongs to the password it was made with (any password change by any path ends it), every sign-in from Claude is emailed (a repeat ends that app's earlier connection first), the per-account sign-in bucket is taken before the password check, /oauth/revoke needs the app's own client_id, and a restore ends every Claude connection and re-applies ops/ai-reader.sql (FMS also re-applies it at boot). The boot check is now an allow-list (ai::reader::READ_COLUMNS), the outbound filter is wider, and an hourly question budget alerts the admin address. Migration 0072 ends all existing connections once. Staff guide admin/ai-assistant, admin/ops/email. docs/progress/2026-10-04.md, commit 48dbe7b.

2026-10-03 ​

  • Email on a new Claude connection (SEC-17 follow-up closed). The first time a Claude app is connected to a staff account (also again after a disconnect) FMS emails that person, copying the admin alert address; queued through the outbox (AiConnectionNotice) before the sign-in code is issued, so a connection is never made silently. Needs an email relay; none ⇒ skipped with a warning. Outgoing gained cc; the ai.sign_in Audit row carries new_connection. Staff guide admin/ai-assistant, admin/ops/email. docs/progress/2026-10-03.md (J).
  • Claude reads FMS (DECISIONS #43, RISK SEC-17, spec 05 FR-23, migration 0071). Staff add FMS to Claude (Settings → Connectors → Add custom connector, PUBLIC_BASE_URL/mcp) and sign in with their own staff account (OAuth 2.1 + PKCE). Claude then calls eleven read-only tools as that person: allow-listed, column by column, through a read-only database pool, production only, rate-limited, checked on the way out and audited as channel AI assistant. It never proxies /admin/api. Your account lists and disconnects your connections; owners Revoke all on Staff; switching an account off or a password reset ends them. Off by default: AI_ASSISTANT_ENABLED needs an https PUBLIC_BASE_URL. Staff guide admin/ai-assistant. docs/progress/2026-10-03.md (E).
  • Code review of the Claude connector. Reads now go through a SELECT-only Postgres login, fms_ai_reader (AI_DATABASE_URL, ops/ai-reader.sql), required in production while the connector is on and checked at every boot (ai::reader::verify). Also: recent_payments filters the payer by exact address; the token exchange is one transaction; find_members agrees with member_status on who has access; every call spends the person's throttle first; PUBLIC_BASE_URL is parsed; redirect_uri follows RFC 6749 §4.1.3; unused client registrations expire by last sign-in. ops/RUNBOOK.md §9, docs/progress/2026-10-03.md (G).
  • Second code review of the Claude connector. A token request no longer holds two database connections (about ten at once could starve the shared pool and stall checkout and webhooks). Changing your own password now ends your Claude connections; connect again afterwards. % and _ are literal in member search (console Members list and Claude), the token endpoint requires client_id on both grants, a refresh token reused within 30 seconds is refused without ending the connection, find_members takes "member now", failed-renewal and unpaid-team-invoice from the member list's own columns (subscriptions::granting_for), and /mcp authenticates in one query. SEC-18 also strips ?password=. Staff guide admin/account, admin/ai-assistant, admin/members-find. docs/progress/2026-10-03.md (H).
  • Third code review of the Claude connector. The reader login now grants column by column (hidden bank references, Stripe ids, invoice links, claim tokens and discount codes are unreadable; ai::reader::verify checks NEVER_READ_COLUMNS): re-run ops/ai-reader.sql on deploy. Changing your own password ends Claude connections first and fails if it cannot; throttled calls reach Audit once a minute; replays and Claude-side disconnects are Audit rows; secret-looking values are [withheld] one by one; unused registrations last 90 days; unknown filter values are refused. ops/RUNBOOK.md §9, docs/progress/2026-10-03.md (I).
  • SEC-18 closed: the environments list no longer returns database passwords. GET /admin/api/environments, readable by every console role, included each Postgres connection string with its password; it is now stripped (environments::without_password). docs/progress/2026-10-03.md (F).
  • Board cards can be linked and shared. In the staff guide every Decision- and Risk-board card has a fixed address (#decision-21, #risk-fin-3) and a Copy link button; the summary tables and the risk board's "decision N" mentions open the card in a new tab, and a shared link highlights its card. docs/progress/2026-10-03.md (D).
  • The Risk and Decision boards re-read for colleagues. Every card now has a generic example, a Words we use list, and (on decisions) what happens if nobody answers. Thomas's 1–3 Oct calls were checked against "Thomas decides technical, colleagues decide the rest": new Please confirm card 37 (what each console role may do, Support giving free memberships), Marketing's half of 35 split out, 42 dropped to for information, #38/#39/#42 relabelled Technical, MIG-1 and SEC-3 put on the sign-off sheet, SEC-9 given a 30-day proposal. docs/progress/2026-10-03.md (D).
  • Plugin 0.47.2: the archive page is responsive again. The hero's title sat left of the logo and the What you get card on wide screens (three different column widths); below 821px a signed-in visitor's button or note was pushed off the card. One 1040px column for all of it, and the stacked actions no longer wrap sideways. docs/progress/2026-10-03.md (C).
  • Code-review fixes, soon list: Health keeps watching a Mailchimp key it cannot read, reads webhook arrival from webhooks only and degrades only when a renewal was due (migration 0070); dead alert emails no longer feed their own alert; webhook replay moves on to events not yet replayed and never reopens an SCA window (DECISIONS #42); incomplete Consolidation runs never read "in sync"; only failed sign-ins spend the account bucket and a known browser skips it, IPv6 by /64; a throttled 3DS finalize says the payment may have gone through; R2 may use http:// on loopback; Stripe request tests assert prices, amounts and keys. Plugins: Member Gate 0.47.1, migration assistant 0.3.1. docs/progress/2026-10-03.md (B).
  • Code-review fixes, before cutover: Support may move only the website account that tried to sign in (owners any, DECISIONS #40); a default admin password cannot sign in in production; a refused reroute link commits nothing and adopts instead; a paying backup stops every create; the Mailchimp strip keeps free newsletters (DECISIONS #41); the renewal price check reads the invoice's own price; declined signups no longer dead-letter. docs/progress/2026-10-03.md (A).

2026-10-02 ​

  • FIN-7 closed: every Stripe money path's request body is tested against the in-process mock (tests/stripe_requests.rs: checkout, routing refusal, prices, cancel, portal, seat and tier amends, the reroute recreate, donate/gift PaymentIntents, a 3DS-parked donation). docs/progress/2026-10-02.md (risk board, batch 5).
  • MIG-7 closed: the member page's Website account card shows a refused website sign-in (migration 0068) and moves, forgets or keeps the binding; the member import pre-scans for identity conflicts. docs/progress/2026-10-02.md (risk board, batch 4).
  • MIG-8 closed: Health watches each Stripe key, its webhooks, and the Mailchimp key (migration 0069, INTEGRATION_HEALTH_POLL_SECONDS; DECISIONS #32 = A). docs/progress/2026-10-02.md.
  • MIG-4 closed: ALLOW_LIVE_MAILCHIMP_WRITES arms Consolidation's Mailchimp fixes for the cutover clean-up (DECISIONS #3 = C); badges on Consolidation and Connections; runbook Phase C step. The old "test mode" label never refused a console-connected audience — the gate now keys on the production environment. docs/progress/2026-10-02.md (risk board, batch 3).
  • FIN-10 closed: Money ↔ Stripe and Mailchimp → FMS checks on Consolidation; an incomplete Stripe run is never "in sync". docs/progress/2026-10-02.md.
  • FIN-2 closed: Consolidation reports a member Stripe bills twice (two individual memberships, or one plan on two accounts) — red banner, Billed twice rows, no automatic fix. docs/progress/2026-10-02.md (risk board, batch 2).
  • FIN-6: link instead of hold. A member already paying for the plan on the backup account is linked to that subscription; anything else gets the ordinary dated grace. The open-ended hold is gone (legacy rows still guarded). docs/progress/2026-10-02.md.
  • FIN-8: a failed renewal on a not-yet-linked subscription queues its adoption and a reroute that waits for it. docs/progress/2026-10-02.md.
  • FIN-9: every Stripe event is kept whole for 90 days and replayable by owners (migration 0067, WEBHOOK_RAW_RETENTION_DAYS, Health → Dead letters). docs/progress/2026-10-02.md.
  • Risk board, Technical column decided (DECISIONS #3 = C, #10 A–E, #32 = A). Thomas decided every row; the sign-off sheet is empty. Board, register and decision board updated. docs/progress/2026-10-02.md (risk board, batch 1).
  • SEC-13: no default admin password in production. A known-default or short ADMIN_PASSWORD refuses to boot; an account still on one is held to Your account; compose no longer supplies seedpass123. docs/progress/2026-10-02.md.
  • SEC-1/2/4: deploy.sh requires APP_ENV=production; an unknown webhook account path is an empty 404 (no global-secret fallback). SEC-1 and SEC-4 closed. docs/progress/2026-10-02.md.
  • SEC-7: joining the free plan needs the website sign-in (DECISIONS #10 B = 1). Unidentified visitors go to WordPress; nothing is created for a posted address. docs/progress/2026-10-02.md.
  • SEC-8: console sign-in throttled per address and per account, and a dummy hash for unknown addresses (DECISIONS #10 A = 1). docs/progress/2026-10-02.md.
  • SEC-14: browser security headers on every FMS response (frame denial, nosniff, referrer policy, HSTS over HTTPS; the script CSP is November). docs/progress/2026-10-02.md.
  • SEC-15: donate/gift card submits throttled (finalize has its own bucket); SMTP passwords over security = none and http:// R2 in production refused. docs/progress/2026-10-02.md.
  • OPS-1: production Health says when no off-site backup is configured (and when two uploaders share a prefix). docs/progress/2026-10-02.md.
  • FIN-4: every new dead letter alerts again (migration 0066, system_alerts.level); a dead money-lane event turns the queue red. docs/progress/2026-10-02.md.
  • SEC-9: plugin 0.47.0 stores no service key — wp-config.php only; migration assistant 0.3.0 reads the constant. docs/progress/2026-10-02.md.
  • FIN-2 test + a Stripe mock for the integration suites (StripeRegistry::with_base_url, tests/common/stripe_mock.rs, tests/ensure_cancel.rs). docs/progress/2026-10-02.md.
  • Archive: a calmer header. The plan badge, email, Manage newsletters and Back to … no longer crowd one row (the title wrapped and the email was cut off). Now: Manage newsletters plus one account button (initial + main plan) that opens the full email, every plan, the archive-manager note and Back to …. One row at every width. docs/progress/2026-10-02.md.
  • Archive: the Shown to members and Team plans switches no longer blank Manage newsletters. On any row below the first two, flipping either switch scrolled the dialog itself out of view (a white box, no error). Fix: one CSS rule. The same-day review fixes to the reading-level model were dropped in the merge with #39, which replaced that model. docs/progress/2026-10-02.md.
  • Archive: reader groups live in the archive; FMS keeps only permissions (DECISIONS #39, supersedes #38). Archive managers make reader groups on Manage newsletters → Reader groups: a name, what locked issues say, the FMS plans ticked from FMS's list, and every team plan. Each newsletter gets a group, Everyone, or the default group. FMS no longer ranks plans: plans.archive_level, GET /v1/archive/ladder and the console's Reading level in the archive are gone (migration 0065); the hand-off redeem now names the member's plans and GET /v1/plans/all lists every membership plan. Today's rules convert to Fanatic, Follower and Friend groups that read exactly as before. docs/progress/2026-10-02.md.
  • Archive: the Who can read choices come from the plans (DECISIONS #38). Each plan has an archive reading level on its Plans form (migration 0064, backfilled Friend 1, Follower 2, Fanatic and teams 3; Community never). FMS publishes the levels in use at GET /v1/archive/ladder and sends each member's level in the hand-off redeem; Manage newsletters draws one card per level plus Everyone, so a new plan becomes a choice with no code change. Managers can also set the default rule for unset newsletters. Old rules and old cookies keep working. docs/progress/2026-10-02.md.
  • Archive: Manage newsletters is a short list, and one row opens to edit. Each row now reads like its tab: position and arrows, colour dot, name, issue count, and a who can read badge (plus Hidden / Unsaved). Edit opens one newsletter into labelled fields (Name, Tab colour, Who can read as three explained choices, a Shown switch). The name box always holds the real name. The footer counts unsaved changes. No API or data change (archive/web/manage.js, styles.css, index.html). Staff guide wordpress/archive-newsletters. docs/progress/2026-10-02.md.

2026-10-01 ​

  • Archive manager is a tick on the staff account, and Staff is in the top menu (migration 0063, DECISIONS #17 amended). The Can manage the archive's newsletters tick left the member page. An owner now sets it on a colleague's new staff page (/admin/staff/:id). It counts for the website login with that address while the account is active: switching someone off takes it away, and staff need no member row. Existing ticks carry over to the staff account with the same email; members without one lose the tick. Staff moved from Settings to the top menu (owners only). Role, password, switch-off and the tick live on each person's page. POST /admin/api/members/{id}/archive-manager is replaced by POST /admin/api/admins/{id}/archive-manager and GET /admin/api/admins/{id}. docs/progress/2026-10-01.md.
  • Staff: "Email sign-in details" after adding a colleague or resetting a password (DECISIONS #37 amended). The dialog no longer closes on Add; it shows the sign-in details with Copy and an owner-pressed email (POST /admin/api/admins/{id}/invite-email), sent through the Settings → Email relay. Mails only the stored, still-temporary password; never reaches the audit trail. docs/progress/2026-10-01.md.
  • Console roles: Owner, Finance, Support, Viewer (migration 0062, DECISIONS #37, spec 05 FR-20). One gate (admin::roles::gate) decides every /admin/api route from a single table; an unlisted route is owner-only, and a unit test reads the router so none ships without an entry. The gate reads the account on every request, so a role change or switch-off bites on the next click. New Settings → Staff (owners): add a colleague with a temporary password they must replace before anything else works, change roles, switch accounts off and on (never deleted), and the last active owner cannot be removed. Refusals are refused audit rows, and every admin row now carries the role held (activity_events.role). The console hides what a role can't use. Every existing admin became an Owner. Break-glass binary staff-reset <email> when no owner can sign in (ops/RUNBOOK.md §7). Staff guide admin/ops/staff.md. docs/progress/2026-10-01.md.
  • Risk register re-synced, every risk has an owner, and colleagues get a plain-language risk board (docs only). Ten stale statuses fixed against the code: MIG-6, MIG-9 and MIG-11 closed; FIN-9, FIN-12, OPS-1, SEC-8, MIG-4, MIG-12 and MIG-13 narrowed; FIN-6 wording corrected. An Owner column on every scan table. Accepting a risk is now signed by the Business owner on the owner's advice. §5 gains SEC-13, MIG-12 (#20) and MIG-13 member comms (#19); MIG-9 ticked. New docs/RISK-BOARD.md, shown as Risk board in the staff guide's Project section, with a Business-owner sign-off sheet. docs/progress/2026-10-01.md.
  • Logs and the audit trail: cheaper to write, prune and read (migration 0061, DECISIONS #12 / #14 amended, new #36, spec 08 FR-19a). Audit rows are queued and batch-written (a full queue writes inline, so none is dropped; shutdown flushes). Routine member reads are kept once an hour per member, route and channel. A new services::retention prunes logs, the trail, completed outbox events and webhook marks (180 days) in index-driven batches. Audit, Logs and Health reads stop scanning tables. Logs History refreshes incrementally and gains Show older. Sandbox clones leave the trail and logs out. → docs/progress/2026-10-01.md
  • Archive: who can read each newsletter (spec 10 FR-16, DECISIONS #35). Manage newsletters gains Who can read per newsletter: Fanatic members only (default), Followers and up or Everyone with the archive (series_overrides.audience). Below the rule a member sees the headline with a lock and an Upgrade card, never the text or a search hit. Archive only; FMS unchanged. The archive's per-reader rules now match the file served, closing a hand-typed-path route to hidden issues. docs/progress/2026-10-01.md.
  • The archive never links to Mailchimp's public copy of an issue (spec 10 FR-15). The reader's Original email and Read it on Mailchimp links are gone and the build emits no archiveUrl. Links are judged by host: a link to another issue's Mailchimp page opens that issue in the archive, any other Mailchimp link (view in browser, click tracking with a subscriber id, unsubscribe) keeps its words and loses the link. Search indexes the cleaned article text, so no snippet quotes an address; the next sync or build re-indexes an old store once. Images are unchanged (DECISIONS #29). docs/progress/2026-10-01.md.
  • Admins change their own sign-in email and password. Console menu → Your account (/admin/account); GET /admin/api/account, POST /admin/api/account/{email,password}, each behind the current password; new password ≥ 12 characters; audited as admin.email_change / admin.password_change with no password captured. Other sessions are not revoked. Addresses are stored lowercase and sign-in ignores case. docs/progress/2026-10-01.md.
  • "Not a refund" box before a card member changes level (plugin 0.46.0). A card-billed upgrade or downgrade, and the cancel page's cheaper-level offer, cannot be confirmed until the member ticks that the unused time is credited (toward the upgrade, or off the next invoice), not refunded to their card. Browser required plus an admin-post refusal; no FMS change. docs/progress/2026-10-01.md.
  • Every decision has an owner, and colleagues get a plain-language board (docs only).docs/DECISIONS.md names one owner per decision (Business owner, Marketing, Finance, Legal / compliance, or Technical = Thomas), marks the calls Thomas took alone for the owner to confirm, and adds #18–34: the open questions that lived only in the roadmap, risk register, PRD and specs (cutover date, member comms, outage policy, past-due access, data erasure, seat prices, legacy prices and more). New docs/DECISION-BOARD.md, shown as Decision board in the staff guide's Project section; the Decisions summary gains an Owner column. No number changed. docs/progress/2026-10-01.md.

2026-09-29 ​

  • One email confirmation counts on both sides (plugin 0.45.0). Clicking the team-invoice link, or FMS Mark email verified, now lifts the website's confirm your email hold (POST /v1/team-invoices/verified-email/status); confirming on the website issues a waiting team invoice at once (record_wordpress_proof). docs/progress/2026-09-29.md.
  • Archive managers tidy the newsletter list (DECISIONS #17, spec 10 FR-14, migration 0060). A new member-page card, Newsletter archive, says whether the archive lets the member in and holds a Can manage the archive's newsletters tick (audited). FMS reports it as the archive_manage capability, which also grants archive_access. In the archive such a member gets Manage newsletters: rename, recolour, reorder or hide each newsletter, applied at once without a rebuild (series_overrides in the archive store; GET/PUT /api/series). A hidden newsletter is gone for members, issues, articles, search hits and teaser included; managers see it marked. No plugin change. Deploy FMS first, then the archive, then run one archive build. docs/progress/2026-09-29.md.
  • Admin screens use the full width (frontend only). Cards that stopped at two-thirds of the page now fill it, and each is laid out for the space: the forms (new member, create and edit plan, the SMTP relay, off-site backups) show each group's title and help on the left and its fields on the right (components/FormSection.tsx); Membership emails, sandboxes and service apps sit two to a row; the relay, Mailchimp and archive boxes put status beside the actions. A sweep of every page also fixed the members list running off the card (Name and Email are one column, the team role rides with Billing), the empty half of the member page's left column (Notes moved under Grant a subscription), Retention & churn standing alone on the Dashboard and Reports, a lone extra Stripe account taking half a row, and a browser-default menu on Backup. docs/progress/2026-09-29.md.
  • Project-status charts read cleanly and respond (guide only). The changelog chart names each bar by its day and each month once, so labels no longer overlap, and every bar can be pointed at, tapped or tabbed to: a panel lists that day's changes and opens them in the record. The timeline's today is the reader's own date, not the status file's, a phase still open after its end is drawn late, long phase names wrap instead of being clipped, and each row shows its dates. docs/status.json marks the Track 2 phase under way. docs/progress/2026-09-29.md.
  • Membership emails and the win-back segment (MIG-2 = B, plugin 0.44.0, migration 0059). FMS now sends four account emails through the relay: ends soon (comp and bank-paid grants, 14 and 3 days before), has ended, is cancelled and welcome. Each is off until switched on per environment on Settings → Email, with a preview, and a sandbox clone turns them off. Ends soon and welcome carry a one-click stop link (/unsubscribe, a signed non-JWT token). A fms:lapsed Mailchimp tag marks members who used to pay, and /checkout?plan=…&code=… fills in a discount code, through the WordPress sign-in too. The Dashboard counts Won back. docs/progress/2026-09-29.md.
  • MIG-2 re-cut (decision only, no code). DECISIONS §2 was written when FMS could send no member email. It now lists what Stripe, FMS and WordPress already cover after cutover, the five gaps (ends soon, ended, cancellation confirmed, welcome, win-back), and four options for who sends them, with details M2–M9. Recommendation B: FMS sends the account emails, Mailchimp the win-back campaign. Flags that TH's Stripe failed-payment emails were turned off in July. docs/progress/2026-09-29.md.
  • One Membership & Billing page (plugin 0.42.0). The theme's yellow account nav is a WordPress menu whose Membership & Billing tab still pointed at the legacy PMPro page, so members reached one page from the nav and another from the hub. The plugin now retargets that tab to the page chosen in the Membership & Billing picker (wp_nav_menu_objects, filter fms_mg_billing_nav_menus) and marks it current there. [fms_billing] gains the facts the theme's old My Membership panel showed — Member since, Billing, Next payment / Ends on / Runs until — with the hub header's wording. The separate Invoices page is now optional. 2026-09-29
  • One page per job (plugin 0.43.0, DECISIONS §16). An audit found the Membership & Billing split everywhere the plugin shims an old PMPro page. Now the old Billing, Orders, Profile and Cancel pages redirect to the picked page (Orders → #invoices, Cancel → #cancel; only fms_* query args carried; Confirmation deliberately not redirected), through one helper that the four older legacy redirects now share. The theme's Orders-page panel and its Cancel my subscription link are replaced, so the website no longer calls Stripe with its own key. [pmpro_cancel] is shimmed. Membership & Billing lists the tiers to switch to when no pricing page is set. Profile links never open wp-admin. 2026-09-29

2026-09-28 ​

  • Google Workspace SMTP relay. Optional Bounce address (envelope sender, migration 0058), a stable Message-ID per queued email or outbox event, a List-Unsubscribe-capable sender (no producer sets it yet), ≤100 recipients per transaction, and relay replies classified transient / relay-refused / permanent: member mail backs off exponentially and parks as Failed (with Retry, or Discard to keep the record and clear Health) instead of retrying forever; permanent admin alerts dead-letter, and a partly delivered multi-recipient alert is not re-sent to those who have it. A blank Reply-To now means the signed-in account when it is a mailbox on the From domain, not noreply@; the status block shows Replies go to. SMTP_SECURITY is validated at boot. EMAIL_SINK dry run, mail-test CLI, SMTP_REPLY_TO / SMTP_BOUNCE_ADDRESS; the rehearsal rig's FMS mail to Mailpit works again. DECISIONS #9 EM5 amended, EM8–EM10. 2026-09-28

  • The WordPress account hub opens with Your membership (plugin 0.41.0). Plan, state, kind, renews/ends date, and the bank-hold, grace and parked-upgrade banners at the top of [fms_account]; gift recipients get Continue as a member (no token: checkout anchors on gifts::live_grant_for). GET /v1/me/membership gains team_seat and gift. Hub gains Make a donation and the archive card. docs/progress/2026-09-28.md, DECISIONS §15.

  • Card-paying members are no longer bounced off Membership & Billing. The plugin's template_redirect to the Stripe portal hid the confirm-payment banner, invoices and cancel flow from exactly those members; removed. The billing card is greyed with the true reason (free / team seat / grant) on both surfaces. docs/progress/2026-09-28.md.

  • GET /v1/me/access reports past_due during a bank-authentication hold instead of "none" (the website printed "None" beside a live member's tier). tests/access_api.rs.

  • Member API trimmed: GET /v1/dashboard/summary (fleet-wide counts on any member JWT, no consumer) removed; GET /v1/subscriptions no longer returns region. docs/progress/2026-09-28.md.

  • Team seats are told who to ask; cancelling says it ends now, unrefunded. Seat note names the team and its admin (membership panel, cancel section, pricing notice). Self-cancel at period end + Resume is decided for after cutover (DECISIONS §15 AC5).

  • A dashboard "Mark as paid" now reads as Paid outside Stripe, not No charge found. On Stripe's 2025-03-31+ API the invoice's paid_out_of_band flag is gone; the fact lives in a paid payment_record entry of the invoice's payments list, which the settlement reader, the paid- invoice webhook and the team-invoice worker now all recognise (stripe::invoice_paid_out_of_band). The reader also stops following a canceled card attempt past a paid entry. The worker keeps the admin-entered net of a confirmed bank transfer on the newer API too. Re-check skipped on Reports re-files affected rows. And Reports no longer raises an attention card for a final verdict: Paid outside Stripe / Nothing charged rows sit in a collapsed note under Money; only No charge found / unrecorded reasons keep the card. docs/progress/2026-09-28.md.

  • Open in Stripe beside each account name on Settings → Connections. Opens that account's own Stripe dashboard (dashboard.stripe.com/acct_…/test/…) in a new tab, so an operator with several Stripe accounts lands on the one FMS holds. Re-check now stores the real acct_… id it reads from Stripe (rows connected before connect did so still carried the seeded placeholder, and the button stays hidden until one Re-check repairs them). The account-named button on the Reports "nothing to settle" rows is gone; those rows keep their invoice Open in Stripe button. tests/connections_recheck.rs. docs/progress/2026-09-28.md.

  • The thank-you note after paying opens with a small animated seal, not confetti. A green tick pops in, draws itself and sends out one burst of rays; the note rises in over a fading page. Plays once, not at all under reduced motion. Plugin 0.40.0. docs/progress/2026-09-28.md.

  • Who did what, from where: the activity trail (DECISIONS #14, spec 08 FR-21). One append-only table, activity_events (migration 0057, boot database, kept ACTIVITY_RETENTION_DAYS = 180), written by one middleware on every console write — refused attempts, failed sign-ins and data exports included — and by every member action through the website, the card forms and the hand-offs, which moved in from handoff_events (dropped). Each row names the admin or member, the channel, the member's own IP and browser when FMS knew them (never the website's server), and only allow-listed request fields. New Settings → Audit; the member page's Sign-ins from the website card becomes Activity; Logs gain an admin filter. Plugin 0.39.0 sends the member's origin at mint and on service-key calls. A restore rewinds the trail (ops/RUNBOOK.md §5). docs/progress/2026-09-28.md.

  • Reports names the invoices it wrote off. The "nothing to settle" card lists each skipped invoice — member, amount, Stripe account, invoice id and the worker's reason (member_invoices.settlement_skipped_reason, migration 0056; cleared on a re-check) — with Open in Stripe (dashboard deep link on the right account and mode) and an account button that opens Settings → Connections with that account outlined (?account=<id>). The card wording no longer promises that a re-check clears a genuine write-off. docs/progress/2026-09-28.md.

  • Payment received says "now or later" out loud. On New member and Grant access (paid outside Stripe) the box has a Record now / Record later switch: Record later hides the amounts and sends none, where before the only way to skip was to discover that the pre-filled Amount charged could be cleared. The currency picker is the console's own select, and the New member name placeholders read Sonny Swe. docs/progress/2026-09-28.md.

2026-09-27 ​

  • Review fixes for archive slice 2 (migration 0055). A hand-off refusal (expired, reused, wrong_scope) is recorded once per token, so a replayed link can't grow the audit; the archive address refuses a password as well as a user name, and a server answering {"ok": true} without issues is not taken for the archive (the Connections card no longer crashes on it); every console validation message now reads as its own sentence — the validation error: prefix is gone from all admin API errors, not just the archive's. docs/progress/2026-09-27.md (slice 2, review fixes).
  • FMS Health watches the newsletter archive (migration 0053). Settings → Connections has a Newsletter archive card: save the archive's address (it must answer with its health report first), Test it, or Remove it. FMS then polls the archive's /api/health every minute (ARCHIVE_HEALTH_POLL_SECONDS) and shows a newsletter archive component on Health: the archive's own verdict, down with an alert after two missed polls in a row (one blip raises nothing), and a list of the issues Mailchimp keeps refusing. Never critical. docs/progress/2026-09-27.md (slice 2), spec 10 FR-11.
  • Hand-offs into the archive are audited (migration 0054). Every link the website mints and every redemption of a token FMS signed — let in, turned away for the plan, expired, used twice, wrong site — plus rate-limit trips (once per address per minute) land in handoff_events, kept 180 days. The member page shows the last 20 as Sign-ins from the website, and the log lines now carry the member for the page's Logs link. Forged tokens are only logged. docs/progress/2026-09-27.md (slice 2), spec 10 FR-12.
  • The archive searches the whole text of every issue (archive server). A members-only GET /api/search ranks the FTS5 index; the reader merges its hits into the instant filter, shows a two-line snippet with the words marked, and sorts by Best match while a search is active. /api/health adds failing (which issues, since when; campaigns.content_error_since). The load-failure page no longer tells readers to run npm run sync, and the missing-sign-in note no longer names an environment variable. docs/progress/2026-09-27.md (slice 2), spec 10 FR-8/FR-10.
  • New member says why Create member is greyed out. Choosing Paid outside Stripe pre-fills Amount charged, and the payment pair is both-or-neither, so Received in bank became silently required. The payment box now states the rule, tints the missing field and says what to type. A line under Create member names whatever is still missing. A half-filled pair no longer blocks after switching back to Free (here and on Grant access). Record payment says both amounts are needed. docs/progress/2026-09-27.md.
  • Plugin 0.38.1: the thank-you note has a × and is never cut off. A close button sits on the yellow hero (a plain link, so it works without JavaScript) beside Start reading, the backdrop and Escape. A note taller than the window now makes the overlay scroll instead of clipping the last bullets and buttons, and the page behind stays put. docs/progress/2026-09-27.md.
  • Review follow-up on the two fixes above. Renew with Record the payment ticked now needs both amounts (a blank pair used to renew and record nothing); Grant access shows the same "Still needed…" line as Create member; the payment box announces a gap as a prompt and only a wrong value as invalid, on both inputs. Plugin 0.38.1 also: the thank-you note focuses its card rather than the below-the-fold Start reading, and the page behind no longer shifts sideways. docs/progress/2026-09-27.md.
  • The newsletter archive is granted per plan (DECISIONS #13; plugin 0.38.0 + archive server + migration 0052).
    • Plans → edit has an Access box, Grants access to the newsletter archive; the plan list shows an archive badge. FMS issues a new archive_access capability to every member holding a ticked plan, derived on read at /v1/me/access, /v1/access/can and the archive redeem alike. Backfilled so nothing changes yet: Fanatic (both intervals) and every team plan. The free Community plan can never grant it.
    • The WordPress plugin and the archive server default to archive_access; the plugin moves a stored tier_fanatic onto it once. Change the archive server's ARCHIVE_REQUIRED_CAP in the same deploy.
    • Archive copy no longer names a tier or claims "since 2019". The Friend suggested copy promises the website's archive of past magazine issues, and the pricing FAQ says selected memberships include the newsletter archive.
    • docs/progress/2026-09-27.md, spec 10.
  • An unreachable FMS no longer tells a member their plan lacks the archive (plugin 0.38.0). A new unavailable state says "We can't check your membership right now" with Try again, on the archive page and the My Newsletters card. A failed hand-off page no longer mentions FMS or tokens. docs/progress/2026-09-27.md.
  • Archive sync and health tell the truth (archive server). One issue Mailchimp keeps refusing no longer makes sync exit 1, so sync && build keeps building. /api/health ignores aborted runs, adds failing_issues / last_sync_failed / last_run_aborted, and answers 503 when not ok. docs/progress/2026-09-27.md.
  • Spec 10 — Newsletter archive records the as-built archive and the 2026-09-27 gap review (G-1…G-20) as Phase 2 / Later requirements. docs/specs/10-newsletter-archive.md.

2026-09-24 ​

  • One archive landing page, animated (plugin 0.37.0 + archive server).
    • A signed-out visitor to the archive is sent straight to the WordPress archive page, instead of first seeing the archive's own near-identical page. The deep link they opened comes back with them after signing in.
    • Fanatics returning from log-in go straight in, now also from the plugin's own page.
    • The WordPress page carries the animated issues scene in its hero. The lock opens while the log-in button is hovered or focused.
    • The archive's own page remains only for hand-off errors, and for a browser that dropped the archive's cookie. /auth now lands on /?signed=1 so that case can be told apart.
    • docs/progress/2026-09-24.md.
  • Archive sign-in gate is animated. The archive site's own "members only" page (archive/web/) replaces its static CSS tiles with an inline SVG scene. Issues fly along a dotted trail into a paper stack, the top issue writes itself in, a magnifier finds and highlights a line, and a Members only · every issue since 2019 seal unlocks on a loop. Pure SVG + CSS (.gs-*), no JS. Reduced-motion users get a composed still. Visual only; the gate's behaviour and copy are unchanged. docs/progress/2026-09-24.md.
  • Admin login shows Bagan at sunrise. An animated SVG backdrop sits behind the sign-in card: temples and stupas, drifting hot-air balloons, swaying toddy palms, the Irrawaddy with a boat, and a slowly glowing sun. Pure SVG and CSS in frontend/src/components/LoginScene.tsx, with no JS or dependencies; reduced-motion users get a still frame. The card is now frosted and fits phones. Visual only; the sign-in behaviour is unchanged. docs/progress/2026-09-24.md.
  • Plugin 0.36.1: FMS Membership is a top-level WordPress admin menu (just above Appearance) instead of an entry under Settings. Same slug; the old Settings address redirects; the page now prints its own "Settings saved." and button notices, which top-level pages do not get for free. Every staff-guide and runbook path changed from Settings → FMS Membership to FMS Membership. docs/progress/2026-09-24.md.
  • Signing out of WordPress signs you out of the archive (plugin 0.36.1 plus the archive server). The archive's own 12-hour cookie used to survive a WordPress log-out. Every hand-off now leaves an fms_mg_archive_open marker, and a log-out that finds it redirects through the archive's /logout?return=…, which clears the cookie and follows return only onto the member site. Deploy the archive first. docs/progress/2026-09-24.md.
  • Plugin 0.36.1: logging in from the archive page returns to the archive. On the plugin's own archive page (no Newsletter archive page chosen), Log in to read the archive used to land members on the home page after they signed in. It now returns them to the archive page. The Latest issues back-off no longer raises a PHP warning. The staff guide now says to reuse the site's existing /login/ page, because the theme styles no other. docs/progress/2026-09-24.md.
  • Team invoices say why they are stuck. A key-mode mismatch (test key in Production, live key in a sandbox) is the new permanent AppError::EnvironmentRefused, and work for a deleted request is the new permanent AppError::Gone: both dead-letter on the first attempt with the reason instead of retrying for days. Sandbox clones complete the invoice work they copied. The admin row shows Retrying · attempt N or Stuck with the reason; saving and the settings card share one precondition list (account, key mode, not read-only, instructions, contact, WP_ORIGIN). docs/progress/2026-09-24.md.
  • Structured, searchable log history (DECISIONS #12 → B). app_logs gains request_id, member_id, stripe_account_id, event_type, outbox_id (migration 0051), filled from the HTTP request, webhook and outbox delivery spans. Logs → History shows them as follow-chips; Member page → Logs and Dead letters → Logs open a filtered view. Retention by kind: LOG_RETENTION_DAYS_MONEY (180) for rows with an id or from a money module, LOG_RETENTION_DAYS (30) otherwise. New log_history health component (LOG_TABLE_ALERT_MB, 2048). Spec 08 FR-19; tests/app_logs.rs. docs/progress/2026-09-24.md.
  • Decision #12 written up: where logs live (keep Postgres app_logs with structured columns, or add Loki / VictoriaLogs / ClickHouse). Recommendation B. docs/DECISIONS.md §12.
  • Newsletter archive live at archive.frontiermyanmar.net with the back catalogue restored from 2020 — grant metric 4 met a month early. docs/ROADMAP.md Grant frame, docs/status.json (state live).

2026-09-23 ​

  • Staff guide: hidden Project status section + roadmap re-cut against the grant. docs-site/project/ opens with glance panels — grant scoreboard (five metric cards), phase timeline with a today marker, two-track Done / In progress / Next board, ordered next steps, changelog activity chart, decisions table with open/decided, risk register stacked bars + open High/Critical list — built at build time from docs/status.json (new, hand-maintained) and by parsing the records (project/records.data.ts); each record page then folds the full text of docs/ROADMAP.md, CHANGELOG.md, docs/DECISIONS.md or docs/RISK-ASSESSMENT.md into a collapsed Full record (VitePress @include; relative repo links rewritten to GitHub, raw HTML off on those pages). Reached only from the footer, for the developers and admins who own FMS. docs/ROADMAP.md gains a Grant frame (JX Fund tracks → the five §4.7 metrics → state) and a 2026-09-23 "Where we are" / "Next" / cutover-sequence re-cut. The deploy workflow and Pages watch paths now include the four records.
  • Review fixes on the migration hardening (same day). A Stripe-billed team owner's grant row and seat rows now carry the Stripe ids / owner WordPress id, so the bulk import resolves them to the importer's member instead of renaming it (apply) or counting a new one (preview). Leaving grace always clears the deadline, so a recovered dunning row can't carry a spent deadline into its next structural stop. Mailchimp's "signed up to a lot of lists" 400 stays retryable. The console's inline import releases a paused job. The unsyncable marker no longer blocks a real signup. Migration 0050 drops the import job's started_by FK, which refused every import started from a sandbox. Decisions #11. docs/progress/2026-09-23.md.
  • Stripe importer is a resumable background job (MIG-15). Progress is saved per page (stripe_import_jobs, migration 0047). There is no 20k cap, and a proxy timeout, restart or Stripe outage pauses the job for Resume. Failed rows are listed and the rest import. The Connections panel polls it and offers Resume/Cancel. Rows FMS owns (structural grace, send_invoice) are now skipped, not rewritten and counted as updated. docs/progress/2026-09-23.md.
  • Past-due at cutover keeps access (owner decision). An imported past_due subscription becomes dunning grace (grace_source, migration 0048) until the later of its paid-through date and GRACE_PERIOD_DAYS. Stripe's webhooks settle it. docs/progress/2026-09-23.md.
  • Outbox has two lanes. Mailchimp syncs drain concurrently (OUTBOX_CRM_CONCURRENCY, default 5, clamped to 10) through one shared client per connection. Money events stay serial and never wait behind a re-sync. Health adds outbox_crm_worker (non-critical), and a claim error no longer stops the worker. docs/progress/2026-09-23.md.
  • HTTP timeouts and retry headers. Every Stripe and Mailchimp request has a 10 s connect timeout plus STRIPE_HTTP_TIMEOUT_SECS (60) / MAILCHIMP_HTTP_TIMEOUT_SECS (30). Stripe GETs honour Stripe-Should-Retry and Retry-After, and Mailchimp reads retry 429s too. docs/progress/2026-09-23.md.
  • Mailchimp 400s are permanent, and refused addresses are marked. A 400 dead-letters on the first attempt instead of after about 2.4 h. A compliance-state, forgotten or fake address is marked in crm_unsyncable_addresses (migration 0046), and its re-syncs are skipped for 30 days. Consolidation shows these. docs/progress/2026-09-23.md.
  • Re-syncs never create Mailchimp contacts (owner decision). Only signup events (create_if_absent) PUT, and everything else uses PATCH, which skips an absent contact. Consolidation reports absent paying members as Not in the Mailchimp audience. docs/progress/2026-09-23.md.
  • Plugin 0.36.0: Stripe-billed members are exported as binding rows. Bulk import binds their wp_user_id to the importer's member by Stripe customer id, then subscription id (index, migration 0049). It never creates or renames, and reports address mismatches and unmatched customers. The --include-stripe-billed switch is removed. Migration assistant 0.2.0. docs/progress/2026-09-23.md.
  • Staff guide: the newsletter archive. New docs-site/wordpress/archive.md (plugin settings) and wordpress/members/archive.md (landing states, menu tab, Newsletters card, search/Date/sort, reader navigation, every refusal message incl. the single-use hand-off). The post-checkout thank-you note, troubleshooting, FAQ, glossary and task entries were updated too. Screenshots come from the rig plus a new shots/archive-preview.sh, which shows the 5 real published issues (no subscriber data; the footer wording says so). The missing ops/connections/04-routing shot was captured and the orphaned dashboard routing shot removed.

2026-09-22 ​

  • Archive: year, month and date-range filtering. One Date dropdown supports whole years, months and inclusive From/To ranges, with validation, shareable URLs, reset and keyboard/mobile support. docs/progress/2026-09-22.md.

  • Archive: bottom reader navigation. Added All issues / Previous issue / Next issue buttons beneath articles, preserving filtered order and restoring list position/focus, with mobile layouts and safe end-of-list states. docs/progress/2026-09-22.md.

  • Archive: hide template filters. Removed Template … entries from the newsletter filter bar while keeping their issues in All newsletters and search; old template-filter URLs reset to All newsletters. docs/progress/2026-09-22.md.

  • Archive security (SEC-11/12): closed encoded-path gate bypasses and malformed-path crashes; added FMS single-use archive hand-off redemption, plugin 0.35.0 and nginx HTTPS/header/rate-limit/token-log protection. Deploy FMS → archive → plugin. docs/progress/2026-09-22.md.

  • Security review recorded: docs/RISK-ASSESSMENT.md SEC-11–16 plus SEC-7–9 updates; deferred hardening options in docs/DECISIONS.md §10. docs/progress/2026-09-22.md.

  • Archive: filter layout and hover spacing. Newsletter tabs wrap without a horizontal scrollbar, result counts have their own readable row, and issue highlights have padded, rounded edges with matching keyboard focus and no sticky touch hover. docs/progress/2026-09-22.md.

  • Archive: first-boot holding page. npm start no longer exits when site/ is empty (a fresh volume before the first sync+build); it serves a branded 503 being prepared page that refreshes itself, while /api/health stays ok: false. Fixes the container restart loop on a new VPS. archive/src/server.ts.

  • Ops: ops/nginx/archive.frontiermyanmar.net.conf — nginx site for the newsletter archive (proxy to 127.0.0.1:8140, certbot-ready); setup steps in archive/README.md → Deploying.

  • Archive: styled filter dropdowns. Year, sort and optional facets now share accessible menus with clear selected states, separate issue counts, keyboard/type-ahead controls, mobile-aware positioning and native fallback; URL/reset behaviour is preserved. docs/progress/2026-09-22.md.

  • Archive: premium editorial list. Consistent issue rows, optional photo thumbnails, quiet newsletter markers and month dividers replace the mixed card grid, featured hero and readership badges; search/filter state and non-sticky navigation remain, and month groups now continue correctly when more issues load. docs/progress/2026-09-22.md.

2026-09-21 ​

  • Archive: compact masthead and filters. Smaller branding and title, search/year/sort on one desktop row, clearer newsletter chips, and results beside Clear filters; responsive controls retain URL state, and both archive filters and reader navigation scroll with the page. docs/progress/2026-09-21.md.
  • Archive: series and honest covers. Mailchimp has no series field and the sends are unfoldered, so archive/src/series.ts reads the newsletter from the sender name (folder first, template id last; known titles carry a brand colour), giving series chips in the toolbar, coloured tags on cards and the reader kicker, and a series facet in index.json. Mastheads are no longer card images: an opening image repeated across issues (detectMastheads), or a wordmark immediately followed by a photo, is dropped as chrome; short dateline / URL lines above it no longer end the hero search. Issues without a photo render as text cards (coloured rule, tag, headline, standfirst) and the featured slot as a series-coloured plate — no more cropped email banners. Verified on the five live issues.
  • "Newsletter archive" in the navigation for entitled members: a wp_nav_menu_objects filter appends the item to the account tabs (membership-account-nav-menu) and the Our Work dropdown (our-work-menu) — only when the visitor holds the archive capability; the plugin's fallback account bar gets the same tab; My Newsletters carries an archive card (open it / what unlocks it). Filters fms_mg_archive_nav_menus, fms_mg_archive_nav_label. Plugin 0.34.0.
  • Archive masthead: "Sign out" → "Back to <site>": the link only cleared the archive's own cookie while WordPress stayed logged in, so the next "Sign in" bounced the member straight back — a false sign-out. /api/me now carries siteUrl/siteHost (ARCHIVE_SITE_URL, default the login URL's origin); the email no longer clips under the link. The real sign-out is on the WordPress site.
  • Archive replacement page + the Frontier Myanmar wordmark: when no Newsletter archive page is set, or it is unpublished, the hand-off URL itself serves the [fms_archive] section in a plugin-owned shell (site logo, section, one-line footer — not the theme's get_header(), which prints bare menus on Elementor sites); every fallback (log-in round trip, archive "Sign in", nonce-less hand-off) lands there and entitled members still skip straight into the archive. The archive site's masthead and the replacement page show the Frontier Myanmar wordmark (archive/web/img/logo.png, assets/img/logo-frontier-myanmar.png) instead of a text "Frontier". Plugin 0.34.0.
  • Archive page opens the archive itself for entitled members: a Fanatic visiting the Newsletter archive page is minted a token and sent to <archive>/auth on template_redirect; guests and members without the tier still see the landing section; ?fms_archive=page shows it to a member; a failed mint falls through to the page. Setting Send members with the tier straight into the archive, default on. Plugin 0.34.0.
  • Plugin 0.34.0 — [fms_archive] is a finished landing section: yellow hero (kicker, heading, standfirst, issue count and year span), a card with the stack-of-issues art, a What you get checklist and one action per visitor state (guest → log in / become a Fanatic; member without the tier → why it is locked / see options; Fanatic → the nonce'd hand-off, "signed in as …"), then Latest issues — titles only, from the archive's new public GET /api/teaser (cached 10 min). New setting Internal archive URL for the server-to-server fetch when the public name does not resolve inside Docker. Replaces the bare button the first click-through exposed. Screenshots verified desktop + phone.
  • Plugin 0.34.0 — the post-checkout welcome is a thank-you note (2026-09-21): the ?fms_joined= modal greets the member by first name when signed in, carries a short note from the newsroom on what the money does (readers, not advertisers; reporters and editors covering Myanmar), signs off as the Frontier team, and demotes the tier benefits to a quieter What to expect from us list; team/community wording; Manage membership button for signed-in members; fms_mg_welcome_note filter. docs/progress/2026-09-21.md.
  • Plugin 0.33.1 — archive hand-off no longer 400s: admin-post.php?action=fms_mg_archive now has a logged-out (nopriv) handler that redirects to login, and a nonce-less request from a logged-in member (the archive's own "Sign in" link, which cannot carry a WordPress nonce) is sent to the members page holding the nonce'd [fms_archive] button instead of being refused. The token mint itself stays nonce-checked. New setting Newsletter archive page (falls back to the account page). Found on the first local click-through.
  • Newsletter archive: first real run + ops hooks — npm run sync -- --limit N / --since DATE for a small first run; the Mailchimp client is pinned GET-only (tests assert the method); --show and search snippets print the cleaned article text; public GET /api/health (issues, last sync age, build time); archive/Dockerfile + docker-compose.example.yml (server + 6-hourly sync loop). First sync against the live audience: 4034 sent campaigns, 5 pulled, real templates convert cleanly. Small tasks delegated to Codex (gpt-6-astra), reviewed and tested here.

2026-09-14 ​

  • Member names (migration 0045): users.first_name / last_name, required on the console's New member form and optional everywhere else. WordPress owns the value and mirrors it on every token mint and on profile save (POST /v1/users/name-change, plugin 0.33.0), writing only non-empty values and only when they differ — an incomplete profile never blanks an admin's entry. The Members list shows, searches and sorts by name; an admin can correct one on the member page. docs/progress/2026-09-14.md.
  • Members list — tier, dates, and acting on a row: a Tier filter where the paid tiers count only memberships somebody paid for (a comped Follower appears under Whitelisted, not Follower; Community means the free plan and nothing above it; team seats are neither), a membership start/end column (the member page's Renews is now End date), a per-row kind badge, a gift marker, a true match count behind the 200-row cap, and Stop / Renew on the row. The SQL mirrors growth::source_of and entitlements::caps_for_plan, with tests holding them in step.
  • Renew a grant (POST /admin/api/subscriptions/{id}/renew): comp and bank-paid memberships have no Stripe subscription to roll themselves over, and a lapsed one is not recoverable in place — the sweep rewrites it to Community. Renew adds one plan term to the later of the deadline and today, optionally recording the payment through the same ledger write as Record payment. Refuses card subscriptions, ended rows, Community and open-ended grants, each with its reason. Stop and Renew are real dialogs: refunds are still issued in Stripe, and the dialog says so.
  • A plan edit now reaches its members: services::plans::update_plan enqueues one MemberTagResync per granting member in the same transaction as the write (INVARIANT #3), so adding a newsletter to a plan updates everyone on it — and cannot re-subscribe somebody who switched it off, because the resync recomputes the subtract-only desired state. Unchanged tags enqueue nothing. The audience is GRANTING_STATUSES, shared with apply_canonical_tags.
  • WordPress newsletter scope chooser (plugin 0.33.0): a team admin opening My Newsletters picks "your account" or "the whole team"; team-wide changes were previously reachable only from the team page. Their own account remains the default.
  • Reports — membership, renewals, and the row-level lists: a period control (3/6/12/24 months) over the whole page; the growth charts beside the money; a new renewed series read from the settlement ledger (definition recorded in services::growth); a subscriber list covering everyone whose membership overlapped the period; a payments ledger keeping refunded and disputed rows visible and searchable by bank reference; Download CSV for both (every match, not the page, with no cross-currency total); and Print / Save as PDF via a print stylesheet rather than a PDF dependency. Closes DECISIONS §7 G1 and G2. New endpoints GET /admin/api/reports/{subscribers,payments}; csvfmt shared with the consolidation export.
  • Gifts: the member page says Gifted by …, the subscription row carries a Gift badge, the Members list marks a gifted membership, and the admin gift list links a claimed gift to the member it created.
  • Review fixes (pre-existing, found while reviewing the round): a partial refund discovered at settlement-capture time is now read before the ledger row is written — redelivery short-circuits on an existing row, so a refund read that failed after the insert was never retried and the refund was silently never subtracted; a team invoice re-created after a wrong-currency draft is deleted now carries the currency in its idempotency key (team-invoice-{id}-{currency}), where the old key with a new currency parameter had Stripe refusing the create for 24 hours; email_settings::resolve still reads the settings row on every call (so a pause or delete takes effect at once) but builds the SMTP transport once per distinct configuration instead of decrypting and constructing a fresh connection pool every 30 seconds. Also in this round's own code: the payments-ledger charge-id search is case-insensitive on both sides, the Members row dialogs are told the primary subscription's own plan rather than the first of an alphabetical aggregate, and a WordPress name half dropped for length is now logged.

2026-09-13 ​

  • Admin-panel changes from the testing round (six items, all admin-side): Dashboard Recent subscriptions filters by status/plan, sorts by When/Email, numbers its rows and is driven by clickable Subscriptions by status rows (pool raised to the newest 50, ten shown by default); the Payment routing card moved from the Dashboard to Settings → Connections (GET /admin/api/routing); Members sorts server-side by Email/Joined (sort, dir on GET /admin/api/members) with a # column; grants gain Authorized by (migration 0043, subscriptions.authorized_by, on the New member and Grant forms and the member page); Teams says team admin instead of owner everywhere, sorts by team / admin email, numbers rows and searches the billing email; the team page gets an editable Billing administrator email (migration 0044, teams.billing_email, POST /admin/api/teams/{id}/billing-email) and a per-team member search; text fields are restyled app-wide as tinted wells so they no longer look like ghost buttons. docs/progress/2026-09-13.md.

2026-09-12 ​

  • Reports subtract partial refunds: migration 0042 adds refunded_gross/fee/net to payment_settlements, read from the refunds' balance transactions by a new SyncRefunds outbox event (queued by charge.refunded, full or partial) and at capture time for a charge already refunded; monthly_summary nets them off in the payment's month and exposes refunded. A full refund still excludes the row. Closes the 2026-09-02 "partial refunds are not tracked" limitation. docs/progress/2026-09-12.md.
  • Newsletter archive (archive/) — a new self-contained sub-project, in two steps the same day. Data: npm run sync pulls every sent Mailchimp campaign since 2019 (metadata, engagement stats, the full campaign JSON, html/archive_html/plain_text) into archive/data/archive.sqlite (Node's built-in SQLite, FTS5); incremental, retrying, read-only against Mailchimp, no subscriber data. Site: each email is converted to an article (src/article.ts: chrome, footer, merge tags and tracking stripped; headings, figures, quotes, CTAs kept; sanitised HTML) and npm run build emits a magazine-style page — search, year chips, series/audience filters, month-grouped cards with covers, a reading view with hero, drop cap and newer/older navigation. Gate: npm start serves it for Fanatic members only: WordPress mints a member token and hands the browser to /auth?token=…, the server checks GET /v1/me/access for tier_fanatic and opens its own signed session; /data/* and /c/* are 401 otherwise. FMS Member Gate 0.32.0 adds the [fms_archive] button + Newsletter archive URL setting. npm run search exercises the full-text query the planned MCP will expose. CI job added. archive/README.md; docs/progress/2026-09-12.md.
  • Search and filters on Members and Teams: Members filters by status (active / lapsed / never), plan, billing source, team role (owner / seat / individual) and joined window, with search over current and previous email — all applied in SQL because the list is capped at 200 (repository::users::list_members + MemberFilters, GET /admin/api/members); the cap is now shown on screen. Teams filters client-side by status, plan, billing and seat usage, with search on name or owner. Shared FilterBar component. Test tests/member_list.rs. docs/progress/2026-09-12.md.
  • Dashboard & Reports charts restyled (frontend only): KPI tiles read label → value → delta pill → one context line, with the trend as a full-width strip along the foot instead of a corner thumbnail; the chart kit gets 11px axes, dashed minor gridlines, every month labelled when it fits, point markers + end-of-line value labels on unstacked lines, narrower bars with a total above each, a stacked tooltip total, an auto "only N months of history" note when the window is mostly empty, and a donut whose legend shows <1% rather than 0% with a share bar per row (sharePercent). Dashboard: subscriptions by status is a proportional bar, recent subscriptions capped at 10 with an All members link, members-by-account bars carry shares. Reports: new Where it went card (gross → fees → net, and net by source over the window) beside the 12-month chart. docs/progress/2026-09-12.md.
  • Branded transactional email: one design for every message FMS sends (src/mailbrand.rs): yellow stripe, wordmark, headline, sentences, one button, optional bank-details block, footer — as multipart/alternative text + HTML. Used by the four team-invoice mails, admin alerts, the Settings → Email test, and a new Preview design button (GET /admin/api/settings/email/preview). Migration 0041 adds team_invoice_mail.html_body; the WordPress poller sends the HTML part with the text as alternative (plugin 0.31.1). Closes ROADMAP "Next" item 0.
  • Team invoice currency (fix): the worker now pins currency on the Stripe invoice it creates. Without it Stripe defaulted the draft to the issuing account's currency (THB on the Thai account) and then refused the USD line item ("cannot combine currencies"), leaving requests stuck on Preparing invoice. An empty draft found in the wrong currency is deleted and recreated on the next attempt. src/services/team_invoices/worker.rs, test issue_replaces_empty_draft_in_the_wrong_currency.
  • Outgoing email from the console (Settings → Email): an SMTP relay (Gmail App Password expected) stored encrypted in email_settings (migration 0040), saved with a live connection test and a "Send test email" button. Once saved, FMS delivers the member mail queue itself (services::email::member_mail_loop, 30 s, back-off + per-row error, sent_via), /v1/team-invoices/outbound hands WordPress empty batches, admin alerts use the same relay, and Health gains a member_email component. No relay ⇒ WordPress keeps sending, exactly as before. Decision #9 in docs/DECISIONS.md; docs/progress/2026-09-12.md.
  • Team invoices — Mark email verified: an admin can vouch for a requester's address on an Awaiting verification row (POST /admin/api/team-invoices/{id}/verify, audited in verified_by); the request queues and the Stripe invoice is issued exactly as after the emailed link. Staff guide: Outgoing email, Team invoices updated; two stale redirect assertions in tests/team_invoice_http.rs brought in line with the restyled notices.
  • Staff guide (docs-site/): new pages Team invoices (admin: switch on, statuses, confirm a bank/Wise payment, cancel, renewals) and Paying by invoice (what the team admin sees); Teams, Common tasks, FAQ and troubleshooting link to them; Connect to FMS explains the internal http://fms-app:8080 API URL and Cloudflare's bot check. Admin shots from the rehearsal rig (shots runner, 7 new), member-side shots hand-taken from a local dev run and marked .manual.
  • Team invoice pages restyled: /team-invoices, its email-confirm and seat-upgrade pages now use the shared cardform::shell (same chrome as checkout/donate/gift) with status pills, inline validation errors and success notices; the checkout card offers "Request an invoice" as a styled secondary action under the pay button (only when invoicing is enabled) instead of a bare link. Admin: API error banners showed [object Object] — frontend/src/lib/api.ts now unwraps {error:{message}}; the transfer-instructions field is labelled required.
  • WordPress → FMS internal route: wordpress-plugin/docker-compose.yml joins the fms_default network so the plugin's server-to-server calls use http://fms-app:8080 and never cross Cloudflare, whose bot challenge was returning 403 (cf-mitigated: challenge) to every wp_remote_post — the "could not reach FMS / key rejected" banner.
  • Production nginx: ops/nginx/fms.frontiermyanmar.net.conf proxies every path to the FMS container. The previous site config served a hand-copied SPA from /var/www/fms-admin with a root that never matched /admin/assets/* (every bundle request fell back to index.html → the module-script MIME error) and only whitelisted some API paths, so newer public routes such as /team-invoices never reached the app. Install per the file header.
  • Admin bundle caching: /admin/assets/* is served without the SPA fallback (a missing hashed bundle now 404s instead of returning index.html as text/html), bundles carry Cache-Control: immutable, and index.html is no-cache — fixes the "Expected a JavaScript module … MIME type text/html" break after a deploy (browser/Cloudflare holding a stale index.html). src/admin/mod.rs.
  • Team invoice checkout: verified-email bank/Wise or hosted Stripe invoices, audited outside-payment confirmation, annual invoiced renewals and payment-gated seat upgrades; disabled until configured. See progress and operations.

2026-09-11 ​

  • Off-site backups from the console (closes spec 08 FR-17 for the console-configured target): Settings → Backup gains an Off-site copies (Cloudflare R2) card — bucket, account id, token (secret encrypted with APP_ENCRYPTION_KEY), nightly hour, retention. FMS itself now takes the nightly pg_dump | gzip and ships it to R2 with the same object layout and 7 + 12 retention as ops/backup.sh, so restore.sh reads either. Save verifies with Cloudflare first; Test connection and Upload snapshot now buttons; a new non-critical offsite_backup health component alerts when no snapshot has landed for 26 h. New hand-rolled SigV4 client src/r2.rs, migration 0038_backup_settings. The env-configured sidecar keeps working unchanged — run one or the other, not both. docs/progress/2026-09-11.md.

2026-09-10 ​

  • Staff guide site (docs-site/): a separately hosted, screenshot-per-step guide to the admin portal and the WordPress plugins, written for non-technical colleagues — 48 pages and 115 screenshots, all generated from the rehearsal rig by a re-runnable Playwright pipeline (docs-site/shots/, npm run shots), with a .png.manual marker that protects any shot replaced by hand. VitePress; Cloudflare Pages behind Access (ops/RUNBOOK.md §8); a CI job builds it and fails on a dead link or a missing image. docs/ stays the engineering source of truth and is unchanged apart from a pointer in docs/README.md. docs/progress/2026-09-10.md.
  • Dev-server proxy target is configurable (frontend/vite.config.ts): VITE_API_TARGET overrides the hard-coded http://localhost:8137, so the SPA can be developed — and screenshotted — against the rehearsal rig on 8237. Default unchanged.
  • Legacy server-rendered admin removed; the React admin is served at /admin (OPS): the maud+htmx console (dashboard, connections, plans, seed form and its login page — src/admin/{dashboard,plans,subscriptions,connections,ui}.rs) is gone, the Vite build has base: "/admin/", and src/admin/mod.rs nests the bundle at /admin beneath the JSON routes. Before this the bundle sat at /spa asking for /assets/…, which 404'd, so the built image served the old console and the React one was unreachable outside vite dev. Member-facing pages (/checkout, /donate, /gift, /pricing, /auth/session) untouched. Dev URL is now http://localhost:5173/admin/. docs/progress/2026-09-10.md.

2026-09-02 ​

  • Growth metrics — acquisition / retention / churn on the Dashboard (closes spec 05 FR-1 + Q1, churn half of MIG-11): GET /admin/api/reports/growth?months=N serves per-UTC-month active members (split by source), new members, churned, returning and the churn/retention rates, from the local subscriptions cache alone — no Stripe call. Counted per member, so a team seat alongside a personal plan is one member and a reroute is not churn; a rate with no denominator is null, never 0%. Needs migration 0037 subscriptions.ended_at, stamped on every terminal status flip and cleared if a row recovers (dunning is not an ending); existing terminal rows backfilled from updated_at and marked approximate in the UI. New seed s1_growth_history, new suite tests/growth.rs. docs/progress/2026-09-02.md "Growth metrics".
  • Admin chart kit + Reports/Dashboard redesign (no new dependency):frontend/src/components/charts/ (stacked bars, area/line, donut, sparkline, legend, table disclosure, currency tabs) with axes, gridlines, mean lines, tooltips and a hatched marker on the month in progress. Reports switches currencies with a segmented control instead of repeating a full section each (switched, never summed) and its tiles gained month-over-month deltas and sparklines; the Dashboard gained a Growth band above a Plumbing band, loaded independently. docs/progress/2026-09-02.md "The chart kit".
  • Settlement fetch fixed for 2025+ Stripe API (Reports was empty): the invoice read now expands payments (Basil dropped charge/payment_intent; unexpanded payments is empty), so paid invoices no longer get written off as "nothing to settle". The write-off is now visible on Reports (missing.skipped) with a Re-check skipped button (recheck_skipped on POST /admin/api/settlements/backfill). docs/progress/2026-09-02.md "Same-day fix".
  • Customizable newsletters (subtract-only): an FMS admin can opt a member in or out from the member page; a team admin can mute a members-only newsletter for their whole team (now and for every future seat) or for one seat, from the WordPress team page. New team_newsletter_mutes table + newsletter_preferences.source (member | admin | team_admin), migration 0036. Every suppression is applied inside the single crm_state::desired_state_for_address, which both the Mailchimp push and both sides of Consolidation's drift diff already read — so a muted newsletter is pushed off rather than omitted and can never be reported as a missing tag (verified live, both directions). A team mute is scoped to the seat subscription the team pays for, so a member's own plan is untouched; a team admin can undo their own mute but never a member's own unsubscribe, and reaches only the members-only newsletters the team plan grants (same-day review fix: a save writes only rows whose state changes, so a re-posted form can't relabel who wrote an opt-out). The CRM worker's subscription-lifecycle delta was removed in favour of the exhaustive resync — it was a second definition of the desired state. GET/PUT /v1/teams/{id}/newsletters, GET/PUT /v1/teams/{id}/members/{member_id}/newsletters, GET/POST /admin/api/{members,teams}/{id}/newsletters; plugin v0.30.0. Spec 06 FR-7b / FR-16a. → docs/progress/2026-09-02.md, decisions in docs/DECISIONS.md §8.
  • Docs (timeline): docs/ROADMAP.md "Where we are" re-cut as of 2026-09-02 — the Sep-2 work recorded; the Next list re-ordered (commit → go-live operator session → owner decisions → code owed → rehearsal Phases B/C → parked); a proposed dated sequence to cutover for the owner to confirm; the stale "renewal SCA not handled" risk row corrected (closed 2026-08-15). RISK-ASSESSMENT.md §6 in-flight inventory and the test counts (44 suites / 467 tests) refreshed.
  • Reports audit readiness recorded as an open decision (docs/DECISIONS.md §7: transaction ledger + CSV, void-not-delete, payout reconciliation, period controls) with a no-code "for now" checklist; ops ledger export in ops/RUNBOOK.md §6d. The refund-before-fetch and 2025 payments-list fixes from the review are in docs/progress/2026-09-02.md.
  • Net revenue (Reports tab): a payment_settlements ledger (migration 0035) records what each payment actually left in the bank — Stripe's fee, the settlement currency and the conversion rate, read from the charge's BalanceTransaction by a new FetchSettlement outbox event + SettlementDispatcher (never in the webhook handler, INVARIANT #7; reads only, so the live brake is untouched). Producers: invoice.paid, settled donations and gifts. A full refund / dispute.funds_withdrawn flips the row out of the totals. New admin Reports tab: net / gross / fees per settlement currency (never combined — no invented FX) with a 12-month stacked chart, plus a Backfill button for payments that predate the feature (GET /admin/api/reports/revenue, POST /admin/api/settlements/backfill). Closes the revenue half of MIG-11. → docs/progress/2026-09-02.md.
  • Whitelist grants carry money: an external grant now records what the member was charged and what reached the bank (e.g. billed $350, banked $335 after the transfer fee), written in the grant's own transaction; comp grants drop it as they already drop external_method. A Record payment action on the member page logs later collections (renewals by bank transfer) and reclassifies a comp grant as external; every subscription's payment history — Stripe renewals with their fees and hand-keyed transfers alike — is listed on the member page. POST /admin/api/subscriptions/{id}/payments, POST /admin/api/settlements/{id}/delete. Spec 01 FR-8 (was [Phase 2]).
  • Member notes log: a member_notes table (append-only: who, when, optional linked grant) — a Note field on New member and on Grant a subscription, written in the grant's own transaction, and a Notes card with "Add note" on the member page (POST /admin/api/members/{id}/notes). First place the acting admin is recorded (admin_user_id + email snapshot, no FK — admins live in the boot database). Migration 0034. → docs/progress/2026-09-02.md, decisions in docs/DECISIONS.md §6.
  • Fix (teams): granting a team plan from New member / Grant a subscription now provisions the team (operator-named — required in the form) and the owner's admin seat in the grant transaction. Previously it left an orphaned team subscription with no roster that only the Consolidation back-fill repaired. tests/team_grants.rs.
  • Admin UI: Rename on the team page (POST /admin/api/teams/{id}/rename) — no rename existed anywhere. Team names are trimmed and capped at 120 characters on every surface.

2026-08-29 ​

  • Docs (MIG-4 corrected): Consolidation's System ↔ Mailchimp "Re-sync all drifting" already is the post-cutover bulk correction; the real gap is that every Mailchimp fix refuses outside test mode with no arming switch. Option A rewritten around an ALLOW_LIVE_MAILCHIMP_WRITES brake. → docs/DECISIONS.md §3.
  • Decision (owner): a plan-price edit in FMS must propagate to Stripe (new price on every mapped account, repoint, archive old). Added to the roadmap's Next list (item 4); the FIN-1 refuse-the-edit guard stays until then. → docs/ROADMAP.md, docs/RISK-ASSESSMENT.md FIN-1.
  • Rehearsal rig (Phase A, first cut): docker-compose.rehearsal.yml + rehearsal/rehearse.sh (live-shaped WordPress, Mailpit, local Mailchimp stub, Stripe event pump), a Rehearsal Console (Testing → Rehearsal) that runs the FMS side of the cutover as buttons, the pseudonymised full-fidelity WordPress restore (rehearsal/restore-wp.sh), and the disposable FMS Migration Assistant plugin (chunked export, batched restrictions, page swap/rollback, flip, sweep). Sandbox-only MAILCHIMP_API_BASE_URL. → docs/progress/2026-08-29.md, docs/migration-runbooks/local-rehearsal.md, decisions in docs/DECISIONS.md §5.
  • Fix (teams): adding a member who already holds a seat on a full team now says so instead of "team is full" — the bulk importer's delta pass was failing on every existing seat (found at 23k-member scale). → docs/progress/2026-08-29.md
  • Member Gate: export builder split into context / user-id chunk / owner+seat pass so the assistant can run it in batches; the one-shot "Download member export" output is unchanged.
  • Admin UI: Connections → "Recreate webhook endpoint" now shows its status — spinner + disabled button while the Stripe call runs, an inline "✓ Endpoint created — new signing secret stored." on success (failures keep landing in the page error banner). Frontend only.
  • Admin UI: Team detail exposes the existing ownership handoff — a Make admin action on each seated member calls POST /admin/api/teams/{id}/transfer-admin (the sitting admin is demoted to member in the same transaction). Frontend only; no backend change.

2026-08-26 — 8caccdb ​

  • Security: webhook writes are pinned to the path account; a payload naming another account's subscription changes nothing and is still marked processed (SEC-5 closed). tests/webhooks.rs.
  • Security: a sandbox may only write to a Mailchimp audience it connected itself — the CRM worker consumes refused events with a warning, mailchimp/wipe refuses, new clones strip mailchimp_config (SEC-6 closed). New suite tests/crm_gate.rs; MailchimpClient::with_base_url test seam.
  • Billing: the checkout create_subscription idempotency key now hashes price / card / quantity / anchor, so a retry with a different card is no longer a 24 h idempotency_error lock-out (FIN-5 closed). services::subscriptions::checkout_idempotency_key.
  • Decision: MIG-1 — the 172 Community-readable posts migrate as members_only (accept the lockout). MIG-2 / MIG-4 / MIG-5 written up for the owner in docs/DECISIONS.md.
  • Ops: webhook re-provisioning tick sheet (ops/RUNBOOK.md §6b.1); live-fire session plan.
  • Docs: all living docs re-synced to the 2026-08-25 commit; docs/progress/2026-08-25.md written after the fact; this changelog created and backfilled. → docs/progress/2026-08-26.md

2026-08-25 — c7b6df3 ​

  • Billing: a plan's local price can no longer be edited once any account has a mapped Stripe price (assert_price_editable); Consolidation gains a Plan prices ↔ Stripe drift panel (FIN-1 closed). tests/plan_price_guard.rs.
  • Billing: no Stripe cancel result is discarded any more — admin cancel refuses instead of half-cancelling; the reroute worker, its unwind and both checkout cleanups enqueue a durable EnsureStripeCancel outbox order on failure (FIN-2 code-complete; integration test owed).
  • Ops: dead-letter queue is two-way — Health → Dead letters lists payloads and replays one or all (FIN-4 partial; alert still transition-only). outbox::{list_dead, requeue_dead, requeue_all_dead}.
  • Security: DB/config/internal errors render an opaque 500 body (SEC-8 partial).
  • Ops: container runs as non-root fms; 5432 and 8137 bind to loopback only; compose passes env by explicit whitelist; HEALTH_ALERT_WEBHOOK_URL; CONSOLIDATION_REPORT_INTERVAL_SECONDS documented (MIG-10 closed). Upgrade note: existing fms_logs volume needs a one-time chown.
  • CRM: a member email change also re-syncs the vacated address (strips their tags off it). → docs/progress/2026-08-25.md

2026-08-15 — d6d2d88 ​

  • Billing: renewal-time SCA handled — invoice.payment_action_required captured in member_invoices.action_required with the hosted invoice link, a bounded subscriptions.sca_hold_until access window (migration 0033_sca_hold), a member "Confirm payment" banner (plugin v0.29.0) and a shared renewal_sca health alert (FIN-13 closed). Requires re-provisioning every Stripe webhook endpoint (ops/RUNBOOK.md §6b). → docs/progress/2026-08-15.md

2026-08-14 — 02567c4 · 62a452f · c235c31 ​

  • Docs: all living docs synced to the 2026-08-13 state; Aug 8–14 stakeholder update.
  • Billing: gift → paying conversion anchors the first charge to the gift's end date and retires the comp row (spec 06 FR-12f). Welcome page; donate link fix.

2026-08-13 — a967940 … 4908bb6, e5103c9 ​

  • Feature: 3DS/SCA challenge completion on /checkout, /donate, /gift — park-and-resume with per-surface finalize endpoints that re-read the verdict from Stripe (migration 0032_pending_payments, tests/sca.rs). Live-verified through Stripe's real challenge iframes.
  • Fix: one-off PaymentIntents were rejected by Stripe (missing payment_method_types[]=card) — donations and gifts had never worked against a real account (42678fe).
  • Fix: admin cancel lands the member on free Community; connect stores the real acct_… id.
  • Feature: grant bookkeeping dates starts_at / collected_at (migration 0031_grant_dates).
  • Ops: Sprint 8 closed — nightly pg_dump → R2 (7 daily + 12 weekly), deploy.sh, restore.sh, ops/backup.sh, ops/RUNBOOK.md; restore drill row-count-identical.
  • Security: live-mode Stripe write brake — every mutating call to a live account refused unless ALLOW_LIVE_STRIPE_WRITES=true (OPS-2). tests/live_write_brake.rs.
  • Security: per-IP rate limit on POST /checkout/discount (TRUST_PROXY behind a proxy).
  • Verified: every Stripe write path live-fired against real TH+SG sandbox accounts (11/12 rehearsal scenarios; seat amend, cancel, silent recreate, discounts, donations, gifts). → docs/progress/2026-08-12.md, docs/progress/2026-08-13.md

2026-08-10 — d4be250 … e4d33c3 ​

  • Feature: donations (/donate), gift memberships (/gift), internal discount codes (once / forever), sharing src/cardform.rs and create_payment_intent (a7a340f).
  • Feature: gift newsletters — deliver to another address (spec 06 FR-7a).
  • Fix: duplicate migration 0025 renumbered to 0026 (boot blocker B0 closed); one cutover runbook made authoritative and PMPro end dates preserved on import (B1 closed).
  • Security: production refuses to boot misconfigured (APP_ENV=production guard — SEC-1/2/4).
  • CI: pipeline with a duplicate-migration guard.
  • Docs: docs/RISK-ASSESSMENT.md — the pre-cutover risk register (SEC-3 accepted). → docs/progress/2026-08-10.md

2026-08-08 — c8a74b2, 3d41255 ​

  • Feature: email sync Stripe → FMS → WordPress plugin.
  • Feature: a team admin can convert out of their team plan.

2026-08-01 — docs/progress/2026-08-01.md ​

  • Ops: payment-health check (the standing TH→SG rescue audit), guided rescue flow, live-fire rehearsal runner on the admin Testing screen, live/test mode awareness, self-firing payment alerts, and Consolidation explaining every check and fix.

Earlier ​

Sprints 0–5 and 7 (June–July 2026): reliability backbone, standalone-keys routing rework, plans/checkout/newsletter tags, access API + WordPress plugin, admin SPA, health monitor, grace/expiry sweep, team seat management. Records: docs/archive/progress/, docs/ROADMAP.md.

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