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_toreplacejoined_daysonGET /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 (sharedcomponents/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). NewPOST /admin/api/team-invoices/preview,POST /admin/api/team-invoices(queued,verified_by+requested_by_admin= the staff account, website checkout switch not required) andPOST /admin/api/team-invoices/{id}/resend(open only, once per 10 minutes,resend_count), all Finance; list rows gainrequested_by_admin,resend_count,team_id, team rowscollection_method; invoiced seats 3–100 (was 3–19, migration0101). 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.
SECTIONSis 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);StripeDriftandPaymentRiskcarryuser_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-onlyGET /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/paymentsgainsmonth=YYYY-MM, replacing the window), narrowed by the segment clicked. The chart kit (StackedBars,DivergingBars,AreaLine) getsselected/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::attentiononGET /admin/api/dashboard's newattention; Plumbing becomes Machinery (developers and owners only, tiles Updates waiting / Failed updates). Every page header has a Guide button to its staff-guide page whenSTAFF_GUIDE_URLis set (sent onGET /admin/api/measstaff_guide_url; map infrontend/src/lib/guide.ts). Links to technical screens on business pages are shown only to roles that can open them;window.confirmreplaced 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. Migration0099. 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}, auditedmigration.step, stored inmigration_marks, migration0100);GET /admin/api/migrationis 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/donatepage 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
typesfacet with it; an old?type=link is ignored); sort is Newest first / Oldest first / Most read (Widest reach and Longest reads removed, an oldsort=sent|longestlink 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:memberor a grantingstatus) on posts, the site default, the gate block and[fms_gate cap="any_member"];members_onlyrelabelled 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
_inLIKE 'pmpro_%stripe_connect_%'matchedpmpro_stripe_connection_test; the filter drops it too); the dump stage refuses a staledatabase.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-clockpast_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 thecus_/sub_rehearsal_join); test keys typed in only (sk_test_/rk_test_, probed); resumable state + report CSV underexports/; test-clockpast_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) andops/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 fromFMS_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;lockandpmproremain),fms_mg_last_answer_hours0–72 (default 24;FMS_MG_LAST_ANSWER_HOURSwins), 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 onpmprobehaves aslast_answeronce PMPro is inactive; a site that never saved the setting moves fromlocktolast_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) entersgrace/dunninguntil the later of the period start and now +GRACE_PERIOD_DAYS(30); a paid retry returns it to active, Stripe's cancel orunpaidends 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). Suiteswebhooks,grace,reroute,migration_segments,consolidationand 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 (coversrestore.sh), keeping the last active owner; superseded rows pruned byservices::retention. The Backup page counts production (backup::statusreadsauth_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 (409sandbox_has_live_accounts). A gift link opens a confirm page and the claim isPOST /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 aMemberTagResync(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/finalizeacts only on a row markedsubscriptions.checkout_parked_atand provisions it once; any other row is answered locally with no Stripe call. The discount quote never looks up a posted email.POST /checkoutand/checkout/finalizeget 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_claimno longer re-checks the cap the claim took). A booked Opn plan change drops aforeverdiscount (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/finalizeacts 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 —
promocard,bannerstrip,railsidebar 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 guidewordpress/archive-spotlight; pluginreadme.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 createdtrialinguntil 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,/pricingand Member Gate show First N days free;delay_dayson/v1/plans,/v1/plans/alland Claude'slist_plans;customer.subscription.trial_will_endregistered (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 moredisplay: 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.shwrites the dump undertmp/(trap-cleaned), uploads onlyfms-*.dump.gz(.age)and refuses a recipient that is notage1…;restore.shrefuses an option without its value and a passphrase-protected key, and stages a fetch asrestore/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): withSTRIPE_API_VERSIONset, a new webhook endpoint is created with it as itsapi_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 unpinnedGET /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 andstaff-resetend the account's other sessions (sessions_valid_after, 401session_ended; the person's own browser is re-issued); every console write must come from the console's own pages (Sec-Fetch-Site/Origin, 403cross_site_request); optionalADMIN_SESSION_SECRETgives 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 (agein its image);restore.sh --identitydecrypts; 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-paywalledand gated articles carry Google'sisAccessibleForFree: falsehasPart(Yoast's Article piece, else one JSON-LD block).includes/paywall.php, newphp 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.mdworking 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/seriesread as a lost role. Now only the archive's ownmanagers_onlysays that; a check gets the security-service message and a cross-origin save Reload the page and try again (archive/web/manage.jsfailMessage). - 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 signedenv, a link names one with?env=<label>(env_scope; 409environment_mismatch, 401environment_gone/wrong_environment, 404unknown_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. NewGET /v1/environment; Member Gate 0.55.0 follows it; archiveFMS_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 aworkerscomponent (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; pureincludes/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’.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-Secretwith every archive export call when both are set (FMS_AS_CF_ACCESS_CLIENT_ID/_SECRETinwp-config.phpwin). 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 withfms_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.shskips 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-keyedGET /api/export/issues[/{id}](off untilARCHIVE_EXPORT_KEYis 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
newslettersto the hand-off redeem and toGET /v1/plans/all, and a publicGET /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'sis_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 amember_email_changesrow (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 pullsGET /v1/users/email-changesand answers each onPOST /v1/users/email-changes/{id}/website, so a member who never signs in again is updated too. An address WordPress refuses is answeredrejected; a row WordPress cannot write is skipped and retried, never blocking the feed (the mint'semail_mismatchself-heal stays the fast path; held members wait for hand-over; the backfill isbefore_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_tokennow reads the browser's token atGET vault.omise.co/tokens/{id}with the account's public key (Omise's Tokens API), notapi.omise.cowith the secret key, which answersnot_found. Fixes checkout, donations, gifts and the member card change at once. The mock now answersnot_foundon the API host and wants apkey_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/fixwithadd_absent. Each is the signup's ownMemberTagResync(create_if_absent, sourcereconcile_add); refused addresses, members PMPro still holds and members who get no newsletter now are counted and left out; samemailchimp_fix_refusalgate, audited like every console write. Suiteconsolidation. 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
PlanPricePropagateper 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_pricesrepointed, the plan'sforeverdiscount prices dropped, the old prices archived byArchiveStripePriceorders. 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_pricekeys name the amount. Edit plan says how many accounts follow; Consolidation → Plan prices ↔ Stripe counts propagations waiting and refused. Suitesplan_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.ymlnow 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 testcompose_passes_every_env_var_the_app_readskeeps compose and.env.examplecomplete; 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 anddocs/SETUP.mdcorrected (PUBLIC_BASE_URLrequired,STRIPE_SECRET_KEYnot 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 onbilling_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. Suiteconnection_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. Suiteopn_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 fromPUBLIC_BASE_URL; the name stays as the image's alt text for clients that block remote images.Rendered::with_originfills 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,/giftand/accountheaders 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_erronAppResult, now aclippy.tomlthreshold;double_must_usefrom async_trait's generated code, allowed on the two traits; onerfind, onesort_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/migrationURL). Staff guide updated.
2026-10-15
- Console: the import panel and newsletter opt-outs (Members, owners). Bulk import shows the export file's
notesabove 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 frommigration_marks(migration0089), 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_aton 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 moreexternalteam grant;notes.stripe_team_owners); the level map honours the checkout map's filter;notes.skipped_membersnames the unmapped-level members; the CLI writesnotes.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 gainsmode: "verify"(every row classified, nothing written),file/whole_file(lists held members the export no longer carries) andreconcile_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 Stripeteam_*row and is never shrunk; new report fieldsmatched_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 storesstart_dateasstarts_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.shis 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 armsALLOW_LIVE_STRIPE_WRITESfor 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, migration0088): 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 409legacy_held; Mailchimp, lifecycle mail and outreach skip them; a structural failure dead-letters aslegacy_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 saysmanaged_by;GET /v1/migration/statusfeeds 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. Suitemigration_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, mirroringpmpro_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.shflattens 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 withphp -land 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_passwordbefore 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 (403default_password, naming an owner's temporary password andstaff-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.rsnow drives all four enqueue sites (structural reroute, unwind, checkout decline cleanup, finalize) through their real entry points: a failed inline cancel leaves exactly oneEnsureStripeCancelorder, 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.mdFIN-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 paddedseedpass123got 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,/donateshows 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'squoted_currency/amountis 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 onopn_charge_attempts;cardform::moneyis currency-correct (฿4,120.00 THB); success panels show the amount charged (+ the FMS receipt);/v1/me/membershipserves 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/planscurrency_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, migration0086) from Omise'sGET /forex/usdeveryFX_RATE_REFRESH_SECONDS, 5 % jump guard → owner accepts, typed-rate option, stale afterFX_RATE_MAX_AGE_HOURS; healthfx_rate. Owner-only Local-currency pricing on the Opn card (rounding, conversion-fee allowance behind a confirmed warning,…/pricingroutes). Renewals re-rated each period with the terms stored onsubscriptions.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. Everyplan_pricesrow 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, migration0085, owner-onlyPOST /admin/api/connections/accounts/{id}/enabled). A disabled account takes no new business —routing::route_candidatesskips 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_mainunchanged. 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_pricingalready 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'smember_billing::cancel_at_period_endfor a card-billed individual membership — Stripecancel_at_period_end=true(no DELETE), Opn the local flag — with Resume until then; when such a row ends (Stripe'scustomer.subscription.deleted/canceledwebhook, 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/billingsend 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). APIGET /v1/me/billing(managed_in: stripe_portal | fms),…/billing/card{,/start}(Opn only; a Stripe row is refusedcard_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). Suitesmember_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_dedupenow 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 /chargessends its attempt row's id as Omise's duplicate-chargeref_id(ledger and never-retry kept; the reconcile re-sends only after live-fireopn_ref_id_dedupepasses); dynamic 3-D Secure pinned — first charges sendreturn_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 reasonvoided); live-fire gainsopn_ref_id_dedupeandopn_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/routinggainsaccountsandbrands(oldroutingrows kept, pluscandidates); 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'sno_pricerefusal 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:60sort_by_key,src/ratelimit.rs:386rfind); 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 stillactive, 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 anaction_requiredinvoice 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 isincompleteuntil Omise sayssuccessful; 3-D Secure redirects to the bank and returns atGET /checkout/return;once/foreverdiscounts computed in FMS; a gift holder continuing is charged now with the term from the gift's end; provisioning via theOpnMembershipProvisionoutbox event.46c677d. - Opn reconcile: every
OPN_EVENT_POLL_SECONDS(default 900) per Opn account,GET /eventsfrom a cursor (opn_event_cursor), lost charge answers found by metadata after an hour, abandoned 3-D Secure checkouts retired after a day; owner-onlyPOST /admin/api/connections/opn/{id}/reconcileruns 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_toolattempt 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, feefee + fee_vat, net, and the rate when currencies differ, through the sameFetchSettlementevent; 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 theOmise-SignatureHMAC with the base64-decoded secret (either signature during a 24 h roll), dedupes byevnt_…, archives and enqueuesOpnEventReceived; 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:
/donateand/giftcharge through Omise when the card routes there; a 3-D Secure charge parks the row and redirects to the bank, back atGET /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_candidatesorders bystripe_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 ofALLOW_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.sourceandintegration_probes.kindaccept Opn, newopn_charge_attemptsandopn_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, therenewal_scaalert 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.testrows inside one transaction, drives FMS's real code (synthetic Stripe events through the webhook capture, nowwebhooks::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.confirmon 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 gainsSTUB_LATENCY_MS/STUB_MAX_CONCURRENT, and migration assistant 0.3.2 fixes its help text.CRM_SYNC_DISABLEDdiscards 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-modeboth) 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 fillsbackwards(no transform once it ends), the sharedDialogportals to<body>, and Record payment usesDialog(Escape, scroll for a tall form). The per-dialog workarounds are gone. Commit onclaude/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-resetclears it;staff-totpsets 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/sendmoved 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 tierPermission::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_paidand 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. ThePaymentFailedReroutepayload 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_boardandrisk_boardnow 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 andGUIDE_FEED_POLL_SECONDSare gone; an edit to a record reaches Claude at the next FMS deploy. New optionalSTAFF_GUIDE_URL(links into the guide);deploy.shstampsFMS_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) andread_roadmapfor every role,decision_boardfor owners and finance,risk_boardfor owners; a tool a role may not use is not listed and is refused (audited) if called. They answer fromai/records.json, which every staff-guide build now publishes from the four plain-language records only (neverDECISIONS.md/RISK-ASSESSMENT.md), and which FMS fetches hourly with a Cloudflare Access service token (Settings → Connections → Staff guide for Claude, Health componentstaff_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/mcpopen 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/mcpcall without theFMS-Claudetoken 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) andAiConnectionAdminNotice(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_guideandread_guide_page, answer how-to questions fromdocs-site/, compiled into the binary by the newbuild.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 --buildhad failed sinceb6fb360. The release compile of thefmscrate peaks at ~3.4 GB, so a 4 GB host OOM-killsrustc:deploy.shnow refuses to build below 6 GB of RAM + swap andops/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, migration0073), 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 range160.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/REFERENCESand sequence reads, and the script closespublicCREATE 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 guideadmin/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/revokeneeds the app's ownclient_id, and a restore ends every Claude connection and re-appliesops/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. Migration0072ends all existing connections once. Staff guideadmin/ai-assistant,admin/ops/email.docs/progress/2026-10-04.md, commit48dbe7b.
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.Outgoinggainedcc; theai.sign_inAudit row carriesnew_connection. Staff guideadmin/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_ENABLEDneeds an httpsPUBLIC_BASE_URL. Staff guideadmin/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_paymentsfilters the payer by exact address; the token exchange is one transaction;find_membersagrees withmember_statuson who has access; every call spends the person's throttle first;PUBLIC_BASE_URLis parsed;redirect_urifollows 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 requiresclient_idon both grants, a refresh token reused within 30 seconds is refused without ending the connection,find_memberstakes "member now", failed-renewal and unpaid-team-invoice from the member list's own columns (subscriptions::granting_for), and/mcpauthenticates in one query. SEC-18 also strips?password=. Staff guideadmin/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::verifychecksNEVER_READ_COLUMNS): re-runops/ai-reader.sqlon 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 usehttp://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_WRITESarms 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_PASSWORDrefuses to boot; an account still on one is held to Your account; compose no longer suppliesseedpass123.docs/progress/2026-10-02.md. - SEC-1/2/4:
deploy.shrequiresAPP_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 = noneandhttp://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.phponly; 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/ladderand the console's Reading level in the archive are gone (migration0065); the hand-off redeem now names the member'splansandGET /v1/plans/alllists 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 atGET /v1/archive/ladderand 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 guidewordpress/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-manageris replaced byPOST /admin/api/admins/{id}/archive-managerandGET /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/apiroute 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 arerefusedaudit 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 binarystaff-reset <email>when no owner can sign in (ops/RUNBOOK.md§7). Staff guideadmin/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 newservices::retentionprunes 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 nextsyncorbuildre-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 asadmin.email_change/admin.password_changewith 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
requiredplus 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.mdnames 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). Newdocs/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 thearchive_managecapability, which also grantsarchive_access. In the archive such a member gets Manage newsletters: rename, recolour, reorder or hide each newsletter, applied at once without a rebuild (series_overridesin 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.jsonmarks 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). Afms:lapsedMailchimp 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, filterfms_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; onlyfms_*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 stableMessage-IDper 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, notnoreply@; the status block shows Replies go to.SMTP_SECURITYis validated at boot.EMAIL_SINKdry run,mail-testCLI,SMTP_REPLY_TO/SMTP_BOUNCE_ADDRESS; the rehearsal rig's FMS mail to Mailpit works again. DECISIONS #9 EM5 amended, EM8–EM10. 2026-09-28The 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 ongifts::live_grant_for).GET /v1/me/membershipgainsteam_seatandgift. 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_redirectto 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/accessreportspast_dueduring 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/subscriptionsno longer returnsregion.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_bandflag is gone; the fact lives in a paidpayment_recordentry of the invoice'spaymentslist, 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 realacct_…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(migration0057, boot database, keptACTIVITY_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 fromhandoff_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, migration0056; 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}withoutissuesis not taken for the archive (the Connections card no longer crashes on it); every console validation message now reads as its own sentence — thevalidation 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/healthevery 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 inhandoff_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/searchranks 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/healthaddsfailing(which issues, since when;campaigns.content_error_since). The load-failure page no longer tells readers to runnpm 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_accesscapability to every member holding a ticked plan, derived on read at/v1/me/access,/v1/access/canand 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 storedtier_fanaticonto it once. Change the archive server'sARCHIVE_REQUIRED_CAPin 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.
- Plans → edit has an Access box, Grants access to the newsletter archive; the plan list shows an archive badge. FMS issues a new
- 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
syncexit 1, sosync && buildkeeps building./api/healthignores aborted runs, addsfailing_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.
/authnow lands on/?signed=1so 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_openmarker, and a log-out that finds it redirects through the archive's/logout?return=…, which clears the cookie and followsreturnonly 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 permanentAppError::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_logsgainsrequest_id,member_id,stripe_account_id,event_type,outbox_id(migration0051), 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. Newlog_historyhealth 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_logswith structured columns, or add Loki / VictoriaLogs / ClickHouse). Recommendation B.docs/DECISIONS.md§12. - Newsletter archive live at
archive.frontiermyanmar.netwith the back catalogue restored from 2020 — grant metric 4 met a month early.docs/ROADMAP.mdGrant frame,docs/status.json(statelive).
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 fromdocs/status.json(new, hand-maintained) and by parsing the records (project/records.data.ts); each record page then folds the full text ofdocs/ROADMAP.md,CHANGELOG.md,docs/DECISIONS.mdordocs/RISK-ASSESSMENT.mdinto 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.mdgains 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
0050drops the import job'sstarted_byFK, 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, migration0047). 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_duesubscription becomes dunning grace (grace_source, migration0048) until the later of its paid-through date andGRACE_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 addsoutbox_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 honourStripe-Should-RetryandRetry-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(migration0046), 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 usesPATCH, 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_idto the importer's member by Stripe customer id, then subscription id (index, migration0049). It never creates or renames, and reports address mismatches and unmatched customers. The--include-stripe-billedswitch 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) andwordpress/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 newshots/archive-preview.sh, which shows the 5 real published issues (no subscriber data; the footer wording says so). The missingops/connections/04-routingshot 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.mdSEC-11–16 plus SEC-7–9 updates; deferred hardening options indocs/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 startno longer exits whensite/is empty (a fresh volume before the first sync+build); it serves a branded 503 being prepared page that refreshes itself, while/api/healthstaysok: 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 to127.0.0.1:8140, certbot-ready); setup steps inarchive/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.tsreads 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 aseriesfacet inindex.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_objectsfilter 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). Filtersfms_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/menow carriessiteUrl/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'sget_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>/authontemplate_redirect; guests and members without the tier still see the landing section;?fms_archive=pageshows 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 publicGET /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_notefilter.docs/progress/2026-09-21.md. - Plugin 0.33.1 — archive hand-off no longer 400s:
admin-post.php?action=fms_mg_archivenow 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 DATEfor a small first run; the Mailchimp client is pinned GET-only (tests assert the method);--showand search snippets print the cleaned article text; publicGET /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_ofandentitlements::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_planenqueues oneMemberTagResyncper 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 isGRANTING_STATUSES, shared withapply_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 endpointsGET /admin/api/reports/{subscribers,payments};csvfmtshared 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 newcurrencyparameter had Stripe refusing the create for 24 hours;email_settings::resolvestill 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,dironGET /admin/api/members) with a#column; grants gain Authorized by (migration0043,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 (migration0044,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
0042addsrefunded_gross/fee/nettopayment_settlements, read from the refunds' balance transactions by a newSyncRefundsoutbox event (queued bycharge.refunded, full or partial) and at capture time for a charge already refunded;monthly_summarynets them off in the payment's month and exposesrefunded. 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 syncpulls every sent Mailchimp campaign since 2019 (metadata, engagement stats, the full campaign JSON,html/archive_html/plain_text) intoarchive/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) andnpm run buildemits 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 startserves it for Fanatic members only: WordPress mints a member token and hands the browser to/auth?token=…, the server checksGET /v1/me/accessfortier_fanaticand 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 searchexercises 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. SharedFilterBarcomponent. Testtests/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 than0%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 — asmultipart/alternativetext + 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). Migration0041addsteam_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
currencyon 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, testissue_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(migration0040), 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/outboundhands WordPress empty batches, admin alerts use the same relay, and Health gains amember_emailcomponent. No relay ⇒ WordPress keeps sending, exactly as before. Decision #9 indocs/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 inverified_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 intests/team_invoice_http.rsbrought 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 internalhttp://fms-app:8080API URL and Cloudflare's bot check. Admin shots from the rehearsal rig (shotsrunner, 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 sharedcardform::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.tsnow unwraps{error:{message}}; the transfer-instructions field is labelled required. - WordPress → FMS internal route:
wordpress-plugin/docker-compose.ymljoins thefms_defaultnetwork so the plugin's server-to-server calls usehttp://fms-app:8080and never cross Cloudflare, whose bot challenge was returning 403 (cf-mitigated: challenge) to everywp_remote_post— the "could not reach FMS / key rejected" banner. - Production nginx:
ops/nginx/fms.frontiermyanmar.net.confproxies every path to the FMS container. The previous site config served a hand-copied SPA from/var/www/fms-adminwith arootthat never matched/admin/assets/*(every bundle request fell back toindex.html→ the module-script MIME error) and only whitelisted some API paths, so newer public routes such as/team-invoicesnever 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 returningindex.htmlastext/html), bundles carryCache-Control: immutable, andindex.htmlisno-cache— fixes the "Expected a JavaScript module … MIME type text/html" break after a deploy (browser/Cloudflare holding a staleindex.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 nightlypg_dump | gzipand ships it to R2 with the same object layout and 7 + 12 retention asops/backup.sh, sorestore.shreads either. Save verifies with Cloudflare first; Test connection and Upload snapshot now buttons; a new non-criticaloffsite_backuphealth component alerts when no snapshot has landed for 26 h. New hand-rolled SigV4 clientsrc/r2.rs, migration0038_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.manualmarker 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 indocs/README.md.docs/progress/2026-09-10.md. - Dev-server proxy target is configurable (
frontend/vite.config.ts):VITE_API_TARGEToverrides the hard-codedhttp://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 hasbase: "/admin/", andsrc/admin/mod.rsnests the bundle at/adminbeneath the JSON routes. Before this the bundle sat at/spaasking for/assets/…, which 404'd, so the built image served the old console and the React one was unreachable outsidevite dev. Member-facing pages (/checkout,/donate,/gift,/pricing,/auth/session) untouched. Dev URL is nowhttp://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=Nserves per-UTC-month active members (split by source), new members, churned, returning and the churn/retention rates, from the localsubscriptionscache 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 isnull, never 0%. Needs migration 0037subscriptions.ended_at, stamped on every terminal status flip and cleared if a row recovers (dunning is not an ending); existing terminal rows backfilled fromupdated_atand marked approximate in the UI. New seeds1_growth_history, new suitetests/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 droppedcharge/payment_intent; unexpandedpaymentsis 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_skippedonPOST /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_mutestable +newsletter_preferences.source(member | admin | team_admin), migration0036. Every suppression is applied inside the singlecrm_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 indocs/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 inops/RUNBOOK.md§6d. The refund-before-fetch and 2025 payments-list fixes from the review are indocs/progress/2026-09-02.md. - Net revenue (Reports tab): a
payment_settlementsledger (migration0035) 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 newFetchSettlementoutbox 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_withdrawnflips 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
externalgrant 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 dropexternal_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_notestable (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). Migration0034. →docs/progress/2026-09-02.md, decisions indocs/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_WRITESbrake. →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.mdFIN-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-onlyMAILCHIMP_API_BASE_URL. →docs/progress/2026-08-29.md,docs/migration-runbooks/local-rehearsal.md, decisions indocs/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/wiperefuses, new clones stripmailchimp_config(SEC-6 closed). New suitetests/crm_gate.rs;MailchimpClient::with_base_urltest seam. - Billing: the checkout
create_subscriptionidempotency key now hashes price / card / quantity / anchor, so a retry with a different card is no longer a 24 hidempotency_errorlock-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 indocs/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.mdwritten 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
EnsureStripeCanceloutbox 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;5432and8137bind to loopback only; compose passes env by explicit whitelist;HEALTH_ALERT_WEBHOOK_URL;CONSOLIDATION_REPORT_INTERVAL_SECONDSdocumented (MIG-10 closed). Upgrade note: existingfms_logsvolume needs a one-timechown. - 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_requiredcaptured inmember_invoices.action_requiredwith the hosted invoice link, a boundedsubscriptions.sca_hold_untilaccess window (migration0033_sca_hold), a member "Confirm payment" banner (plugin v0.29.0) and a sharedrenewal_scahealth 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 (migration0032_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(migration0031_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
liveaccount refused unlessALLOW_LIVE_STRIPE_WRITES=true(OPS-2).tests/live_write_brake.rs. - Security: per-IP rate limit on
POST /checkout/discount(TRUST_PROXYbehind 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), sharingsrc/cardform.rsandcreate_payment_intent(a7a340f). - Feature: gift newsletters — deliver to another address (spec 06 FR-7a).
- Fix: duplicate migration
0025renumbered to0026(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=productionguard — 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.